Skip to content

Commit

Permalink
feat: Add support for deploying .NET 7 applications to Elastic Beanstalk
Browse files Browse the repository at this point in the history
  • Loading branch information
normj committed Nov 3, 2022
1 parent 4867a80 commit 1a8d3af
Show file tree
Hide file tree
Showing 15 changed files with 206 additions and 70 deletions.
2 changes: 2 additions & 0 deletions src/AWS.Deploy.Constants/RecipeIdentifier.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,7 @@ internal static class RecipeIdentifier
/// Id for the 'dotnet build --self-contained' recipe option
/// </summary>
public const string DotnetPublishSelfContainedBuildOptionId = "SelfContainedBuild";

public const string TARGET_SERVICE_ELASTIC_BEANSTALK = "AWS Elastic Beanstalk";
}
}
8 changes: 8 additions & 0 deletions src/AWS.Deploy.Orchestration/DeploymentBundleHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
using AWS.Deploy.Common.IO;
using AWS.Deploy.Common.Recipes;
using AWS.Deploy.Common.Utilities;
using AWS.Deploy.Constants;
using AWS.Deploy.Orchestration.Data;
using AWS.Deploy.Orchestration.Utilities;

Expand Down Expand Up @@ -103,6 +104,13 @@ public async Task<string> CreateDotnetPublishZip(Recommendation recommendation)
_interactiveService.LogInfoMessage(string.Empty);
_interactiveService.LogInfoMessage("Creating Dotnet Publish Zip file...");

// Since Beanstalk doesn't currently have .NET 7 preinstalled we need to make sure we are doing a self contained publish when creating the deployment bundle.
if (recommendation.Recipe.TargetService == RecipeIdentifier.TARGET_SERVICE_ELASTIC_BEANSTALK && recommendation.ProjectDefinition.TargetFramework == "net7.0")
{
_interactiveService.LogInfoMessage("Using self contained publish since AWS Elastic Beanstalk does not currently have .NET 7 preinstalled");
recommendation.DeploymentBundle.DotnetPublishSelfContainedBuild = true;
}

var publishDirectoryInfo = _directoryManager.CreateDirectory(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()));
var additionalArguments = recommendation.DeploymentBundle.DotnetPublishAdditionalBuildArguments;
var runtimeArg =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ public async Task ExecuteAsync(Orchestrator orchestrator, CloudApplication cloud
{
elasticBeanstalkHandler.SetupWindowsDeploymentManifest(recommendation, deploymentPackage);
}
else if (recommendation.Recipe.Id.Equals(Constants.RecipeIdentifier.EXISTING_BEANSTALK_ENVIRONMENT_RECIPE_ID))
{
elasticBeanstalkHandler.SetupProcfileForSelfContained(deploymentPackage);
}

var versionLabel = $"v-{DateTime.Now.Ticks}";
var s3location = await elasticBeanstalkHandler.CreateApplicationStorageLocationAsync(applicationName, versionLabel, deploymentPackage);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ public interface IElasticBeanstalkHandler
/// The two main settings that are updated are IIS Website and IIS App Path.
/// </summary>
void SetupWindowsDeploymentManifest(Recommendation recommendation, string dotnetZipFilePath);

/// <summary>
/// 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
/// </summary>
void SetupProcfileForSelfContained(string dotnetZipFilePath);

Task<S3Location> CreateApplicationStorageLocationAsync(string applicationName, string versionLabel, string deploymentPackage);
Task<CreateApplicationVersionResponse> CreateApplicationVersionAsync(string applicationName, string versionLabel, S3Location sourceBundle);
Task<bool> UpdateEnvironmentAsync(string applicationName, string environmentName, string versionLabel, List<ConfigurationOptionSetting> optionSettings);
Expand Down Expand Up @@ -204,6 +212,65 @@ public void SetupWindowsDeploymentManifest(Recommendation recommendation, string
}
}

/// <summary>
/// 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.
/// </summary>
/// <param name="dotnetZipFilePath"></param>
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<S3Location> CreateApplicationStorageLocationAsync(string applicationName, string versionLabel, string deploymentPackage)
{
string bucketName;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

using System;
using System.Collections.Generic;
using System.IO.Compression;
using System.Text.Json;
using Amazon.CDK;
using Amazon.CDK.AWS.ElasticBeanstalk;
using Amazon.CDK.AWS.IAM;
Expand All @@ -13,6 +15,7 @@
using Constructs;
using System.Linq;
using Amazon.CDK.AWS.EC2;
using System.IO;

// This is a generated file from the original deployment recipe. It is recommended to not modify this file in order
// to allow easy updates to the file when the original recipe that this project was created from has updates.
Expand Down Expand Up @@ -59,6 +62,9 @@ public Recipe(Construct scope, IRecipeProps<Configuration> props)
if (string.IsNullOrEmpty(props.DotnetPublishZipPath))
throw new InvalidOrMissingConfigurationException("The provided path containing the dotnet publish zip file is null or empty.");

// Self contained deployment bundles need a Procfile to tell Beanstalk what process to start.
SetupProcfileForSelfContained(props.DotnetPublishZipPath);

ApplicationAsset = new Asset(this, "Asset", new AssetProps
{
Path = props.DotnetPublishZipPath
Expand Down Expand Up @@ -480,5 +486,61 @@ private void ConfigureBeanstalkEnvironment(Configuration settings, string beanst
VersionLabel = ApplicationVersion.Ref,
}));
}

/// <summary>
/// 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
/// </summary>
/// <param name="dotnetZipFilePath"></param>
static 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));
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"Type": "MSProperty",
"Condition": {
"PropertyName": "TargetFramework",
"AllowedValues": [ "netcoreapp3.1", "net5.0", "net6.0" ]
"AllowedValues": [ "netcoreapp3.1", "net5.0", "net6.0", "net7.0" ]
}
}
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"Type": "MSProperty",
"Condition": {
"PropertyName": "TargetFramework",
"AllowedValues": [ "netcoreapp3.1", "net5.0", "net6.0" ]
"AllowedValues": [ "netcoreapp3.1", "net5.0", "net6.0", "net7.0" ]
}
}
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"Type": "MSProperty",
"Condition": {
"PropertyName": "TargetFramework",
"AllowedValues": [ "netcoreapp3.1", "net5.0", "net6.0" ]
"AllowedValues": [ "netcoreapp3.1", "net5.0", "net6.0", "net7.0" ]
}
}
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"Type": "MSProperty",
"Condition": {
"PropertyName": "TargetFramework",
"AllowedValues": [ "netcoreapp3.1", "net5.0", "net6.0" ]
"AllowedValues": [ "netcoreapp3.1", "net5.0", "net6.0", "net7.0" ]
}
}
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,26 @@ public async Task DeployToExistingBeanstalkEnvironment()
var expectedVersionLabel = successMessage.Split(" ").Last();
Assert.True(await _fixture.EBHelper.VerifyEnvironmentVersionLabel(_fixture.EnvironmentName, expectedVersionLabel));
}

[Fact]
public async Task DeployToExistingBeanstalkEnvironmentSelfContained()
{
var projectPath = _fixture.TestAppManager.GetProjectPath(Path.Combine("testapps", "WebAppNoDockerFile", "WebAppNoDockerFile.csproj"));
var deployArgs = new[] { "deploy", "--project-path", projectPath, "--application-name", _fixture.EnvironmentName, "--diagnostics", "--silent", "--region", "us-west-2", "--apply", "Existing-ElasticBeanStalkConfigFile-Linux-SelfContained.json" };
Assert.Equal(CommandReturnCodes.SUCCESS, await _fixture.App.Run(deployArgs));

var environmentDescription = await _fixture.AWSResourceQueryer.DescribeElasticBeanstalkEnvironment(_fixture.EnvironmentName);

// URL could take few more minutes to come live, therefore, we want to wait and keep trying for a specified timeout
await _fixture.HttpHelper.WaitUntilSuccessStatusCode(environmentDescription.CNAME, TimeSpan.FromSeconds(5), TimeSpan.FromMinutes(5));

var successMessagePrefix = $"The Elastic Beanstalk Environment {_fixture.EnvironmentName} has been successfully updated";
var deployStdOutput = _fixture.InteractiveService.StdOutReader.ReadAllLines();
var successMessage = deployStdOutput.First(line => line.Trim().StartsWith(successMessagePrefix));
Assert.False(string.IsNullOrEmpty(successMessage));

var expectedVersionLabel = successMessage.Split(" ").Last();
Assert.True(await _fixture.EBHelper.VerifyEnvironmentVersionLabel(_fixture.EnvironmentName, expectedVersionLabel));
}
}
}
77 changes: 11 additions & 66 deletions test/AWS.Deploy.CLI.IntegrationTests/WebAppNoDockerFileTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,72 +68,14 @@ public WebAppNoDockerFileTests()
_testAppManager = new TestAppManager();
}

[Fact]
public async Task DefaultConfigurations()
{
_stackName = $"WebAppNoDockerFile{Guid.NewGuid().ToString().Split('-').Last()}";
var saveSettingsFilePath = Path.Combine(Path.GetTempPath(), $"DeploymentSettings-{Guid.NewGuid().ToString().Split('-').Last()}.json");

// Arrange input for deploy
await _interactiveService.StdInWriter.WriteAsync(Environment.NewLine); // Select default recommendation
await _interactiveService.StdInWriter.WriteAsync(Environment.NewLine); // Select default option settings
await _interactiveService.StdInWriter.FlushAsync();

// Deploy
var projectPath = _testAppManager.GetProjectPath(Path.Combine("testapps", "WebAppNoDockerFile", "WebAppNoDockerFile.csproj"));
var deployArgs = new[] { "deploy", "--project-path", projectPath, "--application-name", _stackName, "--diagnostics", "--save-settings", saveSettingsFilePath };
Assert.Equal(CommandReturnCodes.SUCCESS, await _app.Run(deployArgs));

// Verify application is deployed and running
Assert.Equal(StackStatus.CREATE_COMPLETE, await _cloudFormationHelper.GetStackStatus(_stackName));

var deployStdOut = _interactiveService.StdOutReader.ReadAllLines();

var tempCdkProjectLine = deployStdOut.First(line => line.StartsWith("Saving AWS CDK deployment project to: "));
var tempCdkProject = tempCdkProjectLine.Split(": ")[1].Trim();
Assert.False(Directory.Exists(tempCdkProject), $"{tempCdkProject} must not exist.");

// Example: Endpoint: http://52.36.216.238/
var endpointLine = deployStdOut.First(line => line.Trim().StartsWith($"Endpoint"));
var applicationUrl = endpointLine.Substring(endpointLine.IndexOf(":") + 1).Trim();
Assert.True(Uri.IsWellFormedUriString(applicationUrl, UriKind.Absolute));

// URL could take few more minutes to come live, therefore, we want to wait and keep trying for a specified timeout
await _httpHelper.WaitUntilSuccessStatusCode(applicationUrl, TimeSpan.FromSeconds(5), TimeSpan.FromMinutes(5));

// Check the overridden workspace
Assert.True(File.Exists(Path.Combine(_customWorkspace, "CDKBootstrapTemplate.yaml")));
Assert.True(Directory.Exists(Path.Combine(_customWorkspace, "temp")));
Assert.True(Directory.Exists(Path.Combine(_customWorkspace, "Projects")));

// Verify existence of the saved deployment settings file
Assert.True(File.Exists(saveSettingsFilePath));

// list
var listArgs = new[] { "list-deployments", "--diagnostics" };
Assert.Equal(CommandReturnCodes.SUCCESS, await _app.Run(listArgs));;

// Verify stack exists in list of deployments
var listStdOut = _interactiveService.StdOutReader.ReadAllLines().Select(x => x.Split()[0]).ToList();
Assert.Contains(listStdOut, (deployment) => _stackName.Equals(deployment));

// Arrange input for delete
// Use --silent flag to delete without user prompts
var deleteArgs = new[] { "delete-deployment", _stackName, "--diagnostics", "--silent" };

// Delete
Assert.Equal(CommandReturnCodes.SUCCESS, await _app.Run(deleteArgs));;

// Verify application is deleted
Assert.True(await _cloudFormationHelper.IsStackDeleted(_stackName), $"{_stackName} still exists.");
}

[Theory]
[InlineData("ElasticBeanStalkConfigFile-Windows.json")]
[InlineData("ElasticBeanStalkConfigFile-Windows-SelfContained.json")]
public async Task WindowsEBDefaultConfigurations(string configFile)
[InlineData("ElasticBeanStalkConfigFile-Linux.json", true)]
[InlineData("ElasticBeanStalkConfigFile-Linux-SelfContained.json", true)]
[InlineData("ElasticBeanStalkConfigFile-Windows.json", false)]
[InlineData("ElasticBeanStalkConfigFile-Windows-SelfContained.json", false)]
public async Task EBDefaultConfigurations(string configFile, bool linux)
{
_stackName = $"WinTest-{Guid.NewGuid().ToString().Split('-').Last()}";
_stackName = $"BeanstalkTest-{Guid.NewGuid().ToString().Split('-').Last()}";

// Deploy
var projectPath = _testAppManager.GetProjectPath(Path.Combine("testapps", "WebAppNoDockerFile", "WebAppNoDockerFile.csproj"));
Expand All @@ -154,8 +96,11 @@ public async Task WindowsEBDefaultConfigurations(string configFile)
var applicationUrl = endpointLine.Substring(endpointLine.IndexOf(":") + 1).Trim();
Assert.True(Uri.IsWellFormedUriString(applicationUrl, UriKind.Absolute));

// "extra-path" is the IISAppPath set in the config file.
applicationUrl = new Uri(new Uri(applicationUrl), "extra-path").AbsoluteUri;
if(!linux)
{
// "extra-path" is the IISAppPath set in the config file.
applicationUrl = new Uri(new Uri(applicationUrl), "extra-path").AbsoluteUri;
}

// URL could take few more minutes to come live, therefore, we want to wait and keep trying for a specified timeout
await _httpHelper.WaitUntilSuccessStatusCode(applicationUrl, TimeSpan.FromSeconds(5), TimeSpan.FromMinutes(5));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"RecipeId": "AspNetAppElasticBeanstalkLinux",
"Settings": {
"SelfContainedBuild": true
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"RecipeId": "AspNetAppElasticBeanstalkLinux",
"Settings": {

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"RecipeId": "AspNetAppExistingBeanstalkEnvironment",
"Settings": {
"SelfContainedBuild": true
}
}
Loading

0 comments on commit 1a8d3af

Please sign in to comment.