- Drop existing indexes on DeploymentJobTargets and DeploymentJobSteps - Alter Status column to nvarchar(450) in DeploymentJobTargets, DeploymentJobSteps, and DeploymentJobs - Add new columns: Finished, OutputMetadataJson, Started to DeploymentJobTargets; ErrorMessage, Finished, OutputMetadataJson, Started to DeploymentJobSteps; CorrelationId, HeartbeatAt, Priority, RowVersion, ScheduledAt, WorkerName to DeploymentJobs - Create new indexes for improved query performance - Add constraints to ensure OutputMetadataJson is valid JSON - Record migration in __EFMigrationsHistory
384 lines
14 KiB
C#
384 lines
14 KiB
C#
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<Guid> targetIds, string jsonData)
|
|
{
|
|
var deploymentDocument = ConfigurationDocumentValidator.NormalizeAndValidate(
|
|
jsonData,
|
|
ConfigurationDocumentKind.DeploymentOverride);
|
|
jsonData = deploymentDocument.JsonData;
|
|
|
|
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 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,
|
|
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,
|
|
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<QueueJobStepModel>();
|
|
|
|
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<QueueJobStepModel> BuildDefaultJobSteps()
|
|
{
|
|
return new List<QueueJobStepModel>
|
|
{
|
|
new()
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
SortOrder = 1,
|
|
Name = "Provision",
|
|
StepType = QueueJobStepType.Provision,
|
|
Status = QueueJobStatus.Pending
|
|
}
|
|
};
|
|
}
|
|
|
|
private static List<QueueJobStepModel> 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);
|
|
}
|
|
}
|
|
}
|
|
|