// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 using System; using System.Collections.Generic; using System.IO.Compression; using System.IO; using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Amazon.ElasticBeanstalk; using Amazon.ElasticBeanstalk.Model; using AWS.Deploy.Common; using AWS.Deploy.Common.IO; using AWS.Deploy.Common.Recipes; using System.Text.Json.Serialization; namespace AWS.Deploy.Orchestration.ServiceHandlers { public interface IElasticBeanstalkHandler { /// /// Deployments to Windows Elastic Beanstalk envvironments require a manifest file to be included with the binaries. /// This method creates the manifest file if it doesn't exist, or it creates a new one. /// The two main settings that are updated are IIS Website and IIS App Path. /// void SetupWindowsDeploymentManifest(Recommendation recommendation, string dotnetZipFilePath); /// /// When deploying a self contained deployment bundle, Beanstalk needs a Procfile to tell the environment what process to start up. /// Check out the AWS Elastic Beanstalk developer guide for more information on Procfiles /// https://docs.aws.amazon.com/elasticbeanstalk/latest/dg/dotnet-linux-procfile.html /// void SetupProcfileForSelfContained(string dotnetZipFilePath); Task CreateApplicationStorageLocationAsync(string applicationName, string versionLabel, string deploymentPackage); Task CreateApplicationVersionAsync(string applicationName, string versionLabel, S3Location sourceBundle); Task UpdateEnvironmentAsync(string applicationName, string environmentName, string versionLabel, List optionSettings); List GetEnvironmentConfigurationSettings(Recommendation recommendation); } /// /// This class represents the structure of the Windows manifest file to be included with Windows Elastic Beanstalk deployments. /// public class ElasticBeanstalkWindowsManifest { [JsonPropertyName("manifestVersion")] public int ManifestVersion { get; set; } = 1; [JsonPropertyName("deployments")] public ManifestDeployments Deployments { get; set; } = new(); public class ManifestDeployments { [JsonPropertyName("aspNetCoreWeb")] public List AspNetCoreWeb { get; set; } = new(); public class AspNetCoreWebDeployments { [JsonPropertyName("name")] public string Name { get; set; } = "app"; [JsonPropertyName("parameters")] public AspNetCoreWebParameters Parameters { get; set; } = new(); public class AspNetCoreWebParameters { [JsonPropertyName("appBundle")] public string AppBundle { get; set; } = "."; [JsonPropertyName("iisPath")] public string IISPath { get; set; } = "/"; [JsonPropertyName("iisWebSite")] public string IISWebSite { get; set; } = "Default Web Site"; } } } } public class AWSElasticBeanstalkHandler : IElasticBeanstalkHandler { private readonly IAWSClientFactory _awsClientFactory; private readonly IOrchestratorInteractiveService _interactiveService; private readonly IFileManager _fileManager; private readonly IOptionSettingHandler _optionSettingHandler; public AWSElasticBeanstalkHandler(IAWSClientFactory awsClientFactory, IOrchestratorInteractiveService interactiveService, IFileManager fileManager, IOptionSettingHandler optionSettingHandler) { _awsClientFactory = awsClientFactory; _interactiveService = interactiveService; _fileManager = fileManager; _optionSettingHandler = optionSettingHandler; } private T GetOrCreateNode(object? json) where T : new() { try { return JsonSerializer.Deserialize(json?.ToString() ?? string.Empty, new JsonSerializerOptions { ReadCommentHandling = JsonCommentHandling.Skip }) ?? new T(); } catch { return new T(); } } /// /// Deployments to Windows Elastic Beanstalk envvironments require a manifest file to be included with the binaries. /// This method creates the manifest file if it doesn't exist, or it creates a new one. /// The two main settings that are updated are IIS Website and IIS App Path. /// public void SetupWindowsDeploymentManifest(Recommendation recommendation, string dotnetZipFilePath) { var iisWebSiteOptionSetting = _optionSettingHandler.GetOptionSetting(recommendation, Constants.ElasticBeanstalk.IISWebSiteOptionId); var iisAppPathOptionSetting = _optionSettingHandler.GetOptionSetting(recommendation, Constants.ElasticBeanstalk.IISAppPathOptionId); var iisWebSiteValue = _optionSettingHandler.GetOptionSettingValue(recommendation, iisWebSiteOptionSetting); var iisAppPathValue = _optionSettingHandler.GetOptionSettingValue(recommendation, iisAppPathOptionSetting); var iisWebSite = !string.IsNullOrEmpty(iisWebSiteValue) ? iisWebSiteValue : "Default Web Site"; var iisAppPath = !string.IsNullOrEmpty(iisAppPathValue) ? iisAppPathValue : "/"; var newManifestFile = new ElasticBeanstalkWindowsManifest(); newManifestFile.Deployments.AspNetCoreWeb.Add(new ElasticBeanstalkWindowsManifest.ManifestDeployments.AspNetCoreWebDeployments { Parameters = new ElasticBeanstalkWindowsManifest.ManifestDeployments.AspNetCoreWebDeployments.AspNetCoreWebParameters { IISPath = iisAppPath, IISWebSite = iisWebSite } }); using (var zipArchive = ZipFile.Open(dotnetZipFilePath, ZipArchiveMode.Update)) { var zipEntry = zipArchive.GetEntry(Constants.ElasticBeanstalk.WindowsManifestName); var serializedManifest = JsonSerializer.Serialize(new Dictionary()); if (zipEntry != null) { using (var streamReader = new StreamReader(zipEntry.Open())) { serializedManifest = streamReader.ReadToEnd(); } } var jsonDoc = GetOrCreateNode>(serializedManifest); if (!jsonDoc.ContainsKey("manifestVersion")) { jsonDoc["manifestVersion"] = newManifestFile.ManifestVersion; } if (jsonDoc.ContainsKey("deployments")) { var deploymentNode = GetOrCreateNode>(jsonDoc["deployments"]); if (deploymentNode.ContainsKey("aspNetCoreWeb")) { var aspNetCoreWebNode = GetOrCreateNode>(deploymentNode["aspNetCoreWeb"]); if (aspNetCoreWebNode.Count == 0) { aspNetCoreWebNode.Add(newManifestFile.Deployments.AspNetCoreWeb[0]); } else { // We only need 1 entry in the 'aspNetCoreWeb' node that defines the parameters we are interested in. Typically, only 1 entry exists. var aspNetCoreWebEntry = GetOrCreateNode>(JsonSerializer.Serialize(aspNetCoreWebNode[0])); var nameValue = aspNetCoreWebEntry.ContainsKey("name") ? aspNetCoreWebEntry["name"].ToString() : string.Empty; aspNetCoreWebEntry["name"] = !string.IsNullOrEmpty(nameValue) ? nameValue : newManifestFile.Deployments.AspNetCoreWeb[0].Name; if (aspNetCoreWebEntry.ContainsKey("parameters")) { var parametersNode = GetOrCreateNode>(aspNetCoreWebEntry["parameters"]); parametersNode["appBundle"] = "."; parametersNode["iisPath"] = iisAppPath; parametersNode["iisWebSite"] = iisWebSite; aspNetCoreWebEntry["parameters"] = parametersNode; } else { aspNetCoreWebEntry["parameters"] = newManifestFile.Deployments.AspNetCoreWeb[0].Parameters; } aspNetCoreWebNode[0] = aspNetCoreWebEntry; } deploymentNode["aspNetCoreWeb"] = aspNetCoreWebNode; } else { deploymentNode["aspNetCoreWeb"] = newManifestFile.Deployments.AspNetCoreWeb; } jsonDoc["deployments"] = deploymentNode; } else { jsonDoc["deployments"] = newManifestFile.Deployments; } using (var jsonStream = new MemoryStream(JsonSerializer.SerializeToUtf8Bytes(jsonDoc, new JsonSerializerOptions { WriteIndented = true }))) { zipEntry ??= zipArchive.CreateEntry(Constants.ElasticBeanstalk.WindowsManifestName); using var zipEntryStream = zipEntry.Open(); jsonStream.Position = 0; jsonStream.CopyTo(zipEntryStream); } } } /// /// When deploying a self contained deployment bundle, Beanstalk needs a Procfile to tell the environment what process to start up. /// Check out the AWS Elastic Beanstalk developer guide for more information on Procfiles /// https://docs.aws.amazon.com/elasticbeanstalk/latest/dg/dotnet-linux-procfile.html /// /// This code is a copy of the code in the AspNetAppElasticBeanstalkLinux CDK recipe definition. Any changes to this method /// should be made into that version as well. /// /// public void SetupProcfileForSelfContained(string dotnetZipFilePath) { const string RUNTIME_CONFIG_SUFFIX = ".runtimeconfig.json"; const string PROCFILE_NAME = "Procfile"; string runtimeConfigFilename; string runtimeConfigJson; using (var zipArchive = ZipFile.Open(dotnetZipFilePath, ZipArchiveMode.Read)) { // Skip Procfile setup if one already exists. if (zipArchive.GetEntry(PROCFILE_NAME) != null) { return; } var runtimeConfigEntry = zipArchive.Entries.FirstOrDefault(x => x.Name.EndsWith(RUNTIME_CONFIG_SUFFIX)); if (runtimeConfigEntry == null) { return; } runtimeConfigFilename = runtimeConfigEntry.Name; using var stream = runtimeConfigEntry.Open(); runtimeConfigJson = new StreamReader(stream).ReadToEnd(); } var runtimeConfigDoc = JsonDocument.Parse(runtimeConfigJson); if (!runtimeConfigDoc.RootElement.TryGetProperty("runtimeOptions", out var runtimeOptionsNode)) { return; } // If there are includedFrameworks then the zip file is a self contained deployment bundle. if (!runtimeOptionsNode.TryGetProperty("includedFrameworks", out _)) { return; } var executableName = runtimeConfigFilename.Substring(0, runtimeConfigFilename.Length - RUNTIME_CONFIG_SUFFIX.Length); var procCommand = $"web: ./{executableName}"; using (var zipArchive = ZipFile.Open(dotnetZipFilePath, ZipArchiveMode.Update)) { var procfileEntry = zipArchive.CreateEntry(PROCFILE_NAME); using var zipEntryStream = procfileEntry.Open(); zipEntryStream.Write(System.Text.UTF8Encoding.UTF8.GetBytes(procCommand)); } } public async Task CreateApplicationStorageLocationAsync(string applicationName, string versionLabel, string deploymentPackage) { string bucketName; try { var ebClient = _awsClientFactory.GetAWSClient(); bucketName = (await ebClient.CreateStorageLocationAsync()).S3Bucket; } catch (Exception e) { throw new ElasticBeanstalkException(DeployToolErrorCode.FailedToCreateElasticBeanstalkStorageLocation, "An error occured while creating the Elastic Beanstalk storage location", e); } var key = string.Format("{0}/AWSDeploymentArchive_{0}_{1}{2}", applicationName.Replace(' ', '-'), versionLabel.Replace(' ', '-'), _fileManager.GetExtension(deploymentPackage)); return new S3Location { S3Bucket = bucketName, S3Key = key }; } public async Task CreateApplicationVersionAsync(string applicationName, string versionLabel, S3Location sourceBundle) { _interactiveService.LogInfoMessage("Creating new application version: " + versionLabel); try { var ebClient = _awsClientFactory.GetAWSClient(); var response = await ebClient.CreateApplicationVersionAsync(new CreateApplicationVersionRequest { ApplicationName = applicationName, VersionLabel = versionLabel, SourceBundle = sourceBundle }); return response; } catch (Exception e) { throw new ElasticBeanstalkException(DeployToolErrorCode.FailedToCreateElasticBeanstalkApplicationVersion, "An error occured while creating the Elastic Beanstalk application version", e); } } public List GetEnvironmentConfigurationSettings(Recommendation recommendation) { var additionalSettings = new List(); List<(string OptionSettingId, string OptionSettingNameSpace, string OptionSettingName)> tupleList; switch (recommendation.Recipe.Id) { case Constants.RecipeIdentifier.EXISTING_BEANSTALK_ENVIRONMENT_RECIPE_ID: tupleList = Constants.ElasticBeanstalk.OptionSettingQueryList; break; case Constants.RecipeIdentifier.EXISTING_BEANSTALK_WINDOWS_ENVIRONMENT_RECIPE_ID: tupleList = Constants.ElasticBeanstalk.WindowsOptionSettingQueryList; break; default: throw new InvalidOperationException($"The recipe '{recommendation.Recipe.Id}' is not supported."); }; foreach (var tuple in tupleList) { var optionSetting = _optionSettingHandler.GetOptionSetting(recommendation, tuple.OptionSettingId); if (!optionSetting.Updatable) continue; var optionSettingValue = optionSetting.GetValue(new Dictionary()); additionalSettings.Add(new ConfigurationOptionSetting { Namespace = tuple.OptionSettingNameSpace, OptionName = tuple.OptionSettingName, Value = optionSettingValue }); } return additionalSettings; } public async Task UpdateEnvironmentAsync(string applicationName, string environmentName, string versionLabel, List optionSettings) { _interactiveService.LogInfoMessage("Getting latest environment event date before update"); var startingEventDate = await GetLatestEventDateAsync(applicationName, environmentName); _interactiveService.LogInfoMessage($"Updating environment {environmentName} to new application version {versionLabel}"); var updateRequest = new UpdateEnvironmentRequest { ApplicationName = applicationName, EnvironmentName = environmentName, VersionLabel = versionLabel, OptionSettings = optionSettings }; try { var ebClient = _awsClientFactory.GetAWSClient(); var updateEnvironmentResponse = await ebClient.UpdateEnvironmentAsync(updateRequest); return await WaitForEnvironmentUpdateCompletion(applicationName, environmentName, startingEventDate); } catch (Exception e) { throw new ElasticBeanstalkException(DeployToolErrorCode.FailedToUpdateElasticBeanstalkEnvironment, "An error occured while updating the Elastic Beanstalk environment", e); } } private async Task GetLatestEventDateAsync(string applicationName, string environmentName) { var request = new DescribeEventsRequest { ApplicationName = applicationName, EnvironmentName = environmentName }; var ebClient = _awsClientFactory.GetAWSClient(); var response = await ebClient.DescribeEventsAsync(request); if (response.Events.Count == 0) return DateTime.Now; return response.Events.First().EventDate; } private async Task WaitForEnvironmentUpdateCompletion(string applicationName, string environmentName, DateTime startingEventDate) { _interactiveService.LogInfoMessage("Waiting for environment update to complete"); var success = true; var environment = new EnvironmentDescription(); var lastPrintedEventDate = startingEventDate; var requestEvents = new DescribeEventsRequest { ApplicationName = applicationName, EnvironmentName = environmentName }; var requestEnvironment = new DescribeEnvironmentsRequest { ApplicationName = applicationName, EnvironmentNames = new List { environmentName } }; var ebClient = _awsClientFactory.GetAWSClient(); do { Thread.Sleep(5000); var responseEnvironments = await ebClient.DescribeEnvironmentsAsync(requestEnvironment); if (responseEnvironments.Environments.Count == 0) throw new AWSResourceNotFoundException(DeployToolErrorCode.BeanstalkEnvironmentDoesNotExist, $"Failed to find environment {environmentName} belonging to application {applicationName}"); environment = responseEnvironments.Environments[0]; requestEvents.StartTimeUtc = lastPrintedEventDate; var responseEvents = await ebClient.DescribeEventsAsync(requestEvents); if (responseEvents.Events.Any()) { for (var i = responseEvents.Events.Count - 1; i >= 0; i--) { var evnt = responseEvents.Events[i]; if (evnt.EventDate <= lastPrintedEventDate) continue; _interactiveService.LogInfoMessage(evnt.EventDate.ToLocalTime() + " " + evnt.Severity + " " + evnt.Message); if (evnt.Severity == EventSeverity.ERROR || evnt.Severity == EventSeverity.FATAL) { success = false; } } lastPrintedEventDate = responseEvents.Events[0].EventDate; } } while (environment.Status == EnvironmentStatus.Launching || environment.Status == EnvironmentStatus.Updating); return success; } } }