tools/pipeline-generator/Azure.Sdk.Tools.PipelineGenerator/Conventions/PipelineConvention.cs (420 lines of code) (raw):

using Microsoft.Extensions.Logging; using Microsoft.TeamFoundation.Build.WebApi; using Microsoft.VisualStudio.Services.Common; using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; namespace PipelineGenerator.Conventions { public abstract class PipelineConvention { public PipelineConvention(ILogger logger, PipelineGenerationContext context) { Logger = logger; Context = context; } private const string ReportBuildStatusKey = "reportBuildStatus"; private Dictionary<string, BuildDefinitionReference> pipelineReferences; protected ILogger Logger { get; } protected PipelineGenerationContext Context { get; } public abstract string SearchPattern { get; } public abstract string PipelineNameSuffix { get; } public abstract string PipelineCategory { get; } public string GetDefinitionName(SdkComponent component) { var baseName = component.Variant == null ? $"{Context.Prefix} - {component.Name}" : $"{Context.Prefix} - {component.Name} - {component.Variant}"; return baseName + PipelineNameSuffix; } public async Task<BuildDefinition> DeleteDefinitionAsync(SdkComponent component, CancellationToken cancellationToken) { var definitionName = GetDefinitionName(component); Logger.LogDebug("Checking to see if definition '{0}' exists prior to deleting.", definitionName); var definition = await GetExistingDefinitionAsync(definitionName, cancellationToken); if (definition != null) { Logger.LogDebug("Found definition called '{0}' at '{1}'.", definitionName, definition.GetWebUrl()); if (!Context.WhatIf) { Logger.LogWarning("Deleting definition '{0}'.", definitionName); var projectReference = await Context.GetProjectReferenceAsync(cancellationToken); var buildClient = await Context.GetBuildHttpClientAsync(cancellationToken); await buildClient.DeleteDefinitionAsync( project: projectReference.Id, definitionId: definition.Id, cancellationToken: cancellationToken ); } else { Logger.LogWarning("Skipping deleting definition '{0}' (--whatif).", definitionName); } return definition; } else { Logger.LogDebug("No definition called '{0}' existed.", definitionName); return null; } } public async Task<BuildDefinition> CreateOrUpdateDefinitionAsync(SdkComponent component, CancellationToken cancellationToken) { var definitionName = GetDefinitionName(component); Logger.LogDebug("Checking to see if definition '{0}' exists prior to create/update.", definitionName); var definition = await GetExistingDefinitionAsync(definitionName, cancellationToken); if (definition == null) { Logger.LogDebug("Definition '{0}' was not found.", definitionName); definition = await CreateDefinitionAsync(definitionName, component, cancellationToken); } Logger.LogDebug("Applying convention to '{0}' definition.", definitionName); var hasChanges = await ApplyConventionAsync(definition, component); if (hasChanges || Context.OverwriteTriggers) { if (!Context.WhatIf) { Logger.LogInformation("Convention had changes, updating '{0}' definition.", definitionName); var buildClient = await Context.GetBuildHttpClientAsync(cancellationToken); definition.Comment = "Updated by pipeline generation tool"; definition = await buildClient.UpdateDefinitionAsync( definition: definition, cancellationToken: cancellationToken ); } else { Logger.LogWarning("Skipping update to definition '{0}' (--whatif).", definitionName); } } else { Logger.LogDebug("No changes for definition '{0}'.", definitionName); } return definition; } private async Task<BuildDefinition> GetExistingDefinitionAsync(string definitionName, CancellationToken cancellationToken) { Logger.LogDebug("Attempting to get existing definition '{0}'.", definitionName); var projectReference = await Context.GetProjectReferenceAsync(cancellationToken); var buildClient = await Context.GetBuildHttpClientAsync(cancellationToken); if (pipelineReferences == default) { var definitionReferences = await buildClient.GetDefinitionsAsync( project: projectReference.Id, path: Context.DevOpsPath ); pipelineReferences = new Dictionary<string, BuildDefinitionReference>(); foreach (var definition in definitionReferences) { if (pipelineReferences.ContainsKey(definition.Name)) { Logger.LogDebug($"Found more then one definition with name {definition.Name}, picking the first one {pipelineReferences[definition.Name].Id} and not {definition.Id}"); } else { pipelineReferences.Add(definition.Name, definition); } } Logger.LogDebug($"Cached {definitionReferences.Count} pipelines."); } BuildDefinitionReference definitionReference = null; pipelineReferences.TryGetValue(definitionName, out definitionReference); if (definitionReference != null) { Logger.LogDebug("Existing definition '{0}' found at '{1}'.", definitionName, definitionReference.GetWebUrl()); return await buildClient.GetDefinitionAsync( project: projectReference.Id, definitionId: definitionReference.Id, cancellationToken: cancellationToken ); } else { Logger.LogDebug("No definition named '{0}' was found.", definitionName); return null; } } private async Task<BuildDefinition> CreateDefinitionAsync(string definitionName, SdkComponent component, CancellationToken cancellationToken) { var serviceEndpoint = await Context.GetServiceEndpointAsync(cancellationToken); var repository = Context.Repository; var buildRepository = new BuildRepository { DefaultBranch = Context.Branch, Id = repository, Name = repository, Type = "GitHub", Url = new Uri($"https://github.com/{repository}.git"), Properties = { ["connectedServiceId"] = serviceEndpoint.Id.ToString() } }; var projectReference = await Context.GetProjectReferenceAsync(cancellationToken); var agentPoolQueue = await Context.GetAgentPoolQueue(cancellationToken); var normalizedRelativeYamlPath = component.RelativeYamlPath.Replace("\\", "/"); var definition = new BuildDefinition() { Name = definitionName, Project = projectReference, Path = Context.DevOpsPath, Repository = buildRepository, Process = new YamlProcess() { YamlFilename = normalizedRelativeYamlPath }, Queue = agentPoolQueue }; if (!Context.WhatIf) { Logger.LogDebug("Creating definition named '{0}'.", definitionName); var buildClient = await Context.GetBuildHttpClientAsync(cancellationToken); definition = await buildClient.CreateDefinitionAsync( definition: definition, cancellationToken: cancellationToken ); Logger.LogInformation("Created definition '{0}' at: {1}", definitionName, definition.GetWebUrl()); } else { Logger.LogWarning("Skipping creating definition '{0}' (--whatif).", definitionName); } return definition; } protected bool EnsureManagedVariables(BuildDefinition definition, SdkComponent component) { var hasChanges = false; var managedVariables = new Dictionary<string, string> { { "meta.platform", this.Context.Prefix }, { "meta.component", component.Name }, { "meta.variant", component.Variant }, { "meta.category", this.PipelineCategory }, { "meta.autoGenerated", "true" }, }; foreach (var (key, value) in managedVariables) { if (string.IsNullOrEmpty(value)) { if (definition.Variables.ContainsKey(key)) { Logger.LogInformation("Removing managed variable {Name}", key); definition.Variables.Remove(key); hasChanges = true; } // else: Nothing to do if an empty variable doesn't already exist. continue; } if (definition.Variables.TryGetValue(key, out var existingVariable)) { if (existingVariable.Value == value && !existingVariable.AllowOverride && !existingVariable.IsSecret) { // nothing to do if an existing variable matches the new value and options continue; } Logger.LogInformation("Overwriting managed variable {Name} from '{OriginalValue}' to '{NewValue}', not secret, not overridable", key, existingVariable.Value, value); } definition.Variables[key] = new BuildDefinitionVariable { Value = value, IsSecret = false, AllowOverride = false }; hasChanges = true; } return hasChanges; } protected bool EnsureVariableGroups(BuildDefinition definition) { var hasChanges = false; var definitionVariableGroupSet = definition.VariableGroups .Select(group => group.Id) .ToHashSet(); var parameterGroupSet = this.Context.VariableGroups.ToHashSet(); var idsToAdd = parameterGroupSet.Except(definitionVariableGroupSet); if (idsToAdd.Any()) { hasChanges = true; } var groupsToAdd = idsToAdd.Select(id => new VariableGroup { Id = id }); definition.VariableGroups.AddRange(groupsToAdd); return hasChanges; } private bool EnsureReportBuildStatus(BuildDefinition definition) { var hasChanges = false; if (definition.Repository.Properties.TryGetValue(ReportBuildStatusKey, out var reportBuildStatusString)) { if (!bool.TryParse(reportBuildStatusString, out var reportBuildStatusValue) || !reportBuildStatusValue) { definition.Repository.Properties[ReportBuildStatusKey] = "true"; hasChanges = true; } } else { definition.Repository.Properties.Add(ReportBuildStatusKey, "true"); hasChanges = true; } return hasChanges; } protected const int FirstSchedulingHour = 0; protected const int LastSchedulingHour = 24; protected const int TotalHours = LastSchedulingHour - FirstSchedulingHour; protected const int TotalMinutes = TotalHours * 60; protected const int BucketSizeInMinutes = 15; protected const int TotalBuckets = TotalMinutes / BucketSizeInMinutes; protected const int BucketsPerHour = 60 / BucketSizeInMinutes; protected virtual Schedule CreateScheduleFromDefinition(BuildDefinition definition) { var bucket = definition.Id % TotalBuckets; var startHours = bucket / BucketsPerHour; var startMinutes = bucket % BucketsPerHour; var schedule = new Schedule { DaysToBuild = (ScheduleDays)31, // Schedule M-F ScheduleOnlyWithChanges = true, StartHours = FirstSchedulingHour + startHours, StartMinutes = startMinutes * BucketSizeInMinutes, TimeZoneId = "Pacific Standard Time", }; schedule.BranchFilters.Add($"+{Context.Branch}"); return schedule; } protected virtual Task<bool> ApplyConventionAsync(BuildDefinition definition, SdkComponent component) { bool hasChanges = false; if (EnsureVariableGroups(definition)) { hasChanges = true; } if (Context.SetManagedVariables && EnsureManagedVariables(definition, component)) { hasChanges = true; } if (EnsureReportBuildStatus(definition)) { hasChanges = true; } if (definition.Path != this.Context.DevOpsPath) { definition.Path = this.Context.DevOpsPath; hasChanges = true; } if (definition.Repository.Properties.TryGetValue(ReportBuildStatusKey, out var reportBuildStatusString)) { if (!bool.TryParse(reportBuildStatusString, out var reportBuildStatusValue) || !reportBuildStatusValue) { definition.Repository.Properties[ReportBuildStatusKey] = "true"; hasChanges = true; } } else { definition.Repository.Properties.Add(ReportBuildStatusKey, "true"); hasChanges = true; } return Task.FromResult(hasChanges); } protected bool EnsureDefaultPullRequestTrigger(BuildDefinition definition, bool overrideYaml = true, bool securePipeline = true) { bool hasChanges = false; var prTriggers = definition.Triggers.OfType<PullRequestTrigger>(); if (prTriggers == default || !prTriggers.Any()) { var newTrigger = new PullRequestTrigger(); if (overrideYaml) { newTrigger.SettingsSourceType = 1; // Override what is in the yaml file and use what is in the pipeline definition newTrigger.BranchFilters.Add("+*"); } else { newTrigger.SettingsSourceType = 2; // Pull settings from yaml } newTrigger.Forks = new Forks { AllowSecrets = securePipeline, Enabled = true }; newTrigger.RequireCommentsForNonTeamMembersOnly = false; newTrigger.IsCommentRequiredForPullRequest = securePipeline; definition.Triggers.Add(newTrigger); hasChanges = true; } else { foreach (var trigger in prTriggers) { if (overrideYaml) { // Override what is in the yaml file and use what is in the pipeline definition if (trigger.SettingsSourceType != 1) { trigger.SettingsSourceType = 1; hasChanges = true; } // If any branch filters exist then overwrite them to the most generous filter. // The filter should support all branches because PR triggers with a yaml override // like this are expected to be manually invoked by `/azp run` comments, and these PRs // may be targeting development branches. if (!trigger.BranchFilters.SequenceEqual(new List<string>{"+*"})) { var filters = trigger.BranchFilters.Select(f => $"'{f}'"); Logger.LogInformation($"Overwriting branch filters ({String.Join(", ", filters)}) for PR trigger with '+*'"); trigger.BranchFilters.Clear(); trigger.BranchFilters.Add("+*"); hasChanges = true; } } else if (trigger.SettingsSourceType != 2) { // Pull settings from yaml trigger.SettingsSourceType = 2; hasChanges = true; } if (trigger.RequireCommentsForNonTeamMembersOnly != false || trigger.Forks.AllowSecrets != securePipeline || trigger.Forks.Enabled != true || trigger.IsCommentRequiredForPullRequest != securePipeline ) { trigger.Forks.AllowSecrets = securePipeline; trigger.Forks.Enabled = true; trigger.RequireCommentsForNonTeamMembersOnly = false; trigger.IsCommentRequiredForPullRequest = securePipeline; hasChanges = true; } } } return hasChanges; } protected bool EnsureDefaultScheduledTrigger(BuildDefinition definition) { bool hasChanges = false; var scheduleTriggers = definition.Triggers.OfType<ScheduleTrigger>(); // Only add the schedule trigger if one doesn't exist. if (scheduleTriggers == default || !scheduleTriggers.Any() || Context.OverwriteTriggers) { var computedSchedule = CreateScheduleFromDefinition(definition); definition.Triggers.RemoveAll(e => e is ScheduleTrigger); definition.Triggers.Add(new ScheduleTrigger { Schedules = new List<Schedule> { computedSchedule } }); hasChanges = true; } return hasChanges; } protected bool EnsureDefaultCITrigger(BuildDefinition definition) { bool hasChanges = false; var ciTrigger = definition.Triggers.OfType<ContinuousIntegrationTrigger>().SingleOrDefault(); if (ciTrigger == null || Context.OverwriteTriggers) { definition.Triggers.RemoveAll(e => e is ContinuousIntegrationTrigger); definition.Triggers.Add(new ContinuousIntegrationTrigger() { SettingsSourceType = 2 // Get CI trigger data from yaml file }); hasChanges = true; } else { if (ciTrigger.SettingsSourceType != 2) { ciTrigger.SettingsSourceType = 2; hasChanges = true; } } return hasChanges; } } }