using Amazon.Lambda.Annotations.APIGateway;
using Amazon.Lambda.Annotations.SourceGenerator.Diagnostics;
using Amazon.Lambda.Annotations.SourceGenerator.FileIO;
using Amazon.Lambda.Annotations.SourceGenerator.Models;
using Amazon.Lambda.Annotations.SourceGenerator.Models.Attributes;
using Microsoft.CodeAnalysis;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Reflection;
namespace Amazon.Lambda.Annotations.SourceGenerator.Writers
{
///
/// This class contains methods to manipulate the AWS serverless template.
/// It takes the metadata captured by and writes it to the AWS SAM template.
/// see here to know more about configurable properties for AWS::Serverless::Function
/// see here for an actual serverless template.
///
public class CloudFormationWriter : IAnnotationReportWriter
{
private const string CREATION_TOOL = "Amazon.Lambda.Annotations";
private const string PARAMETERS = "Parameters";
private const string GET_ATTRIBUTE = "Fn::GetAtt";
private const string REF = "Ref";
// Constants related to the message we append to the CloudFormation template description
private const string BASE_DESCRIPTION = "This template is partially managed by Amazon.Lambda.Annotations";
private const string END_OF_VESRION_IN_DESCRIPTION = ").";
private readonly IFileManager _fileManager;
private readonly IDirectoryManager _directoryManager;
private readonly ITemplateWriter _templateWriter;
private readonly IDiagnosticReporter _diagnosticReporter;
public CloudFormationWriter(IFileManager fileManager, IDirectoryManager directoryManager, ITemplateWriter templateWriter, IDiagnosticReporter diagnosticReporter)
{
_fileManager = fileManager;
_directoryManager = directoryManager;
_diagnosticReporter = diagnosticReporter;
_templateWriter = templateWriter;
}
///
/// It takes the metadata captured by and writes it to the AWS SAM template.
///
public void ApplyReport(AnnotationReport report)
{
var originalContent = _fileManager.ReadAllText(report.CloudFormationTemplatePath);
var templateDirectory = _directoryManager.GetDirectoryName(report.CloudFormationTemplatePath);
var relativeProjectUri = _directoryManager.GetRelativePath(templateDirectory, report.ProjectRootDirectory);
if (string.IsNullOrEmpty(originalContent))
CreateNewTemplate();
else
_templateWriter.Parse(originalContent);
ProcessTemplateDescription(report);
var processedLambdaFunctions = new HashSet();
foreach (var lambdaFunction in report.LambdaFunctions)
{
if (!ShouldProcessLambdaFunction(lambdaFunction))
continue;
ProcessLambdaFunction(lambdaFunction, relativeProjectUri);
processedLambdaFunctions.Add(lambdaFunction.ResourceName);
}
RemoveOrphanedLambdaFunctions(processedLambdaFunctions);
var content = _templateWriter.GetContent();
_fileManager.WriteAllText(report.CloudFormationTemplatePath, content);
_diagnosticReporter.Report(Diagnostic.Create(DiagnosticDescriptors.CodeGeneration, Location.None, $"{report.CloudFormationTemplatePath}", content));
}
///
/// Determines if the Lambda function and its properties should be written to the serverless template.
/// It checks the 'Resources.FUNCTION_NAME' path in the serverless template.
/// If the path does not exist, then the function should be processed and its properties must be persisted.
/// If the path exists, the function will only be processed if 'Resources.FUNCTION_NAME.Metadata.Tool' == 'Amazon.Lambda.Annotations'/>
///
private bool ShouldProcessLambdaFunction(ILambdaFunctionSerializable lambdaFunction)
{
var lambdaFunctionPath = $"Resources.{lambdaFunction.ResourceName}";
if (!_templateWriter.Exists(lambdaFunctionPath))
return true;
var creationTool = _templateWriter.GetToken($"{lambdaFunctionPath}.Metadata.Tool", string.Empty);
return string.Equals(creationTool, CREATION_TOOL, StringComparison.Ordinal);
}
///
/// Captures different properties specified by and attributes specified by
/// and writes it to the serverless template.
///
private void ProcessLambdaFunction(ILambdaFunctionSerializable lambdaFunction, string relativeProjectUri)
{
var lambdaFunctionPath = $"Resources.{lambdaFunction.ResourceName}";
var propertiesPath = $"{lambdaFunctionPath}.Properties";
if (!_templateWriter.Exists(lambdaFunctionPath))
ApplyLambdaFunctionDefaults(lambdaFunctionPath, propertiesPath);
ProcessLambdaFunctionProperties(lambdaFunction, propertiesPath, relativeProjectUri);
ProcessLambdaFunctionEventAttributes(lambdaFunction);
}
///
/// Captures different properties specified by and writes it to the serverless template
/// All properties are specified under 'Resources.FUNCTION_NAME.Properties' path.
///
private void ProcessLambdaFunctionProperties(ILambdaFunctionSerializable lambdaFunction, string propertiesPath, string relativeProjectUri)
{
if (lambdaFunction.Timeout > 0)
_templateWriter.SetToken($"{propertiesPath}.Timeout", lambdaFunction.Timeout);
if (lambdaFunction.MemorySize > 0)
_templateWriter.SetToken($"{propertiesPath}.MemorySize", lambdaFunction.MemorySize);
if (!string.IsNullOrEmpty(lambdaFunction.Role))
{
ProcessLambdaFunctionRole(lambdaFunction, $"{propertiesPath}.Role");
_templateWriter.RemoveToken($"{propertiesPath}.Policies");
}
if (!string.IsNullOrEmpty(lambdaFunction.Policies))
{
var policyArray = lambdaFunction.Policies.Split(',').Select(x => _templateWriter.GetValueOrRef(x.Trim())).ToList();
_templateWriter.SetToken($"{propertiesPath}.Policies", policyArray, TokenType.List);
_templateWriter.RemoveToken($"{propertiesPath}.Role");
}
ProcessPackageTypeProperty(lambdaFunction, propertiesPath, relativeProjectUri);
}
///
/// Specifies the deployment package type in the serverless template.
/// The package type property is specified under 'Resources.FUNCTION_NAME.Properties.PackageType' path.
/// Depending on the package type, some non-relevant properties will be removed.
///
private void ProcessPackageTypeProperty(ILambdaFunctionSerializable lambdaFunction, string propertiesPath, string relativeProjectUri)
{
_templateWriter.SetToken($"{propertiesPath}.PackageType", lambdaFunction.PackageType.ToString());
switch (lambdaFunction.PackageType)
{
case LambdaPackageType.Zip:
_templateWriter.SetToken($"{propertiesPath}.CodeUri", relativeProjectUri);
_templateWriter.SetToken($"{propertiesPath}.Handler", lambdaFunction.Handler);
_templateWriter.RemoveToken($"{propertiesPath}.ImageUri");
_templateWriter.RemoveToken($"{propertiesPath}.ImageConfig");
break;
case LambdaPackageType.Image:
_templateWriter.SetToken($"{propertiesPath}.ImageUri", relativeProjectUri);
_templateWriter.SetToken($"{propertiesPath}.ImageConfig.Command", new List{lambdaFunction.Handler}, TokenType.List);
_templateWriter.RemoveToken($"{propertiesPath}.Handler");
_templateWriter.RemoveToken($"{propertiesPath}.CodeUri");
_templateWriter.RemoveToken($"{propertiesPath}.Runtime");
break;
default:
throw new InvalidEnumArgumentException($"The {nameof(lambdaFunction.PackageType)} does not match any supported enums of type {nameof(LambdaPackageType)}");
}
}
///
/// Writes all attributes captured at to the serverless template.
/// It also removes all events that exist in the serverless template but were not encountered during the current source generation pass.
/// All events are specified under 'Resources.FUNCTION_NAME.Properties.Events' path.
///
private void ProcessLambdaFunctionEventAttributes(ILambdaFunctionSerializable lambdaFunction)
{
var currentSyncedEvents = new List();
foreach (var attributeModel in lambdaFunction.Attributes)
{
string eventName;
switch (attributeModel)
{
case AttributeModel httpApiAttributeModel:
eventName = ProcessHttpApiAttribute(lambdaFunction, httpApiAttributeModel.Data);
currentSyncedEvents.Add(eventName);
break;
case AttributeModel restApiAttributeModel:
eventName = ProcessRestApiAttribute(lambdaFunction, restApiAttributeModel.Data);
currentSyncedEvents.Add(eventName);
break;
}
}
var eventsPath = $"Resources.{lambdaFunction.ResourceName}.Properties.Events";
var syncedEventsMetadataPath = $"Resources.{lambdaFunction.ResourceName}.Metadata.SyncedEvents";
var previousSyncedEvents = _templateWriter.GetToken>(syncedEventsMetadataPath, new List());
// Remove all events that exist in the serverless template but were not encountered during the current source generation pass.
foreach (var previousEventName in previousSyncedEvents)
{
if (!currentSyncedEvents.Contains(previousEventName))
_templateWriter.RemoveToken($"{eventsPath}.{previousEventName}");
}
if (currentSyncedEvents.Any())
_templateWriter.SetToken(syncedEventsMetadataPath, currentSyncedEvents, TokenType.List);
else
_templateWriter.RemoveToken(syncedEventsMetadataPath);
}
///
/// Writes all properties associated with to the serverless template.
///
private string ProcessRestApiAttribute(ILambdaFunctionSerializable lambdaFunction, RestApiAttribute restApiAttribute)
{
var eventPath = $"Resources.{lambdaFunction.ResourceName}.Properties.Events";
var methodName = restApiAttribute.Method.ToString();
var methodPath = $"{eventPath}.Root{methodName}";
_templateWriter.SetToken($"{methodPath}.Type", "Api");
_templateWriter.SetToken($"{methodPath}.Properties.Path", restApiAttribute.Template);
_templateWriter.SetToken($"{methodPath}.Properties.Method", methodName.ToUpper());
return $"Root{methodName}";
}
///
/// Writes all properties associated with to the serverless template.
///
private string ProcessHttpApiAttribute(ILambdaFunctionSerializable lambdaFunction, HttpApiAttribute httpApiAttribute)
{
var eventPath = $"Resources.{lambdaFunction.ResourceName}.Properties.Events";
var methodName = httpApiAttribute.Method.ToString();
var methodPath = $"{eventPath}.Root{methodName}";
_templateWriter.SetToken($"{methodPath}.Type", "HttpApi");
_templateWriter.SetToken($"{methodPath}.Properties.Path", httpApiAttribute.Template);
_templateWriter.SetToken($"{methodPath}.Properties.Method", methodName.ToUpper());
// Only set the PayloadFormatVersion for 1.0.
// If no PayloadFormatVersion is specified then by default 2.0 is used.
if (httpApiAttribute.Version == HttpApiVersion.V1)
_templateWriter.SetToken($"{methodPath}.Properties.PayloadFormatVersion", "1.0");
return $"Root{methodName}";
}
///
/// Writes the default values for the Lambda function's metadata and properties.
///
private void ApplyLambdaFunctionDefaults(string lambdaFunctionPath, string propertiesPath)
{
_templateWriter.SetToken($"{lambdaFunctionPath}.Type", "AWS::Serverless::Function");
_templateWriter.SetToken($"{lambdaFunctionPath}.Metadata.Tool", CREATION_TOOL);
_templateWriter.SetToken($"{propertiesPath}.Runtime", "dotnet6");
_templateWriter.SetToken($"{propertiesPath}.CodeUri", "");
_templateWriter.SetToken($"{propertiesPath}.MemorySize", 256);
_templateWriter.SetToken($"{propertiesPath}.Timeout", 30);
_templateWriter.SetToken($"{propertiesPath}.Policies", new List{"AWSLambdaBasicExecutionRole"}, TokenType.List);
}
///
/// Creates a new serverless template with no resources.
///
private void CreateNewTemplate()
{
_templateWriter.SetToken("AWSTemplateFormatVersion", "2010-09-09");
_templateWriter.SetToken("Transform", "AWS::Serverless-2016-10-31");
}
///
/// Removes all Lambda functions that exist in the serverless template but were not encountered during the current source generation pass.
/// Any resource that is removed must be of type 'AWS::Serverless::Function' and must have 'Resources.FUNCTION_NAME.Metadata.Tool' == 'Amazon.Lambda.Annotations'.
///
private void RemoveOrphanedLambdaFunctions(HashSet processedLambdaFunctions)
{
if (!_templateWriter.Exists("Resources"))
{
return;
}
var toRemove = new List();
foreach (var resourceName in _templateWriter.GetKeys("Resources"))
{
var resourcePath = $"Resources.{resourceName}";
var type = _templateWriter.GetToken($"{resourcePath}.Type", string.Empty);
var creationTool = _templateWriter.GetToken($"{resourcePath}.Metadata.Tool", string.Empty);
if (string.Equals(type, "AWS::Serverless::Function", StringComparison.Ordinal)
&& string.Equals(creationTool, "Amazon.Lambda.Annotations", StringComparison.Ordinal)
&& !processedLambdaFunctions.Contains(resourceName))
{
toRemove.Add(resourceName);
}
}
foreach (var resourceName in toRemove)
{
_templateWriter.RemoveToken($"Resources.{resourceName}");
}
}
///
/// Write the IAM role associated with the Lambda function.
/// The IAM role is specified under 'Resources.FUNCTION_NAME.Properties.Role' path.
///
private void ProcessLambdaFunctionRole(ILambdaFunctionSerializable lambdaFunction, string rolePath)
{
if (string.IsNullOrEmpty(lambdaFunction.Role))
{
return;
}
if (!lambdaFunction.Role.StartsWith("@"))
{
_templateWriter.SetToken(rolePath, lambdaFunction.Role);
return;
}
var role = lambdaFunction.Role.Substring(1);
if (_templateWriter.Exists($"{PARAMETERS}.{role}"))
{
_templateWriter.SetToken($"{rolePath}.{REF}", role);
}
else
{
_templateWriter.SetToken($"{rolePath}.{GET_ATTRIBUTE}", new List{role, "Arn"}, TokenType.List);
}
}
///
/// Suffix that is appended to the CloudFormation template with the name
/// and version of the Lambda Annotations library
///
public static string CurrentDescriptionSuffix
{
get
{
var version = Assembly.GetAssembly(MethodBase.GetCurrentMethod().DeclaringType).GetName().Version.ToString();
return $"{BASE_DESCRIPTION} (v{version}).";
}
}
///
/// This appends a string to the CloudFormation template description field with the version
/// of Lambda Annotations that was used during compilation.
///
/// This string allows AWS to report on these templates to measure the usage of this framework.
/// This aids investigations and outreach if we find a critical bug,
/// helps understanding our version adoption, and allows us to prioritize improvements to this
/// library against other .NET projects.
///
private void ProcessTemplateDescription(AnnotationReport report)
{
if (report.IsTelemetrySuppressed)
{
RemoveTemplateDescriptionIfSet();
}
else
{
SetOrUpdateTemplateDescription();
}
}
///
/// Either appends the new version suffix in the CloudFormation template
/// description, or updates it if an older version is found.
///
private void SetOrUpdateTemplateDescription()
{
string updatedDescription;
if (_templateWriter.Exists("Description"))
{
var existingDescription = _templateWriter.GetToken("Description");
var existingDescriptionSuffix = ExtractCurrentDescriptionSuffix(existingDescription);
if (!string.IsNullOrEmpty(existingDescriptionSuffix))
{
updatedDescription = existingDescription.Replace(existingDescriptionSuffix, CurrentDescriptionSuffix);
}
else if (!string.IsNullOrEmpty(existingDescription)) // The version string isn't in the current description, so we just append it.
{
updatedDescription = existingDescription + " " + CurrentDescriptionSuffix;
}
else // "Description" path exists but is null or empty, so just overwrite it
{
updatedDescription = CurrentDescriptionSuffix;
}
}
else // the "Description" path doesn't exist, so set it
{
updatedDescription = CurrentDescriptionSuffix;
}
// In any case if the updated description is longer than CloudFormation's limit, fall back to the existing one.
// https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/template-description-structure.html
if (updatedDescription.Length > 1024)
{
return;
}
_templateWriter.SetToken("Description", updatedDescription);
}
///
/// Removes the version suffix from a CloudFormation template descripton
///
private void RemoveTemplateDescriptionIfSet()
{
if (!_templateWriter.Exists("Description"))
{
return;
}
var existingDescription = _templateWriter.GetToken("Description");
var existingDescriptionSuffix = ExtractCurrentDescriptionSuffix(existingDescription);
if (string.IsNullOrEmpty(existingDescriptionSuffix))
{
return;
}
var updatedDescription = existingDescription.Replace(existingDescriptionSuffix, "");
_templateWriter.SetToken("Description", updatedDescription);
}
///
/// Extracts the version suffix from a CloudFormation template description
///
///
///
private string ExtractCurrentDescriptionSuffix(string templateDescription)
{
var startIndex = templateDescription.IndexOf(BASE_DESCRIPTION);
if (startIndex >= 0)
{
// Find the next ")." which will be the end of the old version string
var endIndex = templateDescription.IndexOf(END_OF_VESRION_IN_DESCRIPTION, startIndex);
// If we couldn't find the end of our version string, it's only a fragment, so abort.
if (endIndex == -1)
{
return string.Empty;
}
var lengthOfCurrentDescription = endIndex + END_OF_VESRION_IN_DESCRIPTION.Length - startIndex;
return templateDescription.Substring(startIndex, lengthOfCurrentDescription);
}
return string.Empty;
}
}
}