using Microsoft.EntityFrameworkCore; using Microsoft.SelfService.Portal.Core.API.Context; using Microsoft.SelfService.Portal.Core.API.Interfaces; using Microsoft.SelfService.Portal.Core.API.JsonDocuments; using Microsoft.SelfService.Portal.Core.API.Models; using System.Text.Json; namespace Microsoft.SelfService.Portal.Core.API.Services { public class QueueJobService : IQueueJobService { private readonly DataContext _context; public QueueJobService(DataContext context) { _context = context; } public Guid EnqueueTemplateJsonChanged(Guid templateId, string oldJsonData, string newJsonData) { var deployments = _context.Deployments .AsNoTracking() .Include(deployment => deployment.DeploymentGroup) .Where(deployment => deployment.DeploymentGroup.TemplateId == templateId) .ToList(); var payload = new { TemplateId = templateId, OldJsonData = oldJsonData, NewJsonData = newJsonData, TargetCount = deployments.Count, Created = DateTime.UtcNow }; var queueJob = new QueueJobModel { Type = QueueJobType.TemplateJsonChanged, Status = QueueJobStatus.Pending, CorrelationId = Guid.NewGuid(), PayloadJson = JsonSerializer.Serialize(payload), Targets = deployments.Select(deployment => new QueueJobTargetModel { TargetId = deployment.TargetId, DeploymentGroupId = deployment.DeploymentGroupId, TemplateId = templateId, Status = QueueJobStatus.Pending }).ToList() }; queueJob.Steps = BuildDefaultJobSteps(); _context.QueueJobs.Add(queueJob); _context.SaveChanges(); return queueJob.Id; } public Guid EnqueueDeploymentRequest(Guid deploymentGroupId, ICollection targetIds, string jsonData, Guid? deploymentArtifactId = null) { var deploymentGroup = _context.DeploymentGroups .AsNoTracking() .Include(group => group.Template) .ThenInclude(template => template.DeploymentRule) .ThenInclude(rule => rule.Steps) .Include(group => group.TemplateSelections) .ThenInclude(selection => selection.TemplateVersion) .ThenInclude(version => version.Template) .Include(group => group.TargetAssignments) .Include(group => group.DeploymentRule) .ThenInclude(rule => rule!.Steps) .FirstOrDefault(group => group.Id == deploymentGroupId); if (deploymentGroup == null) { throw new InvalidOperationException("DeploymentGroup does not exist."); } var currentArtifact = deploymentArtifactId.HasValue ? _context.DeploymentArtifacts .AsNoTracking() .FirstOrDefault(artifact => artifact.Id == deploymentArtifactId.Value && artifact.DeploymentGroupId == deploymentGroupId && !artifact.IsStale) : _context.DeploymentArtifacts .AsNoTracking() .Where(artifact => artifact.DeploymentGroupId == deploymentGroupId && artifact.ArtifactType == DeploymentArtifactTypes.ResolvedConfigurationData && !artifact.IsStale) .OrderByDescending(artifact => artifact.Created) .FirstOrDefault(); if (deploymentArtifactId.HasValue && currentArtifact == null) { throw new InvalidOperationException("Deployment artifact does not exist or is stale."); } if (currentArtifact != null) { jsonData = currentArtifact.DeploymentJson; } else { var deploymentDocument = ConfigurationDocumentValidator.NormalizeAndValidate( jsonData, ConfigurationDocumentKind.DeploymentOverride); jsonData = deploymentDocument.JsonData; } var primaryTemplateSelection = deploymentGroup.TemplateSelections .OrderBy(selection => selection.SortOrder) .FirstOrDefault(); var templateId = primaryTemplateSelection?.TemplateVersion.TemplateId ?? deploymentGroup.TemplateId; var resolvedTargetIds = targetIds .Distinct() .ToList(); if (resolvedTargetIds.Count == 0) { resolvedTargetIds = deploymentGroup.TargetAssignments .OrderBy(assignment => assignment.SortOrder) .Select(assignment => assignment.TargetId) .Distinct() .ToList(); } if (resolvedTargetIds.Count == 0) { throw new InvalidOperationException("No target Targets provided."); } var existingTargets = _context.Targets .AsNoTracking() .Where(target => resolvedTargetIds.Contains(target.Id)) .Select(target => target.Id) .ToHashSet(); var missingTargets = resolvedTargetIds .Where(targetId => !existingTargets.Contains(targetId)) .ToList(); if (missingTargets.Count > 0) { throw new InvalidOperationException($"Unknown Target IDs: {string.Join(", ", missingTargets)}"); } foreach (var targetId in resolvedTargetIds) { var deployment = _context.Deployments .FirstOrDefault(existing => existing.DeploymentGroupId == deploymentGroupId && existing.TargetId == targetId); if (deployment == null) { deployment = new DeploymentModel { DeploymentGroupId = deploymentGroupId, TargetId = targetId, Status = QueueJobStatus.Pending, JSONData = jsonData }; _context.Deployments.Add(deployment); } else { deployment.Status = QueueJobStatus.Pending; deployment.JSONData = jsonData; } } var resolvedRule = deploymentGroup.DeploymentRule ?? deploymentGroup.Template?.DeploymentRule; var payload = new { DeploymentGroupId = deploymentGroupId, TemplateId = templateId, DeploymentRuleId = resolvedRule?.Id, TemplateSelections = deploymentGroup.TemplateSelections .OrderBy(selection => selection.SortOrder) .Select(selection => new { selection.Id, selection.TemplateVersionId, selection.TemplateRevisionId, TemplateId = selection.TemplateVersion.TemplateId, TemplateName = selection.TemplateVersion.Template.Name, selection.TemplateVersion.Version, selection.TemplateVersion.JsonHash, selection.TemplateRole, selection.SortOrder, selection.Alias }), TargetAssignments = deploymentGroup.TargetAssignments .OrderBy(assignment => assignment.SortOrder) .Where(assignment => resolvedTargetIds.Contains(assignment.TargetId)) .Select(assignment => new { assignment.Id, assignment.TargetId, assignment.RoleKey, assignment.SortOrder, assignment.NodeDataJson }), TargetIds = resolvedTargetIds, DeploymentArtifactId = currentArtifact?.Id, DeploymentJson = currentArtifact?.DeploymentJson, JsonData = jsonData, TargetCount = resolvedTargetIds.Count, Created = DateTime.UtcNow }; var queueJob = new QueueJobModel { Type = QueueJobType.DeploymentRequested, Status = QueueJobStatus.Pending, CorrelationId = Guid.NewGuid(), PayloadJson = JsonSerializer.Serialize(payload), RuleSnapshotJson = resolvedRule != null ? SerializeRuleSnapshot(resolvedRule) : null, Targets = resolvedTargetIds.Select(targetId => new QueueJobTargetModel { TargetId = targetId, DeploymentGroupId = deploymentGroupId, TemplateId = templateId, Status = QueueJobStatus.Pending }).ToList() }; queueJob.Steps = BuildDeploymentSteps(resolvedRule); _context.QueueJobs.Add(queueJob); _context.SaveChanges(); return queueJob.Id; } public bool RetryQueueJob(Guid queueJobId) { var queueJob = _context.QueueJobs .Include(job => job.Targets) .FirstOrDefault(job => job.Id == queueJobId); if (queueJob == null) { return false; } queueJob.Status = QueueJobStatus.Pending; queueJob.ErrorMessage = null; queueJob.Finished = null; queueJob.LockedUntil = null; queueJob.LockedBy = null; queueJob.HeartbeatAt = null; queueJob.WorkerName = null; queueJob.Steps ??= new List(); foreach (var target in queueJob.Targets) { if (target.Status == QueueJobStatus.Failed || target.Status == QueueJobStatus.Cancelled) { target.Status = QueueJobStatus.Pending; target.ErrorMessage = null; target.Started = null; target.Finished = null; target.OutputMetadataJson = null; } } foreach (var step in queueJob.Steps) { if (step.Status == QueueJobStatus.Failed || step.Status == QueueJobStatus.Cancelled || step.Status == QueueJobStatus.WaitingForApproval || step.Status == QueueJobStatus.Rejected) { step.Status = QueueJobStatus.Pending; step.ApprovedAt = null; step.ApprovedBy = null; step.ApprovalComment = null; step.Started = null; step.Finished = null; step.ErrorMessage = null; step.OutputMetadataJson = null; } } return _context.SaveChanges() > 0; } public bool ApproveQueueJobStep(Guid queueJobStepId, string approvedBy, string? comment) { var step = _context.QueueJobSteps .Include(existing => existing.QueueJob) .FirstOrDefault(existing => existing.Id == queueJobStepId); if (step == null || step.StepType != QueueJobStepType.Approval) { return false; } step.Status = QueueJobStatus.Succeeded; step.ApprovedAt = DateTime.UtcNow; step.ApprovedBy = approvedBy; step.ApprovalComment = comment; step.QueueJob.Status = QueueJobStatus.Pending; step.QueueJob.LockedUntil = null; step.QueueJob.LockedBy = null; step.QueueJob.HeartbeatAt = null; step.QueueJob.WorkerName = null; step.QueueJob.ErrorMessage = null; return _context.SaveChanges() > 0; } public bool RejectQueueJobStep(Guid queueJobStepId, string approvedBy, string? comment) { var step = _context.QueueJobSteps .Include(existing => existing.QueueJob) .FirstOrDefault(existing => existing.Id == queueJobStepId); if (step == null || step.StepType != QueueJobStepType.Approval) { return false; } step.Status = QueueJobStatus.Rejected; step.ApprovedAt = DateTime.UtcNow; step.ApprovedBy = approvedBy; step.ApprovalComment = comment; step.QueueJob.Status = QueueJobStatus.Rejected; step.QueueJob.Finished = DateTime.UtcNow; step.QueueJob.LockedUntil = null; step.QueueJob.LockedBy = null; step.QueueJob.HeartbeatAt = null; step.QueueJob.WorkerName = null; step.QueueJob.ErrorMessage = comment ?? "Deployment step rejected."; return _context.SaveChanges() > 0; } private static List BuildDefaultJobSteps() { return new List { new() { Id = Guid.NewGuid(), SortOrder = 1, Name = "Provision", StepType = QueueJobStepType.Provision, Status = QueueJobStatus.Pending } }; } private static List BuildDeploymentSteps(DeploymentRuleModel? deploymentRule) { if (deploymentRule?.Steps == null || deploymentRule.Steps.Count == 0) { return BuildDefaultJobSteps(); } var steps = deploymentRule.Steps .OrderBy(step => step.SortOrder) .Select(step => new QueueJobStepModel { Id = Guid.NewGuid(), SortOrder = step.SortOrder, Name = step.Name, StepType = step.RequiresApproval ? QueueJobStepType.Approval : step.StepType, Status = QueueJobStatus.Pending, MetadataJson = step.MetadataJson }) .ToList(); for (var i = 1; i < steps.Count; i++) { steps[i].DependsOnQueueJobStepId = steps[i - 1].Id; } return steps; } private static string? SerializeRuleSnapshot(DeploymentRuleModel? rule) { if (rule == null) { return null; } var snapshot = new { rule.Id, rule.Name, Steps = rule.Steps .OrderBy(step => step.SortOrder) .Select(step => new { step.Id, step.SortOrder, step.Name, step.StepType, step.RequiresApproval, step.MetadataJson }) }; return JsonSerializer.Serialize(snapshot); } } }