From ab5946c687e89c621676ae4e5a261c33851c0e0e Mon Sep 17 00:00:00 2001 From: Ryan Nowak Date: Sat, 29 Feb 2020 12:40:34 -0800 Subject: [PATCH] code dump of opulence --- src/opulence/Opulence/ApplicationGlobals.cs | 11 + src/opulence/Opulence/ContainerInfo.cs | 46 ++++ src/opulence/Opulence/ContainerRegistry.cs | 19 ++ src/opulence/Opulence/DeploymentKind.cs | 9 + src/opulence/Opulence/DockerImage.cs | 19 ++ src/opulence/Opulence/EnvironmentAttribute.cs | 19 ++ src/opulence/Opulence/Environments.cs | 9 + src/opulence/Opulence/Framework.cs | 19 ++ src/opulence/Opulence/FrameworkCollection.cs | 22 ++ src/opulence/Opulence/GeneratedAssets.cs | 7 + src/opulence/Opulence/HelmChartStep.cs | 9 + src/opulence/Opulence/Opulence.csproj | 13 + src/opulence/Opulence/Pipeline.cs | 53 ++++ src/opulence/Opulence/PipelineHolder.cs | 19 ++ src/opulence/Opulence/Project.cs | 25 ++ src/opulence/Opulence/Secret.cs | 7 + src/opulence/Opulence/Service.cs | 34 +++ src/opulence/Opulence/ServiceBinding.cs | 35 +++ src/opulence/Opulence/Source.cs | 9 + src/opulence/Opulence/Step.cs | 7 + src/opulence/dotnet-opulence/Application.cs | 227 ++++++++++++++++ .../dotnet-opulence/ApplicationFactory.cs | 142 ++++++++++ .../dotnet-opulence/ApplicationYamlWriter.cs | 26 ++ .../dotnet-opulence/BuildDockerImageStep.cs | 31 +++ src/opulence/dotnet-opulence/CombineStep.cs | 97 +++++++ .../dotnet-opulence/CommandException.cs | 15 + .../dotnet-opulence/ConsoleExtensions.cs | 67 +++++ .../dotnet-opulence/CustomizationPipeline.cs | 159 +++++++++++ src/opulence/dotnet-opulence/DeployCommand.cs | 136 ++++++++++ .../dotnet-opulence/DeployServiceYamlStep.cs | 62 +++++ src/opulence/dotnet-opulence/DirectoryCopy.cs | 28 ++ .../dotnet-opulence/DirectorySearch.cs | 81 ++++++ .../dotnet-opulence/DockerContainerBuilder.cs | 70 +++++ src/opulence/dotnet-opulence/DockerPush.cs | 42 +++ .../dotnet-opulence/DockerfileGenerator.cs | 142 ++++++++++ .../dotnet-opulence/GenerateCommand.cs | 59 ++++ .../dotnet-opulence/GenerateDockerfileStep.cs | 43 +++ .../dotnet-opulence/GenerateHelmChartStep.cs | 47 ++++ .../GenerateKubernetesManifestStep.cs | 29 ++ .../GenerateOamComponentStep.cs | 29 ++ .../dotnet-opulence/HelmChartBuilder.cs | 83 ++++++ .../dotnet-opulence/HelmChartGenerator.cs | 111 ++++++++ src/opulence/dotnet-opulence/HelmPush.cs | 52 ++++ .../dotnet-opulence/IYamlManifestOutput.cs | 9 + src/opulence/dotnet-opulence/InitCommand.cs | 193 +++++++++++++ .../KubernetesDeploymentOutput.cs | 27 ++ .../KubernetesManifestGenerator.cs | 196 ++++++++++++++ .../KubernetesServiceOutput.cs | 27 ++ src/opulence/dotnet-opulence/Names.cs | 32 +++ .../OamApplicationGenerator.cs | 84 ++++++ .../dotnet-opulence/OamComponentGenerator.cs | 54 ++++ .../dotnet-opulence/OamComponentOutput.cs | 27 ++ src/opulence/dotnet-opulence/OutputContext.cs | 256 ++++++++++++++++++ .../dotnet-opulence/PackageCommand.cs | 119 ++++++++ src/opulence/dotnet-opulence/Program.cs | 82 ++++++ src/opulence/dotnet-opulence/ProjectReader.cs | 118 ++++++++ .../Properties/launchSettings.json | 8 + src/opulence/dotnet-opulence/PushCommand.cs | 55 ++++ .../dotnet-opulence/PushDockerImageStep.cs | 36 +++ .../dotnet-opulence/Resources/Imports.targets | 8 + src/opulence/dotnet-opulence/ScriptRunner.cs | 166 ++++++++++++ .../dotnet-opulence/ServiceExecutor.cs | 137 ++++++++++ .../dotnet-opulence/StandardOptions.cs | 184 +++++++++++++ src/opulence/dotnet-opulence/StepView.cs | 7 + .../dotnet-opulence/TargetInstaller.cs | 47 ++++ src/opulence/dotnet-opulence/TempDirectory.cs | 27 ++ src/opulence/dotnet-opulence/TempFile.cs | 26 ++ src/opulence/dotnet-opulence/Templates.cs | 25 ++ .../Templates/Helm/templates/_helpers.tpl | 45 +++ .../Templates/Helm/templates/deployment.yaml | 30 ++ .../Templates/Helm/templates/service.yaml | 16 ++ src/opulence/dotnet-opulence/Verbosity.cs | 9 + .../dotnet-opulence/WriteServiceYamlStep.cs | 43 +++ .../dotnet-opulence/build/Opulence.targets | 28 ++ .../buildMultiTargeting/Opulence.targets | 20 ++ .../dotnet-opulence/dotnet-opulence.csproj | 39 +++ tye.sln | 33 +++ 77 files changed, 4382 insertions(+) create mode 100644 src/opulence/Opulence/ApplicationGlobals.cs create mode 100644 src/opulence/Opulence/ContainerInfo.cs create mode 100644 src/opulence/Opulence/ContainerRegistry.cs create mode 100644 src/opulence/Opulence/DeploymentKind.cs create mode 100644 src/opulence/Opulence/DockerImage.cs create mode 100644 src/opulence/Opulence/EnvironmentAttribute.cs create mode 100644 src/opulence/Opulence/Environments.cs create mode 100644 src/opulence/Opulence/Framework.cs create mode 100644 src/opulence/Opulence/FrameworkCollection.cs create mode 100644 src/opulence/Opulence/GeneratedAssets.cs create mode 100644 src/opulence/Opulence/HelmChartStep.cs create mode 100644 src/opulence/Opulence/Opulence.csproj create mode 100644 src/opulence/Opulence/Pipeline.cs create mode 100644 src/opulence/Opulence/PipelineHolder.cs create mode 100644 src/opulence/Opulence/Project.cs create mode 100644 src/opulence/Opulence/Secret.cs create mode 100644 src/opulence/Opulence/Service.cs create mode 100644 src/opulence/Opulence/ServiceBinding.cs create mode 100644 src/opulence/Opulence/Source.cs create mode 100644 src/opulence/Opulence/Step.cs create mode 100644 src/opulence/dotnet-opulence/Application.cs create mode 100644 src/opulence/dotnet-opulence/ApplicationFactory.cs create mode 100644 src/opulence/dotnet-opulence/ApplicationYamlWriter.cs create mode 100644 src/opulence/dotnet-opulence/BuildDockerImageStep.cs create mode 100644 src/opulence/dotnet-opulence/CombineStep.cs create mode 100644 src/opulence/dotnet-opulence/CommandException.cs create mode 100644 src/opulence/dotnet-opulence/ConsoleExtensions.cs create mode 100644 src/opulence/dotnet-opulence/CustomizationPipeline.cs create mode 100644 src/opulence/dotnet-opulence/DeployCommand.cs create mode 100644 src/opulence/dotnet-opulence/DeployServiceYamlStep.cs create mode 100644 src/opulence/dotnet-opulence/DirectoryCopy.cs create mode 100644 src/opulence/dotnet-opulence/DirectorySearch.cs create mode 100644 src/opulence/dotnet-opulence/DockerContainerBuilder.cs create mode 100644 src/opulence/dotnet-opulence/DockerPush.cs create mode 100644 src/opulence/dotnet-opulence/DockerfileGenerator.cs create mode 100644 src/opulence/dotnet-opulence/GenerateCommand.cs create mode 100644 src/opulence/dotnet-opulence/GenerateDockerfileStep.cs create mode 100644 src/opulence/dotnet-opulence/GenerateHelmChartStep.cs create mode 100644 src/opulence/dotnet-opulence/GenerateKubernetesManifestStep.cs create mode 100644 src/opulence/dotnet-opulence/GenerateOamComponentStep.cs create mode 100644 src/opulence/dotnet-opulence/HelmChartBuilder.cs create mode 100644 src/opulence/dotnet-opulence/HelmChartGenerator.cs create mode 100644 src/opulence/dotnet-opulence/HelmPush.cs create mode 100644 src/opulence/dotnet-opulence/IYamlManifestOutput.cs create mode 100644 src/opulence/dotnet-opulence/InitCommand.cs create mode 100644 src/opulence/dotnet-opulence/KubernetesDeploymentOutput.cs create mode 100644 src/opulence/dotnet-opulence/KubernetesManifestGenerator.cs create mode 100644 src/opulence/dotnet-opulence/KubernetesServiceOutput.cs create mode 100644 src/opulence/dotnet-opulence/Names.cs create mode 100644 src/opulence/dotnet-opulence/OamApplicationGenerator.cs create mode 100644 src/opulence/dotnet-opulence/OamComponentGenerator.cs create mode 100644 src/opulence/dotnet-opulence/OamComponentOutput.cs create mode 100644 src/opulence/dotnet-opulence/OutputContext.cs create mode 100644 src/opulence/dotnet-opulence/PackageCommand.cs create mode 100644 src/opulence/dotnet-opulence/Program.cs create mode 100644 src/opulence/dotnet-opulence/ProjectReader.cs create mode 100644 src/opulence/dotnet-opulence/Properties/launchSettings.json create mode 100644 src/opulence/dotnet-opulence/PushCommand.cs create mode 100644 src/opulence/dotnet-opulence/PushDockerImageStep.cs create mode 100644 src/opulence/dotnet-opulence/Resources/Imports.targets create mode 100644 src/opulence/dotnet-opulence/ScriptRunner.cs create mode 100644 src/opulence/dotnet-opulence/ServiceExecutor.cs create mode 100644 src/opulence/dotnet-opulence/StandardOptions.cs create mode 100644 src/opulence/dotnet-opulence/StepView.cs create mode 100644 src/opulence/dotnet-opulence/TargetInstaller.cs create mode 100644 src/opulence/dotnet-opulence/TempDirectory.cs create mode 100644 src/opulence/dotnet-opulence/TempFile.cs create mode 100644 src/opulence/dotnet-opulence/Templates.cs create mode 100644 src/opulence/dotnet-opulence/Templates/Helm/templates/_helpers.tpl create mode 100644 src/opulence/dotnet-opulence/Templates/Helm/templates/deployment.yaml create mode 100644 src/opulence/dotnet-opulence/Templates/Helm/templates/service.yaml create mode 100644 src/opulence/dotnet-opulence/Verbosity.cs create mode 100644 src/opulence/dotnet-opulence/WriteServiceYamlStep.cs create mode 100644 src/opulence/dotnet-opulence/build/Opulence.targets create mode 100644 src/opulence/dotnet-opulence/buildMultiTargeting/Opulence.targets create mode 100644 src/opulence/dotnet-opulence/dotnet-opulence.csproj diff --git a/src/opulence/Opulence/ApplicationGlobals.cs b/src/opulence/Opulence/ApplicationGlobals.cs new file mode 100644 index 00000000..f417f32d --- /dev/null +++ b/src/opulence/Opulence/ApplicationGlobals.cs @@ -0,0 +1,11 @@ +namespace Opulence +{ + public sealed class ApplicationGlobals + { + public DeploymentKind DeploymentKind { get; set; } = DeploymentKind.Kubernetes; + + public string? Name { get; set; } + + public ContainerRegistry? Registry { get; set; } + } +} \ No newline at end of file diff --git a/src/opulence/Opulence/ContainerInfo.cs b/src/opulence/Opulence/ContainerInfo.cs new file mode 100644 index 00000000..529a4c1c --- /dev/null +++ b/src/opulence/Opulence/ContainerInfo.cs @@ -0,0 +1,46 @@ +namespace Opulence +{ + public sealed class ContainerInfo + { + /// + /// Gets or sets the name of the base image. If null, the base image will be chosen + /// based on the project configuration. + /// + public string? BaseImageName { get; set; } + + /// + /// Gets or sets the name of the base image tag. If null, the base image tag will be chosen + /// based on the project configuration. + /// + public string? BaseImageTag { get; set; } + + /// + /// Gets or sets the name of the build image. If null, the build image will be chosen + /// based on the project configuration. + /// + public string? BuildImageName { get; set; } + + /// + /// Gets or sets the name of the base image tag. If null, the build image tag will be chosen + /// based on the project configuration. + /// + public string? BuildImageTag { get; set; } + + /// + /// Gets or sets the name of the image. If null, the image name will be chosen + /// based on the project name. + /// + public string? ImageName { get; set; } + + /// + /// Gets or sets the tag of the image. If null, the image tag will be chosen + /// based on the project version. + /// + public string? ImageTag { get; set; } + + /// + /// Gets or a sets value which determines whether a multiphase dockerfile is used. + /// + public bool? UseMultiphaseDockerfile { get; set; } + } +} diff --git a/src/opulence/Opulence/ContainerRegistry.cs b/src/opulence/Opulence/ContainerRegistry.cs new file mode 100644 index 00000000..1a50be7e --- /dev/null +++ b/src/opulence/Opulence/ContainerRegistry.cs @@ -0,0 +1,19 @@ +using System; + +namespace Opulence +{ + public sealed class ContainerRegistry + { + public ContainerRegistry(string hostname) + { + if (hostname is null) + { + throw new ArgumentNullException(nameof(hostname)); + } + + Hostname = hostname; + } + + public string Hostname { get; } + } +} \ No newline at end of file diff --git a/src/opulence/Opulence/DeploymentKind.cs b/src/opulence/Opulence/DeploymentKind.cs new file mode 100644 index 00000000..8137238f --- /dev/null +++ b/src/opulence/Opulence/DeploymentKind.cs @@ -0,0 +1,9 @@ +namespace Opulence +{ + public enum DeploymentKind + { + None, + Kubernetes, + Oam, + } +} \ No newline at end of file diff --git a/src/opulence/Opulence/DockerImage.cs b/src/opulence/Opulence/DockerImage.cs new file mode 100644 index 00000000..d023c1ec --- /dev/null +++ b/src/opulence/Opulence/DockerImage.cs @@ -0,0 +1,19 @@ +using System; + +namespace Opulence +{ + public sealed class DockerImage : Source + { + public DockerImage(string image) + { + if (image is null) + { + throw new ArgumentNullException(nameof(image)); + } + + Image = image; + } + + public string Image { get; } + } +} \ No newline at end of file diff --git a/src/opulence/Opulence/EnvironmentAttribute.cs b/src/opulence/Opulence/EnvironmentAttribute.cs new file mode 100644 index 00000000..c4238588 --- /dev/null +++ b/src/opulence/Opulence/EnvironmentAttribute.cs @@ -0,0 +1,19 @@ +using System; + +namespace Opulence +{ + public class EnvironmentAttribute : Attribute + { + public EnvironmentAttribute(string environmentName) + { + if (environmentName is null) + { + throw new ArgumentNullException(nameof(environmentName)); + } + + EnvironmentName = environmentName; + } + + public string EnvironmentName { get; } + } +} \ No newline at end of file diff --git a/src/opulence/Opulence/Environments.cs b/src/opulence/Opulence/Environments.cs new file mode 100644 index 00000000..a89a9a95 --- /dev/null +++ b/src/opulence/Opulence/Environments.cs @@ -0,0 +1,9 @@ +namespace Opulence +{ + public static class Environments + { + public const string Development = "Development"; + + public const string Production = "Production"; + } +} \ No newline at end of file diff --git a/src/opulence/Opulence/Framework.cs b/src/opulence/Opulence/Framework.cs new file mode 100644 index 00000000..e5c4d11d --- /dev/null +++ b/src/opulence/Opulence/Framework.cs @@ -0,0 +1,19 @@ +using System; + +namespace Opulence +{ + public sealed class Framework + { + public Framework(string name) + { + if (name is null) + { + throw new ArgumentNullException(nameof(name)); + } + + Name = name; + } + + public string Name { get; } + } +} \ No newline at end of file diff --git a/src/opulence/Opulence/FrameworkCollection.cs b/src/opulence/Opulence/FrameworkCollection.cs new file mode 100644 index 00000000..522cf077 --- /dev/null +++ b/src/opulence/Opulence/FrameworkCollection.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; + +namespace Opulence +{ + public sealed class FrameworkCollection : Collection + { + public void AddRange(IEnumerable items) + { + if (items is null) + { + throw new ArgumentNullException(nameof(items)); + } + + foreach (var item in items) + { + Add(item); + } + } + } +} \ No newline at end of file diff --git a/src/opulence/Opulence/GeneratedAssets.cs b/src/opulence/Opulence/GeneratedAssets.cs new file mode 100644 index 00000000..07822fbc --- /dev/null +++ b/src/opulence/Opulence/GeneratedAssets.cs @@ -0,0 +1,7 @@ +namespace Opulence +{ + public sealed class GeneratedAssets + { + public ContainerInfo? Container { get; set; } + } +} \ No newline at end of file diff --git a/src/opulence/Opulence/HelmChartStep.cs b/src/opulence/Opulence/HelmChartStep.cs new file mode 100644 index 00000000..1ee05666 --- /dev/null +++ b/src/opulence/Opulence/HelmChartStep.cs @@ -0,0 +1,9 @@ +namespace Opulence +{ + public sealed class HelmChartStep : Step + { + public override string DisplayName => "Building Helm Chart..."; + + public string ChartName { get; set; } = default!; + } +} \ No newline at end of file diff --git a/src/opulence/Opulence/Opulence.csproj b/src/opulence/Opulence/Opulence.csproj new file mode 100644 index 00000000..7cd4d233 --- /dev/null +++ b/src/opulence/Opulence/Opulence.csproj @@ -0,0 +1,13 @@ + + + + netstandard2.0 + 8.0 + Enable + + + + + + + diff --git a/src/opulence/Opulence/Pipeline.cs b/src/opulence/Opulence/Pipeline.cs new file mode 100644 index 00000000..cf07fd95 --- /dev/null +++ b/src/opulence/Opulence/Pipeline.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Opulence +{ + public abstract class Pipeline + { + private static readonly Dictionary> callbacks = new Dictionary>(); + + public static void Configure(Action callback) + { + if (callback is null) + { + throw new ArgumentNullException(nameof(callback)); + } + + if (!callbacks.TryGetValue(typeof(TApplication), out var delegates)) + { + delegates = new List(); + callbacks.Add(typeof(TApplication), delegates); + } + + delegates.Add(callback); + } + + public static Task ExecuteAsync(Pipeline pipeline) + { + if (pipeline is null) + { + throw new ArgumentNullException(nameof(pipeline)); + } + + foreach (var kvp in callbacks) + { + var type = kvp.Key; + var delegates = kvp.Value; + + var method = pipeline.GetType().GetMethod("Register").MakeGenericMethod(type); + for (var i = 0; i < delegates.Count; i++) + { + method.Invoke(pipeline, new[]{ delegates[i], }); + } + } + + return pipeline.ExecuteAsync(); + } + + public abstract void Register(Action callback); + + public abstract Task ExecuteAsync(); + } +} \ No newline at end of file diff --git a/src/opulence/Opulence/PipelineHolder.cs b/src/opulence/Opulence/PipelineHolder.cs new file mode 100644 index 00000000..0b0165e7 --- /dev/null +++ b/src/opulence/Opulence/PipelineHolder.cs @@ -0,0 +1,19 @@ +using System; + +namespace Opulence +{ + public sealed class PipelineHolder + { + public PipelineHolder(Pipeline pipeline) + { + if (pipeline is null) + { + throw new ArgumentNullException(nameof(pipeline)); + } + + __Pipeline = pipeline; + } + + public Pipeline __Pipeline { get; } + } +} \ No newline at end of file diff --git a/src/opulence/Opulence/Project.cs b/src/opulence/Opulence/Project.cs new file mode 100644 index 00000000..8ed0a94a --- /dev/null +++ b/src/opulence/Opulence/Project.cs @@ -0,0 +1,25 @@ +using System; + +namespace Opulence +{ + public sealed class Project : Source + { + public Project(string relativeFilePath) + { + if (relativeFilePath is null) + { + throw new ArgumentNullException(nameof(relativeFilePath)); + } + + RelativeFilePath = relativeFilePath; + } + + public string RelativeFilePath { get; } + + public FrameworkCollection Frameworks { get; } = new FrameworkCollection(); + + public string TargetFramework { get; set; } = default!; + + public string Version { get; set; } = default!; + } +} \ No newline at end of file diff --git a/src/opulence/Opulence/Secret.cs b/src/opulence/Opulence/Secret.cs new file mode 100644 index 00000000..0c821ceb --- /dev/null +++ b/src/opulence/Opulence/Secret.cs @@ -0,0 +1,7 @@ +namespace Opulence +{ + public class Secret + { + public string? Name { get; set; } + } +} \ No newline at end of file diff --git a/src/opulence/Opulence/Service.cs b/src/opulence/Opulence/Service.cs new file mode 100644 index 00000000..5549902e --- /dev/null +++ b/src/opulence/Opulence/Service.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; + +namespace Opulence +{ + public sealed class Service + { + public Service(string name) + { + if (name is null) + { + throw new ArgumentNullException(nameof(name)); + } + + Name = name; + } + + public string Name { get; } + + public GeneratedAssets GeneratedAssets { get; } = new GeneratedAssets(); + + public int? Port { get; set; } + + public string? Protocol { get; set; } + + public Source? Source { get; set; } + + public Dictionary Environment { get; set; } = new Dictionary(); + + public int Replicas { get; set; } = 1; + + public List Bindings { get; } = new List(); + } +} \ No newline at end of file diff --git a/src/opulence/Opulence/ServiceBinding.cs b/src/opulence/Opulence/ServiceBinding.cs new file mode 100644 index 00000000..94773df8 --- /dev/null +++ b/src/opulence/Opulence/ServiceBinding.cs @@ -0,0 +1,35 @@ +using System; + +namespace Opulence +{ + public class ServiceBinding + { + public ServiceBinding(string name) + { + if (name is null) + { + throw new ArgumentNullException(nameof(name)); + } + + Name = name; + } + + public ServiceBinding(Service service) + { + if (service is null) + { + throw new ArgumentNullException(nameof(service)); + } + + Name = service.Name; + + // We don't copy other properties here because a "hardcoded" value of Protocol/Port is treated differently + // from just binding the name when considering environments. + } + + public string Name { get; set; } + public string? Protocol { get; set; } + public int? Port { get; set; } + public Secret? ConnectionString { get; set; } + } +} \ No newline at end of file diff --git a/src/opulence/Opulence/Source.cs b/src/opulence/Opulence/Source.cs new file mode 100644 index 00000000..c353af29 --- /dev/null +++ b/src/opulence/Opulence/Source.cs @@ -0,0 +1,9 @@ +namespace Opulence +{ + public abstract class Source + { + internal Source() + { + } + } +} \ No newline at end of file diff --git a/src/opulence/Opulence/Step.cs b/src/opulence/Opulence/Step.cs new file mode 100644 index 00000000..7629aab9 --- /dev/null +++ b/src/opulence/Opulence/Step.cs @@ -0,0 +1,7 @@ +namespace Opulence +{ + public abstract class Step + { + public abstract string DisplayName { get; } + } +} \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/Application.cs b/src/opulence/dotnet-opulence/Application.cs new file mode 100644 index 00000000..d609defe --- /dev/null +++ b/src/opulence/dotnet-opulence/Application.cs @@ -0,0 +1,227 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; + +namespace Opulence +{ + internal abstract class Application + { + public abstract ApplicationGlobals Globals { get; } + + public abstract string RootDirectory { get; } + + public abstract IReadOnlyList Services { get; } + + public string GetProjectDirectory(Project project) + { + if (project is null) + { + throw new ArgumentNullException(nameof(project)); + } + + return Path.GetDirectoryName(Path.Combine(RootDirectory, project.RelativeFilePath))!; + } + } + + internal class ServiceEntry + { + public ServiceEntry(Service service, string friendlyName, IEnumerable? environments = null) + { + if (service is null) + { + throw new ArgumentNullException(nameof(service)); + } + + if (friendlyName is null) + { + throw new ArgumentNullException(nameof(friendlyName)); + } + + Service = service; + FriendlyName = friendlyName; + + Environments = environments?.ToArray() ?? Array.Empty(); + } + + public IReadOnlyList Environments { get; } + + public string FriendlyName { get; } + + public Service Service { get; } + + public List Outputs { get; } = new List(); + + public bool AppliesToEnvironment(string environment) + { + if (environment is null) + { + throw new ArgumentNullException(nameof(environment)); + } + + return Environments.Count == 0 || Environments.Contains(environment, StringComparer.OrdinalIgnoreCase); + } + + internal bool IsMatchForProject(Application application, FileInfo projectFile) + { + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + if (projectFile is null) + { + throw new ArgumentNullException(nameof(projectFile)); + } + + if (string.Equals(projectFile.Extension, ".sln", StringComparison.Ordinal)) + { + return true; + } + + if (Service.Source is Project project && + string.Equals(application.GetProjectDirectory(project), projectFile.DirectoryName, StringComparison.Ordinal)) + { + return true; + } + + return false; + } + } + + public abstract class ServiceOutput + { + } + + public class DockerImageOutput : ServiceOutput + { + public DockerImageOutput(string imageName, string imageTag) + { + if (imageName is null) + { + throw new ArgumentNullException(nameof(imageName)); + } + + if (imageTag is null) + { + throw new ArgumentNullException(nameof(imageTag)); + } + + ImageName = imageName; + ImageTag = imageTag; + } + + public string ImageName { get; } + + public string ImageTag { get; } + } + + + internal class ApplicationWrapper : Application + { + private readonly object inner; + private ApplicationGlobals? globals; + private List? services; + + public ApplicationWrapper(object inner, string rootDirectory) + { + if (inner is null) + { + throw new ArgumentNullException(nameof(inner)); + } + + if (rootDirectory is null) + { + throw new ArgumentNullException(nameof(rootDirectory)); + } + + this.inner = inner; + RootDirectory = rootDirectory; + } + + public override ApplicationGlobals Globals + { + get + { + if (globals == null) + { + var property = inner.GetType().GetProperty("Globals", BindingFlags.Instance | BindingFlags.Public); + if (property != null && property.PropertyType == typeof(ApplicationGlobals)) + { + globals = (ApplicationGlobals?)property.GetValue(inner, null); + } + + globals ??= new ApplicationGlobals(); + } + + return globals!; + } + } + + public override string RootDirectory { get; } + + public override IReadOnlyList Services + { + get + { + if (services == null) + { + services = new List(); + var properties = inner.GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public); + for (var i = 0; i < properties.Length; i++) + { + var property = properties[i]; + if (property.PropertyType == typeof(Service)) + { + var environments = new List(); + var environmentAttributes = property.GetCustomAttributes(); + environments.AddRange(environmentAttributes.Select(e => e.EnvironmentName)); + + var value = property.GetValue(inner, null); + if (value == null) + { + throw new InvalidOperationException("Service properties must return a non-null value."); + } + + services.Add(new ServiceEntry((Service)value, property.Name, environments)); + } + } + } + + return services; + } + } + } + + internal class GroveledApplication : Application + { + public GroveledApplication(ApplicationGlobals globals, string rootDirectory, IEnumerable services) + { + if (globals is null) + { + throw new ArgumentNullException(nameof(globals)); + } + + if (rootDirectory is null) + { + throw new ArgumentNullException(nameof(rootDirectory)); + } + + if (services is null) + { + throw new ArgumentNullException(nameof(services)); + } + + Globals = globals; + RootDirectory = rootDirectory; + Services = services.ToArray(); + } + + public override ApplicationGlobals Globals { get; } + + public override string RootDirectory { get; } + + public override IReadOnlyList Services { get; } + } +} \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/ApplicationFactory.cs b/src/opulence/dotnet-opulence/ApplicationFactory.cs new file mode 100644 index 00000000..edccf541 --- /dev/null +++ b/src/opulence/dotnet-opulence/ApplicationFactory.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Microsoft.Build.Construction; + +namespace Opulence +{ + internal static class ApplicationFactory + { + public static async Task CreateApplicationAsync(OutputContext output, FileInfo projectFile) + { + if (output is null) + { + throw new ArgumentNullException(nameof(output)); + } + + if (projectFile is null) + { + throw new ArgumentNullException(nameof(projectFile)); + } + + if (string.Equals(projectFile.Extension, ".sln", StringComparison.Ordinal)) + { + output.WriteDebugLine($"Solution '{projectFile.FullName}' was provided as input."); + return await CreateApplicationForSolutionAsync(output, projectFile); + } + else + { + output.WriteDebugLine($"Project '{projectFile.FullName}' was provided as input."); + return await CreateApplicationForProjectAsync(output, projectFile); + } + } + + private static async Task CreateApplicationForProjectAsync(OutputContext output, FileInfo projectFile) + { + // Project workflow: + // + // 1. Determine if there's an 'Opulence.csx' - use that to initialize the set of services. + // 2. If there's not an 'Opulence.csx' then move on with just the project. + + var application = await ScriptRunner.RunCustomizationScriptAsync(output, projectFile); + if (application != null) + { + return application; + } + + return await InferApplicationForProject(output, projectFile); + } + + private static async Task InferApplicationForProject(OutputContext output, FileInfo projectFile) + { + var globals = new ApplicationGlobals(); + globals.Name = Names.NormalizeToDns(Path.GetFileNameWithoutExtension(projectFile.Name)); + + var services = new List(); + + var name = Path.GetFileNameWithoutExtension(projectFile.Name); + var project = new Project(projectFile.FullName); + var service = new ServiceEntry(new Service(name), name); + service.Service.Source = project; + services.Add(service); + + await ProjectReader.ReadProjectDetailsAsync(output, projectFile, project); + + var container = new ContainerInfo(); + service.Service.GeneratedAssets.Container = container; + + var application = new GroveledApplication(globals, projectFile.DirectoryName, services); + + DockerfileGenerator.ApplyContainerDefaults(application, service, project, container); + + return application; + } + + private static async Task CreateApplicationForSolutionAsync(OutputContext output, FileInfo solutionFile) + { + // Solution workflow: + // + // 1. If there's an 'Opulence.csx' - use that that to initialize the set of services. + // 2. If there's not an 'Opulence.csx' then grovel all of the projects in the solution looking + // for executable projects. + + SolutionFile solution; + try + { + solution = SolutionFile.Parse(solutionFile.FullName); + } + catch (Exception ex) + { + throw new CommandException($"Parsing solution file '{solutionFile.FullName}' failed.", ex); + } + + var application = await ScriptRunner.RunCustomizationScriptAsync(output, solutionFile, solution); + if (application != null) + { + return application; + } + + return await InferApplicationForSolution(output, solutionFile, solution); + } + + private static async Task InferApplicationForSolution(OutputContext output, FileInfo solutionFile, SolutionFile solution) + { + var globals = new ApplicationGlobals(); + + globals.Name = Names.NormalizeToDns(Path.GetFileNameWithoutExtension(solutionFile.Name)); + + var services = new List(); + for (var i = 0; i < solution.ProjectsInOrder.Count; i++) + { + // The library we're using doesn't translate Windows style paths automatically. + var solutionProject = solution.ProjectsInOrder[i]; + if (solutionProject.AbsolutePath.EndsWith(".csproj", StringComparison.Ordinal)) + { + var projectFilePath = solutionProject.RelativePath.Replace('\\', Path.DirectorySeparatorChar); + var projectFile = new FileInfo(projectFilePath); + + var name = Path.GetFileNameWithoutExtension(projectFile.Name); + var project = new Project(projectFile.FullName); + var service = new ServiceEntry(new Service(name), name); + service.Service.Source = project; + services.Add(service); + + await ProjectReader.ReadProjectDetailsAsync(output, projectFile, project); + + var container = new ContainerInfo(); + service.Service.GeneratedAssets.Container = container; + } + } + + var application = new GroveledApplication(globals, solutionFile.DirectoryName, services); + + foreach (var service in application.Services) + { + DockerfileGenerator.ApplyContainerDefaults(application, service, (Project)service.Service.Source!, service.Service.GeneratedAssets.Container!); + } + + return application; + } + } +} \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/ApplicationYamlWriter.cs b/src/opulence/dotnet-opulence/ApplicationYamlWriter.cs new file mode 100644 index 00000000..e3d3a19c --- /dev/null +++ b/src/opulence/dotnet-opulence/ApplicationYamlWriter.cs @@ -0,0 +1,26 @@ +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using YamlDotNet.RepresentationModel; + +namespace Opulence +{ + internal sealed class ApplicationYamlWriter + { + public static Task WriteAsync(OutputContext output, StreamWriter writer, Application application) + { + var yaml = application.Services.SelectMany(s => s.Outputs.OfType()).ToArray(); + if (yaml.Length == 0) + { + output.WriteDebugLine($"No yaml manifests found. Skipping."); + return Task.CompletedTask; + } + + var yamlStream = new YamlStream(yaml.Select(y => y.Yaml)); + yamlStream.Save(writer, assignAnchors: false); + + return Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/BuildDockerImageStep.cs b/src/opulence/dotnet-opulence/BuildDockerImageStep.cs new file mode 100644 index 00000000..20379af1 --- /dev/null +++ b/src/opulence/dotnet-opulence/BuildDockerImageStep.cs @@ -0,0 +1,31 @@ +using System.Threading.Tasks; + +namespace Opulence +{ + internal sealed class BuildDockerImageStep : ServiceExecutor.Step + { + public override string DisplayText => "Building Docker Image..."; + + public string Environment { get; set; } = "production"; + + public override async Task ExecuteAsync(OutputContext output, Application application, ServiceEntry service) + { + if (SkipWithoutProject(output, service, out var project)) + { + return; + } + + if (SkipWithoutContainerInfo(output, service, out var container)) + { + return; + } + + if (SkipForEnvironment(output, service, Environment)) + { + return; + } + + await DockerContainerBuilder.BuildContainerImageAsync(output, application, service, project, container); + } + } +} \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/CombineStep.cs b/src/opulence/dotnet-opulence/CombineStep.cs new file mode 100644 index 00000000..07a9a447 --- /dev/null +++ b/src/opulence/dotnet-opulence/CombineStep.cs @@ -0,0 +1,97 @@ +using System.CommandLine.Invocation; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using YamlDotNet.RepresentationModel; + +namespace Opulence +{ + internal sealed class CombineStep : ServiceExecutor.Step + { + public override string DisplayText => "Compiling Services..."; + + public string Environment { get; set; } = "production"; + + public override Task ExecuteAsync(OutputContext output, Application application, ServiceEntry service) + { + // Process bindings and turn them into environment variables. + foreach (var binding in service.Service.Bindings) + { + // Try to see if the other project is a service in this application. This can help with + // heuristics if the binding doesn't have all possible info specified. + var other = application.Services.FirstOrDefault(s => s.Service.Name == binding.Name); + + var key = $"SERVICES__{binding.Name}"; + + // Find the value that needs to go in this env-var (in priority order). + string value; + if (binding.ConnectionString != null) + { + binding.ConnectionString.Name ??= $"binding-{binding.Name}"; + + // It's a secret! We don't use env-vars for this. + continue; + } + else if (binding.Protocol != null) + { + // This is fully specified as a URL. + value = ResolveUri(binding.Protocol, binding.Name, binding.Port); + } + else if (other?.Service.Source != null && other.AppliesToEnvironment(Environment)) + { + // If we get here it means that the other service is built from source. + // In this case we'll assume that it's http on 80 as a reasonable guess. + value = ResolveUri(other.Service.Protocol ?? "http", binding.Name, other.Service.Port); + } + else if (other?.Service.Source != null) + { + // The other service is built from source, but doesn't apply to this environment. + // This is likely user-error. + throw new CommandException($"Unable to resolve the uri for binding '{binding.Name}'."); + } + else if (other?.Service.Source == null) + { + // The service isn't built from source. + binding.ConnectionString = new Secret() { Name = $"binding-{binding.Name}", }; + + // It's a secret! We don't use env-vars for this. + continue; + } + else + { + // Generic catch all case. We don't expect this to get hit because the three blocks + // above cover all possibilities, but the compiler doesn't agree. + throw new CommandException($"Unable to resolve the uri for binding '{binding.Name}'."); + } + + service.Service.Environment[key] = value; + } + + return Task.CompletedTask; + } + + private static string ResolveUri(string protocol, string name, int? port) + { + if (protocol == "http" && (port == 80 || port == null)) + { + return $"{protocol}://{name}"; + } + else if (protocol == "https" && (port == 443 || port == null)) + { + return $"{protocol}://{name}"; + } + else if (port == null) + { + return $"{protocol}://{name}"; + } + else + { + return $"{protocol}://{name}:{port}"; + } + } + } +} + + + diff --git a/src/opulence/dotnet-opulence/CommandException.cs b/src/opulence/dotnet-opulence/CommandException.cs new file mode 100644 index 00000000..41b64c9c --- /dev/null +++ b/src/opulence/dotnet-opulence/CommandException.cs @@ -0,0 +1,15 @@ +using System; + +namespace Opulence +{ + internal class CommandException : Exception + { + public CommandException(string message) + : base(message) + { } + + public CommandException(string message, Exception inner) + : base(message, inner) + { } + } +} \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/ConsoleExtensions.cs b/src/opulence/dotnet-opulence/ConsoleExtensions.cs new file mode 100644 index 00000000..832aeae7 --- /dev/null +++ b/src/opulence/dotnet-opulence/ConsoleExtensions.cs @@ -0,0 +1,67 @@ +using System.Linq; + +namespace System.CommandLine +{ + internal static class ConsoleExtensions + { + private static bool? _isConsoleRedirectionCheckSupported; + + private static bool IsConsoleRedirectionCheckSupported + { + get + { + if (_isConsoleRedirectionCheckSupported == null) + { + try + { + var check = Console.IsOutputRedirected; + _isConsoleRedirectionCheckSupported = true; + } + + catch (PlatformNotSupportedException) + { + _isConsoleRedirectionCheckSupported = false; + } + } + + return _isConsoleRedirectionCheckSupported.Value; + } + } + + public static void SetTerminalForegroundColor(this IConsole console, ConsoleColor color) + { + if (console.GetType().GetInterfaces().Any(i => i.Name == "ITerminal")) + { + ((dynamic)console).ForegroundColor = color; + } + + if (IsConsoleRedirectionCheckSupported && + !Console.IsOutputRedirected) + { + Console.ForegroundColor = color; + } + else if (IsConsoleRedirectionCheckSupported) + { + Console.ForegroundColor = color; + } + } + + public static void ResetTerminalForegroundColor(this IConsole console) + { + if (console.GetType().GetInterfaces().Any(i => i.Name == "ITerminal")) + { + ((dynamic)console).ForegroundColor = ConsoleColor.Red; + } + + if (IsConsoleRedirectionCheckSupported && + !Console.IsOutputRedirected) + { + Console.ResetColor(); + } + else if (IsConsoleRedirectionCheckSupported) + { + Console.ResetColor(); + } + } + } +} \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/CustomizationPipeline.cs b/src/opulence/dotnet-opulence/CustomizationPipeline.cs new file mode 100644 index 00000000..2a2d14b2 --- /dev/null +++ b/src/opulence/dotnet-opulence/CustomizationPipeline.cs @@ -0,0 +1,159 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Build.Construction; + +namespace Opulence +{ + internal class CustomizationPipeline : Pipeline + { + private readonly Dictionary> callbacks = new Dictionary>(); + + private readonly OutputContext output; + private readonly string rootDirectory; + private readonly string name; + private readonly SolutionFile? solution; + private readonly FileInfo? projectFile; + + public CustomizationPipeline(OutputContext output, string rootDirectory, string name, SolutionFile? solution, FileInfo? projectFile) + { + if (output is null) + { + throw new ArgumentNullException(nameof(output)); + } + + if (rootDirectory is null) + { + throw new ArgumentNullException(nameof(rootDirectory)); + } + + if (name is null) + { + throw new ArgumentNullException(nameof(name)); + } + + this.output = output; + this.rootDirectory = rootDirectory; + this.name = name; + this.solution = solution; + this.projectFile = projectFile; + } + + public override async Task ExecuteAsync() + { + if (callbacks.Count == 0) + { + return Task.FromResult(null); + } + + if (callbacks.Count > 1) + { + throw new InvalidOperationException("More than one application type is not supported."); + } + + var kvp = callbacks.Single(); + + var type = kvp.Key; + var delegates = kvp.Value; + + output.WriteDebugLine($"Creating instance of application type '{type}'."); + var application = Activator.CreateInstance(type); + output.WriteDebugLine($"Done creating instance of application type '{type}'."); + + var wrapper = new ApplicationWrapper(application!, rootDirectory); + + wrapper.Globals.Name ??= name; + + foreach (var service in wrapper.Services) + { + output.WriteDebugLine($"Found service '{service.FriendlyName} {{ Name: {service.Service.Name} }}'."); + + string? projectRelativeFilePath = null; + string? projectFilePath = null; + if (solution != null) + { + var project = FindProjectInSolution(solution, service.FriendlyName); + if (project == null) + { + output.WriteDebugLine($"Could not find project for service '{service.FriendlyName}'."); + continue; + } + + output.WriteDebugLine($"Found project '{project.RelativePath}' for service '{service.FriendlyName}'."); + projectRelativeFilePath = project.RelativePath.Replace('\\', Path.DirectorySeparatorChar); + projectFilePath = project.AbsolutePath.Replace('\\', Path.DirectorySeparatorChar); + } + else if (projectFile != null) + { + var normalized = Names.NormalizeToFriendly(Path.GetFileNameWithoutExtension(projectFile.Name)); + if (!string.Equals(normalized, service.FriendlyName)) + { + output.WriteDebugLine($"Skipping service '{service.FriendlyName}'."); + continue; + } + + projectRelativeFilePath = projectFile.FullName; + projectFilePath = projectFile.FullName; + } + + if (projectFilePath != null) + { + var project = new Project(projectRelativeFilePath!); + await ProjectReader.ReadProjectDetailsAsync(output, new FileInfo(projectFilePath), project); + + service.Service.Source = project; + + // Apply defaults to everything that has a project. + var container = new ContainerInfo(); + service.Service.GeneratedAssets.Container = container; + DockerfileGenerator.ApplyContainerDefaults(wrapper, service, project, container); + } + } + + output.WriteDebugLine($"Running {delegates.Count} customization callbacks."); + for (var i = 0; i < delegates.Count; i++) + { + delegates[i].DynamicInvoke(application); + } + output.WriteDebugLine($"Done running {delegates.Count} customization callbacks."); + + + return application; + } + + public override void Register(Action callback) + { + if (callback is null) + { + throw new ArgumentNullException(nameof(callback)); + } + + if (!callbacks.TryGetValue(typeof(TApplication), out var delegates)) + { + delegates = new List(); + callbacks.Add(typeof(TApplication), delegates); + } + + delegates.Add(callback); + } + + private static ProjectInSolution? FindProjectInSolution(SolutionFile solution, string projectName) + { + for (var i = 0; i < solution.ProjectsInOrder.Count; i++) + { + var project = solution.ProjectsInOrder[i]; + var normalized = Names.NormalizeToFriendly(project.ProjectName); + + if (string.Equals(normalized, projectName, StringComparison.Ordinal) && + project.AbsolutePath.EndsWith(".csproj", StringComparison.Ordinal)) + { + return project; + } + } + + return null; + } + } +} \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/DeployCommand.cs b/src/opulence/dotnet-opulence/DeployCommand.cs new file mode 100644 index 00000000..1cb71de5 --- /dev/null +++ b/src/opulence/dotnet-opulence/DeployCommand.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.CommandLine.Invocation; +using System.IO; +using System.Text; +using System.Threading.Tasks; + +namespace Opulence +{ + public class DeployCommand + { + public static Command Create() + { + var command = new Command("deploy", "Deploy the application to Kubernetes") + { + StandardOptions.Project, + StandardOptions.Verbosity, + StandardOptions.Environment, + }; + + command.Handler = CommandHandler.Create((console, project, environment, verbosity) => + { + return ExecuteAsync(new OutputContext(console, verbosity), project, environment); + }); + + return command; + } + + private static async Task ExecuteAsync(OutputContext output, FileInfo projectFile, string environment) + { + output.WriteBanner(); + + var application = await ApplicationFactory.CreateApplicationAsync(output, projectFile); + if (application.Globals.Registry?.Hostname == null) + { + throw new CommandException("A registry is required for deploy operations. run 'dotnet-opulence init'."); + } + + var steps = new List() + { + new CombineStep() { Environment = environment, }, + new BuildDockerImageStep() { Environment = environment, }, + new PushDockerImageStep() { Environment = environment, }, + }; + + if (application.Globals.DeploymentKind == DeploymentKind.None) + { + // No extra steps + } + else if (application.Globals.DeploymentKind == DeploymentKind.Kubernetes) + { + steps.Add(new GenerateKubernetesManifestStep() { Environment = environment, }); + } + else if (application.Globals.DeploymentKind == DeploymentKind.Oam) + { + steps.Add(new GenerateOamComponentStep() { Environment = environment, }); + } + else + { + throw new InvalidOperationException($"Unknown DeploymentKind: " + application.Globals.DeploymentKind); + } + + // If this is command is for a project, then deploy the component manifest + // for just the project. We won't run the "application deploy" part. + if (!string.Equals(".sln", projectFile.Extension, StringComparison.Ordinal)) + { + steps.Add(new DeployServiceYamlStep() { Environment = environment, }); + } + + var executor = new ServiceExecutor(output, application, steps); + foreach (var service in application.Services) + { + if (service.IsMatchForProject(application, projectFile)) + { + await executor.ExecuteAsync(service); + } + } + + if (string.Equals(".sln", projectFile.Extension, StringComparison.Ordinal)) + { + await PackageApplicationAsync(output, application, Path.GetFileNameWithoutExtension(projectFile.Name), environment); + } + } + + private static async Task PackageApplicationAsync(OutputContext output, Application application, string applicationName, string environment) + { + using var step = output.BeginStep("Deploying Application Manifests..."); + + using var tempFile = TempFile.Create(); + output.WriteInfoLine($"Writing output to '{tempFile.FilePath}'."); + + { + using var stream = File.OpenWrite(tempFile.FilePath); + using var writer = new StreamWriter(stream, Encoding.UTF8, leaveOpen: true); + + if (application.Globals.DeploymentKind == DeploymentKind.None) + { + // No extra steps + } + else if (application.Globals.DeploymentKind == DeploymentKind.Kubernetes) + { + await ApplicationYamlWriter.WriteAsync(output, writer, application); + } + else if (application.Globals.DeploymentKind == DeploymentKind.Oam) + { + await OamApplicationGenerator.WriteOamApplicationAsync(writer, output, application, applicationName, environment); + } + else + { + throw new InvalidOperationException($"Unknown DeploymentKind: " + application.Globals.DeploymentKind); + } + } + + output.WriteDebugLine("Running 'kubectl apply'."); + output.WriteCommandLine("kubectl", $"apply -f \"{tempFile.FilePath}\""); + var capture = output.Capture(); + var exitCode = await Process.ExecuteAsync( + $"kubectl", + $"apply -f \"{tempFile.FilePath}\"", + System.Environment.CurrentDirectory, + stdOut: capture.StdOut, + stdErr: capture.StdErr); + + output.WriteDebugLine($"Done running 'kubectl apply' exit code: {exitCode}"); + if (exitCode != 0) + { + throw new CommandException("'kubectl apply' failed."); + } + + output.WriteInfoLine($"Deployed application '{applicationName}'."); + + step.MarkComplete(); + } + } +} \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/DeployServiceYamlStep.cs b/src/opulence/dotnet-opulence/DeployServiceYamlStep.cs new file mode 100644 index 00000000..111bcb82 --- /dev/null +++ b/src/opulence/dotnet-opulence/DeployServiceYamlStep.cs @@ -0,0 +1,62 @@ +using System.CommandLine.Invocation; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using YamlDotNet.RepresentationModel; + +namespace Opulence +{ + internal sealed class DeployServiceYamlStep : ServiceExecutor.Step + { + public override string DisplayText => "Deploying Manifests..."; + + public string Environment { get; set; } = "production"; + + public override async Task ExecuteAsync(OutputContext output, Application application, ServiceEntry service) + { + var yaml = service.Outputs.OfType().ToArray(); + if (yaml.Length == 0) + { + output.WriteDebugLine($"No yaml manifests found for service '{service.FriendlyName}'. Skipping."); + return; + } + + using var tempFile = TempFile.Create(); + output.WriteDebugLine($"Writing output to '{tempFile.FilePath}'."); + + { + using var stream = File.OpenWrite(tempFile.FilePath); + using var writer = new StreamWriter(stream, Encoding.UTF8, leaveOpen: true); + var yamlStream = new YamlStream(yaml.Select(y => y.Yaml)); + yamlStream.Save(writer, assignAnchors: false); + } + + // kubectl apply logic is implemented in the client in older versions of k8s. The capability + // to get the same behavior in the server isn't present in every version that's relevant. + // + // https://kubernetes.io/docs/reference/using-api/api-concepts/#server-side-apply + // + output.WriteDebugLine("Running 'kubectl apply'."); + output.WriteCommandLine("kubectl", $"apply -f \"{tempFile.FilePath}\""); + var capture = output.Capture(); + var exitCode = await Process.ExecuteAsync( + $"kubectl", + $"apply -f \"{tempFile.FilePath}\"", + System.Environment.CurrentDirectory, + stdOut: capture.StdOut, + stdErr: capture.StdErr); + + output.WriteDebugLine($"Done running 'kubectl apply' exit code: {exitCode}"); + if (exitCode != 0) + { + throw new CommandException("'kubectl apply' failed."); + } + + output.WriteInfoLine($"Deployed service '{service.FriendlyName}'."); + } + } +} + + + diff --git a/src/opulence/dotnet-opulence/DirectoryCopy.cs b/src/opulence/dotnet-opulence/DirectoryCopy.cs new file mode 100644 index 00000000..ba3abc5f --- /dev/null +++ b/src/opulence/dotnet-opulence/DirectoryCopy.cs @@ -0,0 +1,28 @@ +using System.IO; + +namespace Opulence +{ + public static class DirectoryCopy + { + public static void Copy(string source, string destination) + { + CopyCore(new DirectoryInfo(source), new DirectoryInfo(destination)); + } + + private static void CopyCore(DirectoryInfo source, DirectoryInfo destination) + { + Directory.CreateDirectory(destination.FullName); + + foreach (var file in source.GetFiles()) + { + file.CopyTo(Path.Combine(destination.FullName, file.Name), true); + } + + foreach (var nextSource in source.GetDirectories()) + { + var nextDestination = destination.CreateSubdirectory(nextSource.Name); + CopyCore(nextSource, nextDestination); + } + } + } +} \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/DirectorySearch.cs b/src/opulence/dotnet-opulence/DirectorySearch.cs new file mode 100644 index 00000000..e6e47f75 --- /dev/null +++ b/src/opulence/dotnet-opulence/DirectorySearch.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace Opulence +{ + internal static class DirectorySearch + { + public static string? AscendingSearch(string directoryPath, string fileName) + { + if (directoryPath is null) + { + throw new ArgumentNullException(nameof(directoryPath)); + } + + if (fileName is null) + { + throw new ArgumentNullException(nameof(fileName)); + } + + var directory = new DirectoryInfo(directoryPath); + + while (true) + { + var filePath = Path.Combine(directory.FullName, fileName); + if (File.Exists(filePath)) + { + return filePath; + } + + if (directory == directory.Root) + { + return null; + } + + if (directory.Parent == null) + { + return null; + } + + directory = directory.Parent; + } + } + + public static IEnumerable AscendingWildcardSearch(string directoryPath, string searchPattern) + { + if (directoryPath is null) + { + throw new ArgumentNullException(nameof(directoryPath)); + } + + if (searchPattern is null) + { + throw new ArgumentNullException(nameof(searchPattern)); + } + + var directory = new DirectoryInfo(directoryPath); + + while (true) + { + if (directory.EnumerateFiles(searchPattern).Any()) + { + return directory.EnumerateFiles(searchPattern); + } + + if (directory == directory.Root) + { + return Array.Empty(); + } + + if (directory.Parent == null) + { + return Array.Empty(); + } + + directory = directory.Parent; + } + } + } +} \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/DockerContainerBuilder.cs b/src/opulence/dotnet-opulence/DockerContainerBuilder.cs new file mode 100644 index 00000000..15fd604d --- /dev/null +++ b/src/opulence/dotnet-opulence/DockerContainerBuilder.cs @@ -0,0 +1,70 @@ +using System; +using System.CommandLine.Invocation; +using System.IO; +using System.Threading.Tasks; + +namespace Opulence +{ + internal static class DockerContainerBuilder + { + public static async Task BuildContainerImageAsync(OutputContext output, Application application, ServiceEntry service, Project project, ContainerInfo container) + { + if (output is null) + { + throw new ArgumentNullException(nameof(output)); + } + + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + if (service is null) + { + throw new ArgumentNullException(nameof(service)); + } + + if (project is null) + { + throw new ArgumentNullException(nameof(project)); + } + + if (container is null) + { + throw new ArgumentNullException(nameof(container)); + } + + using var tempFile = TempFile.Create(); + + var dockerFilePath = Path.Combine(application.GetProjectDirectory(project), Path.GetDirectoryName(project.RelativeFilePath)!, "Dockerfile"); + if (File.Exists(dockerFilePath)) + { + output.WriteDebugLine($"Using existing dockerfile '{dockerFilePath}'."); + } + else + { + await DockerfileGenerator.WriteDockerfileAsync(output, application, service, project, container, tempFile.FilePath); + dockerFilePath = tempFile.FilePath; + } + + output.WriteDebugLine("Running 'docker build'."); + output.WriteCommandLine("docker", $"build . -t {container.ImageName}:{container.ImageTag} -f \"{dockerFilePath}\""); + var capture = output.Capture(); + var exitCode = await Process.ExecuteAsync( + $"docker", + $"build . -t {container.ImageName}:{container.ImageTag} -f \"{dockerFilePath}\"", + application.GetProjectDirectory(project), + stdOut: capture.StdOut, + stdErr: capture.StdErr); + + output.WriteDebugLine($"Done running 'docker build' exit code: {exitCode}"); + if (exitCode != 0) + { + throw new CommandException("'docker build' failed."); + } + + output.WriteInfoLine($"Created Docker Image: {container.ImageName}:{container.ImageTag}"); + service.Outputs.Add(new DockerImageOutput(container.ImageName!, container.ImageTag!)); + } + } +} \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/DockerPush.cs b/src/opulence/dotnet-opulence/DockerPush.cs new file mode 100644 index 00000000..1f6f6f2f --- /dev/null +++ b/src/opulence/dotnet-opulence/DockerPush.cs @@ -0,0 +1,42 @@ +using System; +using System.CommandLine.Invocation; +using System.Threading.Tasks; + +namespace Opulence +{ + internal static class DockerPush + { + public static async Task ExecuteAsync(OutputContext output, string imageName, string imageTag) + { + if (output is null) + { + throw new ArgumentNullException(nameof(output)); + } + + if (imageName is null) + { + throw new ArgumentNullException(nameof(imageName)); + } + + if (imageTag is null) + { + throw new ArgumentNullException(nameof(imageTag)); + } + + output.WriteDebugLine("Running 'docker push'."); + output.WriteCommandLine("docker", $"push {imageName}:{imageTag}"); + var capture = output.Capture(); + var exitCode = await Process.ExecuteAsync( + $"docker", + $"push {imageName}:{imageTag}", + stdOut: capture.StdOut, + stdErr: capture.StdErr); + + output.WriteDebugLine($"Done running 'docker push' exit code: {exitCode}"); + if (exitCode != 0) + { + throw new CommandException("'docker push' failed."); + } + } + } +} \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/DockerfileGenerator.cs b/src/opulence/dotnet-opulence/DockerfileGenerator.cs new file mode 100644 index 00000000..8603f82a --- /dev/null +++ b/src/opulence/dotnet-opulence/DockerfileGenerator.cs @@ -0,0 +1,142 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Opulence +{ + internal static class DockerfileGenerator + { + public static async Task WriteDockerfileAsync(OutputContext output, Application application, ServiceEntry service, Project project, ContainerInfo container, string filePath) + { + if (output is null) + { + throw new ArgumentNullException(nameof(output)); + } + + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + if (service is null) + { + throw new ArgumentNullException(nameof(service)); + } + + if (project is null) + { + throw new ArgumentNullException(nameof(project)); + } + + if (container is null) + { + throw new ArgumentNullException(nameof(container)); + } + + if (filePath is null) + { + throw new ArgumentNullException(nameof(filePath)); + } + + using var stream = File.OpenWrite(filePath); + using var writer = new StreamWriter(stream, encoding: Encoding.UTF8, leaveOpen: true); + + var entryPoint = Path.GetFileNameWithoutExtension(project.RelativeFilePath); + output.WriteDebugLine($"Writing dockerfile to '{filePath}'."); + if (container.UseMultiphaseDockerfile ?? true) + { + await WriteMultiphaseDockerfileAsync(writer, entryPoint, container); + } + else + { + await WriteLocalPublishDockerfileAsync(writer, entryPoint, container); + } + output.WriteDebugLine("Done writing dockerfile."); + } + + private static async Task WriteMultiphaseDockerfileAsync(StreamWriter writer, string applicationEntryPoint, ContainerInfo container) + { + await writer.WriteLineAsync($"FROM {container.BuildImageName}:{container.BuildImageTag} as SDK"); + await writer.WriteLineAsync($"WORKDIR /src"); + await writer.WriteLineAsync($"COPY . ."); + await writer.WriteLineAsync($"RUN dotnet publish -c Release -o /out"); + await writer.WriteLineAsync($"FROM {container.BaseImageName}:{container.BaseImageTag} as RUNTIME"); + await writer.WriteLineAsync($"WORKDIR /app"); + await writer.WriteLineAsync($"COPY --from=SDK /out ."); + await writer.WriteLineAsync($"ENTRYPOINT [\"dotnet\", \"{applicationEntryPoint}.dll\"]"); + } + + private static async Task WriteLocalPublishDockerfileAsync(StreamWriter writer, string applicationEntryPoint, ContainerInfo container) + { + await writer.WriteLineAsync($"FROM {container.BaseImageName}:{container.BaseImageTag}"); + await writer.WriteLineAsync($"WORKDIR /app"); + await writer.WriteLineAsync($"COPY . /app"); + await writer.WriteLineAsync($"ENTRYPOINT [\"dotnet\", \"{applicationEntryPoint}.dll\"]"); + } + + public static void ApplyContainerDefaults(Application application, ServiceEntry service, Project project, ContainerInfo container) + { + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + if (service is null) + { + throw new ArgumentNullException(nameof(service)); + } + + if (project is null) + { + throw new ArgumentNullException(nameof(project)); + } + + if (container is null) + { + throw new ArgumentNullException(nameof(container)); + } + + if (container.BaseImageName == null && + project.Frameworks.Any(f => f.Name == "Microsoft.AspNetCore.App")) + { + container.BaseImageName = "mcr.microsoft.com/dotnet/core/aspnet"; + } + else if (container.BaseImageName == null) + { + container.BaseImageName = "mcr.microsoft.com/dotnet/core/runtime"; + } + + if (container.BaseImageTag == null && + project.TargetFramework == "netcoreapp3.1") + { + container.BaseImageTag = "3.1"; + } + else if (container.BaseImageTag == null && + project.TargetFramework == "netcoreapp3.0") + { + container.BaseImageTag = "3.0"; + } + + if (container.BaseImageTag == null) + { + throw new CommandException($"Unsupported TFM {project.TargetFramework}."); + } + + container.BuildImageName ??= "mcr.microsoft.com/dotnet/core/sdk"; + container.BuildImageTag ??= "3.1"; + + if (container.ImageName == null && application.Globals.Registry?.Hostname == null) + { + container.ImageName ??= service.Service.Name.ToLowerInvariant(); + } + else if (container.ImageName == null && application.Globals.Registry?.Hostname != null) + { + container.ImageName ??= $"{application.Globals.Registry?.Hostname}/{service.Service.Name.ToLowerInvariant()}"; + } + + container.ImageTag ??= project.Version.Replace("+", "-"); + } + } +} diff --git a/src/opulence/dotnet-opulence/GenerateCommand.cs b/src/opulence/dotnet-opulence/GenerateCommand.cs new file mode 100644 index 00000000..1d18ec3b --- /dev/null +++ b/src/opulence/dotnet-opulence/GenerateCommand.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.CommandLine.Invocation; +using System.IO; +using System.Threading.Tasks; + +namespace Opulence +{ + internal static class GenerateCommand + { + public static Command Create() + { + var command = new Command("generate", "Generate assets") + { + StandardOptions.Project, + StandardOptions.Verbosity, + StandardOptions.Outputs, + StandardOptions.Force, + }; + + command.Handler = CommandHandler.Create, bool>((console, project, verbosity, outputs, force) => + { + var output = new OutputContext(console, verbosity); + return ExecuteAsync(output, project, outputs, force); + }); + + return command; + } + + private static async Task ExecuteAsync(OutputContext output, FileInfo projectFile, List outputs, bool force) + { + output.WriteBanner(); + + var application = await ApplicationFactory.CreateApplicationAsync(output, projectFile); + + var steps = new List(); + + if (outputs.Count == 0 || outputs.Contains("container")) + { + steps.Add(new GenerateDockerfileStep(){ Force = force, }); + } + + if (outputs.Count == 0 || outputs.Contains("chart")) + { + steps.Add(new GenerateHelmChartStep()); + } + + var executor = new ServiceExecutor(output, application, steps); + foreach (var service in application.Services) + { + if (service.IsMatchForProject(application, projectFile)) + { + await executor.ExecuteAsync(service); + } + } + } + } +} \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/GenerateDockerfileStep.cs b/src/opulence/dotnet-opulence/GenerateDockerfileStep.cs new file mode 100644 index 00000000..38df052b --- /dev/null +++ b/src/opulence/dotnet-opulence/GenerateDockerfileStep.cs @@ -0,0 +1,43 @@ +using System.IO; +using System.Threading.Tasks; + +namespace Opulence +{ + internal sealed class GenerateDockerfileStep : ServiceExecutor.Step + { + public override string DisplayText => "Generating Dockerfile..."; + + public bool Force { get; set; } + + public override async Task ExecuteAsync(OutputContext output, Application application, ServiceEntry service) + { + if (SkipWithoutProject(output, service, out var project)) + { + return; + } + + if (SkipWithoutContainerInfo(output, service, out var container)) + { + return; + } + + if (container.UseMultiphaseDockerfile == false) + { + throw new CommandException("Generated Dockerfile workflow does not support single-phase Dockerfiles."); + } + + container.UseMultiphaseDockerfile ??= true; + + var dockerFilePath = Path.Combine(application.GetProjectDirectory(project), "Dockerfile"); + if (File.Exists(dockerFilePath) && !Force) + { + throw new CommandException("'Dockerfile' already exists for project. use '--force' to overwrite."); + } + + File.Delete(dockerFilePath); + + await DockerfileGenerator.WriteDockerfileAsync(output, application, service, project, container, dockerFilePath); + output.WriteInfoLine($"Generated Dockerfile at '{dockerFilePath}'."); + } + } +} \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/GenerateHelmChartStep.cs b/src/opulence/dotnet-opulence/GenerateHelmChartStep.cs new file mode 100644 index 00000000..7cb2f68a --- /dev/null +++ b/src/opulence/dotnet-opulence/GenerateHelmChartStep.cs @@ -0,0 +1,47 @@ +using System.IO; +using System.Threading.Tasks; + +namespace Opulence +{ + internal sealed class GenerateHelmChartStep : ServiceExecutor.Step + { + public override string DisplayText => "Generating Helm Chart..."; + + public bool Force { get; set; } + + public override async Task ExecuteAsync(OutputContext output, Application application, ServiceEntry service) + { + if (SkipWithoutProject(output, service, out var project)) + { + return; + } + + if (SkipWithoutContainerInfo(output, service, out var container)) + { + return; + } + + var chartDirectory = Path.Combine(application.GetProjectDirectory(project), "charts"); + if (Directory.Exists(chartDirectory) && !Force) + { + throw new CommandException("'charts' directory already exists for project. use '--force' to overwrite."); + } + else if (Directory.Exists(chartDirectory)) + { + Directory.Delete(chartDirectory, recursive: true); + } + + var chart = new HelmChartStep(); + await HelmChartGenerator.GenerateAsync( + output, + application, + service, + project, + container, + chart, + new DirectoryInfo(chartDirectory)); + output.WriteInfoLine($"Generated Helm Chart at '{Path.Combine(chartDirectory, chart.ChartName)}'."); + + } + } +} \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/GenerateKubernetesManifestStep.cs b/src/opulence/dotnet-opulence/GenerateKubernetesManifestStep.cs new file mode 100644 index 00000000..f52fdb44 --- /dev/null +++ b/src/opulence/dotnet-opulence/GenerateKubernetesManifestStep.cs @@ -0,0 +1,29 @@ +using System.Threading.Tasks; + +namespace Opulence +{ + internal sealed class GenerateKubernetesManifestStep : ServiceExecutor.Step + { + public override string DisplayText => "Generating Manifests..."; + + public string Environment { get; set; } = "production"; + + + public override Task ExecuteAsync(OutputContext output, Application application, ServiceEntry service) + { + if (SkipWithoutContainerOutput(output, service)) + { + return Task.CompletedTask; + } + + if (SkipForEnvironment(output, service, Environment)) + { + return Task.CompletedTask; + } + + service.Outputs.Add(KubernetesManifestGenerator.CreateDeployment(output, application, service)); + service.Outputs.Add(KubernetesManifestGenerator.CreateService(output, application, service)); + return Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/GenerateOamComponentStep.cs b/src/opulence/dotnet-opulence/GenerateOamComponentStep.cs new file mode 100644 index 00000000..6ab09322 --- /dev/null +++ b/src/opulence/dotnet-opulence/GenerateOamComponentStep.cs @@ -0,0 +1,29 @@ +using System.Threading.Tasks; + +namespace Opulence +{ + internal sealed class GenerateOamComponentStep : ServiceExecutor.Step + { + public override string DisplayText => "Generating Manifests..."; + + public string Environment { get; set; } = "production"; + + + public override Task ExecuteAsync(OutputContext output, Application application, ServiceEntry service) + { + if (SkipWithoutContainerOutput(output, service)) + { + return Task.CompletedTask; + } + + if (SkipForEnvironment(output, service, Environment)) + { + return Task.CompletedTask; + } + + var component = OamComponentGenerator.CreateOamComponent(output, application, service); + service.Outputs.Add(component); + return Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/HelmChartBuilder.cs b/src/opulence/dotnet-opulence/HelmChartBuilder.cs new file mode 100644 index 00000000..d4481c2c --- /dev/null +++ b/src/opulence/dotnet-opulence/HelmChartBuilder.cs @@ -0,0 +1,83 @@ +using System; +using System.CommandLine.Invocation; +using System.IO; +using System.Threading.Tasks; + +namespace Opulence +{ + internal static class HelmChartBuilder + { + public static async Task BuildHelmChartAsync(OutputContext output, Application application, ServiceEntry service, Project project, ContainerInfo container, HelmChartStep chart) + { + if (output is null) + { + throw new ArgumentNullException(nameof(output)); + } + + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + if (service is null) + { + throw new ArgumentNullException(nameof(service)); + } + + if (project is null) + { + throw new ArgumentNullException(nameof(project)); + } + + if (container is null) + { + throw new ArgumentNullException(nameof(container)); + } + + if (chart is null) + { + throw new ArgumentNullException(nameof(chart)); + } + + var projectDirectory = Path.Combine(application.RootDirectory, Path.GetDirectoryName(project.RelativeFilePath)!); + var outputDirectoryPath = Path.Combine(projectDirectory, "bin"); + using var tempDirectory = TempDirectory.Create(); + + HelmChartGenerator.ApplyHelmChartDefaults(application, service, container, chart); + + var chartRoot = Path.Combine(projectDirectory, "charts"); + var chartPath = Path.Combine(chartRoot, chart.ChartName); + + output.WriteDebugLine($"Looking for existing chart in '{chartPath}'."); + if (Directory.Exists(chartPath)) + { + output.WriteDebugLine($"Found existing chart in '{chartPath}'."); + } + else + { + chartRoot = tempDirectory.DirectoryPath; + chartPath = Path.Combine(chartRoot, chart.ChartName); + output.WriteDebugLine($"Generating chart in '{chartPath}'."); + await HelmChartGenerator.GenerateAsync(output, application, service, project, container, chart, new DirectoryInfo(tempDirectory.DirectoryPath)); + } + + output.WriteDebugLine("Running 'helm package'."); + output.WriteCommandLine("helm", $"package -d \"{outputDirectoryPath}\" --version {project.Version.Replace('+', '-')} --app-version {project.Version.Replace('+', '-')}"); + var capture = output.Capture(); + var exitCode = await Process.ExecuteAsync( + "helm", + $"package . -d \"{outputDirectoryPath}\" --version {project.Version.Replace('+', '-')} --app-version {project.Version.Replace('+', '-')}", + workingDir: chartPath, + stdOut: capture.StdOut, + stdErr: capture.StdErr); + + output.WriteDebugLine($"Running 'helm package' exit code: {exitCode}"); + if (exitCode != 0) + { + throw new CommandException("Running 'helm package' failed."); + } + + output.WriteInfoLine($"Created Helm Chart: {Path.Combine(outputDirectoryPath, chart.ChartName + "-" + project.Version.Replace('+', '-') + ".tgz")}"); + } + } +} \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/HelmChartGenerator.cs b/src/opulence/dotnet-opulence/HelmChartGenerator.cs new file mode 100644 index 00000000..e6243107 --- /dev/null +++ b/src/opulence/dotnet-opulence/HelmChartGenerator.cs @@ -0,0 +1,111 @@ +using System; +using System.IO; +using System.Threading.Tasks; + +namespace Opulence +{ + internal static class HelmChartGenerator + { + public static async Task GenerateAsync(OutputContext output, Application application, ServiceEntry service, Project project, ContainerInfo container, HelmChartStep chart, DirectoryInfo outputDirectory) + { + if (output is null) + { + throw new ArgumentNullException(nameof(output)); + } + + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + if (service is null) + { + throw new ArgumentNullException(nameof(service)); + } + + if (project is null) + { + throw new ArgumentNullException(nameof(project)); + } + + if (container is null) + { + throw new ArgumentNullException(nameof(container)); + } + + if (chart is null) + { + throw new ArgumentNullException(nameof(chart)); + } + + if (outputDirectory is null) + { + throw new ArgumentNullException(nameof(outputDirectory)); + } + + ApplyHelmChartDefaults(application, service, container, chart); + + // The directory with the charts needs to be the same as the chart name + var chartDirectoryPath = Path.Combine(outputDirectory.FullName, chart.ChartName); + Directory.CreateDirectory(chartDirectoryPath); + + var templateDirectoryPath = Path.Combine( + Path.GetDirectoryName(typeof(HelmChartGenerator).Assembly.Location)!, + "Templates", + "Helm"); + + DirectoryCopy.Copy(templateDirectoryPath, chartDirectoryPath); + + // Write Chart.yaml + // + // apiVersion: v1 + // name: + // version: + // appVersion: + await File.WriteAllLinesAsync(Path.Combine(chartDirectoryPath, "Chart.yaml"), new[] + { + $"apiVersion: v1", + $"name: {chart.ChartName}", + $"# helm requires the version and appVersion to specified in Chart.yaml", + $"# opulence will override these values when packaging the chart", + $"version: {project.Version.Replace('+', '-')}", + $"appVersion: {project.Version.Replace('+', '-')}" + }); + + // Write values.yaml + // + // image: + // repository: rynowak.azurecr.io/rochambot/gamemaster + await File.WriteAllLinesAsync(Path.Combine(chartDirectoryPath, "values.yaml"), new[] + { + $"image:", + $" repository: {container.ImageName}", + }); + } + + public static void ApplyHelmChartDefaults(Application application, ServiceEntry service, ContainerInfo container, HelmChartStep chart) + { + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + if (service is null) + { + throw new ArgumentNullException(nameof(service)); + } + + if (container is null) + { + throw new ArgumentNullException(nameof(container)); + } + + if (chart is null) + { + throw new ArgumentNullException(nameof(chart)); + } + + chart.ChartName ??= service.Service.Name.ToLowerInvariant(); + } + } +} \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/HelmPush.cs b/src/opulence/dotnet-opulence/HelmPush.cs new file mode 100644 index 00000000..caed5a61 --- /dev/null +++ b/src/opulence/dotnet-opulence/HelmPush.cs @@ -0,0 +1,52 @@ +using System; +using System.CommandLine.Invocation; +using System.Threading.Tasks; + +namespace Opulence +{ + internal static class HelmPush + { + public static async Task ExecuteAsync(OutputContext output, string registryHostname, string chartFilePath) + { + if (output is null) + { + throw new ArgumentNullException(nameof(output)); + } + + if (registryHostname is null) + { + throw new ArgumentNullException(nameof(registryHostname)); + } + + if (chartFilePath is null) + { + throw new ArgumentNullException(nameof(chartFilePath)); + } + + // NOTE: this is currently hardcoded to use ACR and uses the azure CLI to do + // the push operation. Helm 3 has support for pushing to OCI repositories, + // but it's not documented that it works yet for azure. + if (!registryHostname.EndsWith(".azurecr.io")) + { + throw new CommandException("Currently pushing of helm charts is limited to '*.azurecr.io'."); + } + + var registryName = registryHostname.Substring(0, registryHostname.Length - ".azurecr.io".Length); + + output.WriteDebugLine("Running 'az acr helm push'."); + output.WriteCommandLine("az", $"acr helm push --name {registryName} \"{chartFilePath}\""); + var capture = output.Capture(); + var exitCode = await Process.ExecuteAsync( + $"az", + $"acr helm push --name {registryName} \"{chartFilePath}\"", + stdOut: capture.StdOut, + stdErr: capture.StdErr); + + output.WriteDebugLine($"Done running 'az acr helm push' exit code: {exitCode}"); + if (exitCode != 0) + { + throw new CommandException("'az acr helm push' failed."); + } + } + } +} \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/IYamlManifestOutput.cs b/src/opulence/dotnet-opulence/IYamlManifestOutput.cs new file mode 100644 index 00000000..cb08a4e0 --- /dev/null +++ b/src/opulence/dotnet-opulence/IYamlManifestOutput.cs @@ -0,0 +1,9 @@ +using YamlDotNet.RepresentationModel; + +namespace Opulence +{ + internal interface IYamlManifestOutput + { + YamlDocument Yaml { get; } + } +} \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/InitCommand.cs b/src/opulence/dotnet-opulence/InitCommand.cs new file mode 100644 index 00000000..e5485964 --- /dev/null +++ b/src/opulence/dotnet-opulence/InitCommand.cs @@ -0,0 +1,193 @@ +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.CommandLine.Builder; +using System.CommandLine.Invocation; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Build.Construction; + +namespace Opulence +{ + internal static class InitCommand + { + public static Command Create() + { + var command = new Command("init", "Initialize repo") + { + StandardOptions.Verbosity, + new Option(new[] { "-d", "--directory", }, "Directory to Initialize") + { + Argument = new Argument(() => + { + return new DirectoryInfo(Environment.CurrentDirectory); + }).ExistingOnly(), + }, + }; + + command.Handler = CommandHandler.Create((console, verbosity, directory) => + { + var output = new OutputContext(console, verbosity); + return ExecuteAsync(output, directory); + }); + + return command; + } + + private static async Task ExecuteAsync(OutputContext output, DirectoryInfo directory) + { + output.WriteBanner(); + + string? solutionFilePath = null; + string? opulenceFilePath = null; + + using (var step = output.BeginStep("Looking For Existing Config...")) + { + opulenceFilePath = DirectorySearch.AscendingSearch(directory.FullName, "Opulence.csx"); + if (opulenceFilePath != null) + { + output.WriteInfoLine($"Found 'Opulence.csx' at '{opulenceFilePath}'"); + step.MarkComplete(); + return; + } + else + { + output.WriteInfoLine("Not Found"); + step.MarkComplete(); + } + } + + using (var step = output.BeginStep("Looking For .sln File...")) + { + solutionFilePath = DirectorySearch.AscendingWildcardSearch(directory.FullName, "*.sln").FirstOrDefault()?.FullName; + if (opulenceFilePath == null && + solutionFilePath != null && + output.Confirm($"Use '{Path.GetDirectoryName(solutionFilePath)}' as Root?")) + { + opulenceFilePath = Path.Combine(Path.GetDirectoryName(solutionFilePath)!, "Opulence.csx"); + step.MarkComplete(); + } + else + { + output.WriteInfoLine("Not Found."); + step.MarkComplete(); + } + } + + if (opulenceFilePath == null && + Path.GetFullPath(directory.FullName) != Path.GetFullPath(Environment.CurrentDirectory)) + { + // User specified a directory other than the current one + using (var step = output.BeginStep("Trying Project Directory...")) + { + if (output.Confirm("Use Project Directory as Root?")) + { + opulenceFilePath = Path.Combine(directory.FullName, "Opulence.csx"); + } + + step.MarkComplete(); + } + } + + if (opulenceFilePath == null) + { + using (var step = output.BeginStep("Trying Current Directory...")) + { + if (output.Confirm("Use Current Directory as Root?")) + { + opulenceFilePath = Path.Combine(directory.FullName, "Opulence.csx"); + } + + step.MarkComplete(); + } + } + + if (opulenceFilePath == null) + { + throw new CommandException("Cannot Determine Root Directory."); + } + + using (var step = output.BeginStep("Writing Opulence.csx ...")) + { + var hostname = output.Prompt("Enter the Container Registry (ex: 'example.azurecr.io' for Azure or 'example' for dockerhub)"); + + var services = new List<(string, string)>(); + if (solutionFilePath != null && output.Confirm($"Use solution file '{solutionFilePath}' to initialize services?")) + { + services.AddRange(ReadServicesFromSolution(solutionFilePath)); + services.Sort((a, b) => a.Item1.CompareTo(b.Item1)); + } + + using var stream = File.OpenWrite(opulenceFilePath); + using var writer = new StreamWriter(stream, Encoding.UTF8, leaveOpen: true); + + await WriteOpulenceConfigAsync(writer, hostname, services); + + output.WriteInfoLine($"Initialized Opulence Config at '{opulenceFilePath}'."); + step.MarkComplete(); + } + } + + private static IEnumerable<(string, string)> ReadServicesFromSolution(string solutionFilePath) + { + SolutionFile solution; + try + { + solution = SolutionFile.Parse(solutionFilePath); + } + catch (Exception ex) + { + throw new CommandException($"Parsing solution file '{solutionFilePath}' failed.", ex); + } + + for (var i = 0; i < solution.ProjectsInOrder.Count; i++) + { + var project = solution.ProjectsInOrder[i]; + if (string.Equals(Path.GetExtension(project.RelativePath), ".csproj", StringComparison.Ordinal)) + { + var fileName = Path.GetFileNameWithoutExtension(project.RelativePath.Replace('\\', Path.DirectorySeparatorChar)); + + yield return (Names.NormalizeToFriendly(fileName), Names.NormalizeToDns(fileName)); + } + } + } + + private static async Task WriteOpulenceConfigAsync(TextWriter writer, string hostname, List<(string, string)> services) + { + await writer.WriteLineAsync("#r \"Opulence\""); + await writer.WriteLineAsync(); + + await writer.WriteLineAsync($"using Opulence;"); + await writer.WriteLineAsync(); + + await writer.WriteLineAsync($"public class Application"); + await writer.WriteLineAsync($"{{"); + await writer.WriteLineAsync($" public ApplicationGlobals Globals {{ get; }} = new ApplicationGlobals()"); + await writer.WriteLineAsync($" {{"); + await writer.WriteLineAsync($" Registry = new ContainerRegistry(\"{hostname}\"),"); + await writer.WriteLineAsync($" }};"); + await writer.WriteLineAsync(); + + await writer.WriteLineAsync($" // Define more services and dependencies here as your application grows."); + for (var i = 0; i < services.Count; i++) + { + await writer.WriteLineAsync($" public Service {services[i].Item1} {{ get; }} = new Service(\"{services[i].Item2}\");"); + + if (i + 1 < services.Count) + { + await writer.WriteLineAsync(); + } + } + + await writer.WriteLineAsync($"}}"); + await writer.WriteLineAsync(); + + await writer.WriteLineAsync($"Pipeline.Configure(app =>"); + await writer.WriteLineAsync($"{{"); + await writer.WriteLineAsync($" // Configure your service bindings here with code."); + await writer.WriteLineAsync($"}});"); + } + } +} \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/KubernetesDeploymentOutput.cs b/src/opulence/dotnet-opulence/KubernetesDeploymentOutput.cs new file mode 100644 index 00000000..079ead27 --- /dev/null +++ b/src/opulence/dotnet-opulence/KubernetesDeploymentOutput.cs @@ -0,0 +1,27 @@ +using System; +using YamlDotNet.RepresentationModel; + +namespace Opulence +{ + internal sealed class KubernetesDeploymentOutput : ServiceOutput, IYamlManifestOutput + { + public KubernetesDeploymentOutput(string name, YamlDocument yaml) + { + if (name is null) + { + throw new ArgumentNullException(nameof(name)); + } + + if (yaml is null) + { + throw new ArgumentNullException(nameof(yaml)); + } + + Name = name; + Yaml = yaml; + } + + public string Name { get; } + public YamlDocument Yaml { get; } + } +} \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/KubernetesManifestGenerator.cs b/src/opulence/dotnet-opulence/KubernetesManifestGenerator.cs new file mode 100644 index 00000000..2bb1c06b --- /dev/null +++ b/src/opulence/dotnet-opulence/KubernetesManifestGenerator.cs @@ -0,0 +1,196 @@ +using System; +using System.Linq; +using YamlDotNet.RepresentationModel; + +namespace Opulence +{ + internal static class KubernetesManifestGenerator + { + public static ServiceOutput CreateService(OutputContext output, Application application, ServiceEntry service) + { + if (output is null) + { + throw new ArgumentNullException(nameof(output)); + } + + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + if (service is null) + { + throw new ArgumentNullException(nameof(service)); + } + + var root = new YamlMappingNode(); + + root.Add("kind", "Service"); + root.Add("apiVersion", "v1"); + + var metadata = new YamlMappingNode(); + root.Add("metadata", metadata); + metadata.Add("name", service.Service.Name); + + var labels = new YamlMappingNode(); + metadata.Add("labels", labels); + labels.Add("app.kubernetes.io/name", service.Service.Name); + labels.Add("app.kubernetes.io/part-of", application.Globals.Name); + + var spec = new YamlMappingNode(); + root.Add("spec", spec); + + var selector = new YamlMappingNode(); + spec.Add("selector", selector); + selector.Add("app.kubernetes.io/name", service.Service.Name); + + spec.Add("type", "ClusterIP"); + + var ports = new YamlSequenceNode(); + spec.Add("ports", ports); + + var port = new YamlMappingNode(); + ports.Add(port); + + port.Add("name", "web"); + port.Add("protocol", "TCP"); + port.Add("port", "80"); + port.Add("targetPort", "80"); + + return new KubernetesServiceOutput(service.Service.Name, new YamlDocument(root)); + } + + public static ServiceOutput CreateDeployment(OutputContext output, Application application, ServiceEntry service) + { + if (output is null) + { + throw new ArgumentNullException(nameof(output)); + } + + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + if (service is null) + { + throw new ArgumentNullException(nameof(service)); + } + + var root = new YamlMappingNode(); + + root.Add("kind", "Deployment"); + root.Add("apiVersion", "apps/v1"); + + var metadata = new YamlMappingNode(); + root.Add("metadata", metadata); + metadata.Add("name", service.Service.Name); + + var labels = new YamlMappingNode(); + metadata.Add("labels", labels); + labels.Add("app.kubernetes.io/name", service.Service.Name); + labels.Add("app.kubernetes.io/part-of", application.Globals.Name); + + var spec = new YamlMappingNode(); + root.Add("spec", spec); + + var selector = new YamlMappingNode(); + spec.Add("selector", selector); + + var matchLabels = new YamlMappingNode(); + selector.Add("matchLabels", matchLabels); + matchLabels.Add("app.kubernetes.io/name", service.Service.Name); + + var template = new YamlMappingNode(); + spec.Add("template", template); + + metadata = new YamlMappingNode(); + template.Add("metadata", metadata); + + labels = new YamlMappingNode(); + metadata.Add("labels", labels); + labels.Add("app.kubernetes.io/name", service.Service.Name); + labels.Add("app.kubernetes.io/part-of", application.Globals.Name); + + spec = new YamlMappingNode(); + template.Add("spec", spec); + + var containers = new YamlSequenceNode(); + spec.Add("containers", containers); + + var images = service.Outputs.OfType(); + foreach (var image in images) + { + var container = new YamlMappingNode(); + containers.Add(container); + container.Add("name", service.Service.Name); // NOTE: to really support multiple images we'd need to generate unique names. + container.Add("image", $"{image.ImageName}:{image.ImageTag}"); + + if (service.Service.Environment.Count > 0) + { + var env = new YamlSequenceNode(); + container.Add("env", env); + + foreach (var kvp in service.Service.Environment) + { + env.Add(new YamlMappingNode() + { + { "name", kvp.Key }, + { "value", kvp.Value.ToString() }, + }); + } + } + + if (service.Service.Bindings.Any(b => b.ConnectionString != null)) + { + var volumeMounts = new YamlSequenceNode(); + container.Add("volumeMounts", volumeMounts); + + foreach (var binding in service.Service.Bindings.Where(b => b.ConnectionString != null)) + { + var volumeMount = new YamlMappingNode(); + volumeMounts.Add(volumeMount); + + volumeMount.Add("name", $"{binding.Name}-secret"); + volumeMount.Add("mountPath", $"/var/bindings/{binding.Name}"); + volumeMount.Add("readOnly", "true"); + } + } + + var ports = new YamlSequenceNode(); + container.Add("ports", ports); + + var containerPort = new YamlMappingNode(); + ports.Add(containerPort); + containerPort.Add("containerPort", "80"); + } + + if (service.Service.Bindings.Any(b => b.ConnectionString != null)) + { + var volumes = new YamlSequenceNode(); + spec.Add("volumes", volumes); + + foreach (var binding in service.Service.Bindings.Where(b => b.ConnectionString != null)) + { + var volume = new YamlMappingNode(); + volumes.Add(volume); + volume.Add("name", $"{binding.Name}-secret"); + + var secret = new YamlMappingNode(); + volume.Add("secret", secret); + secret.Add("secretName", binding.ConnectionString!.Name!); + + var items = new YamlSequenceNode(); + secret.Add("items", items); + + var item = new YamlMappingNode(); + items.Add(item); + item.Add("key", "uri"); + item.Add("path", $"SERVICES__{binding.Name}"); + } + } + + return new KubernetesDeploymentOutput(service.Service.Name, new YamlDocument(root)); + } + } +} \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/KubernetesServiceOutput.cs b/src/opulence/dotnet-opulence/KubernetesServiceOutput.cs new file mode 100644 index 00000000..af4e4660 --- /dev/null +++ b/src/opulence/dotnet-opulence/KubernetesServiceOutput.cs @@ -0,0 +1,27 @@ +using System; +using YamlDotNet.RepresentationModel; + +namespace Opulence +{ + internal sealed class KubernetesServiceOutput : ServiceOutput, IYamlManifestOutput + { + public KubernetesServiceOutput(string name, YamlDocument yaml) + { + if (name is null) + { + throw new ArgumentNullException(nameof(name)); + } + + if (yaml is null) + { + throw new ArgumentNullException(nameof(yaml)); + } + + Name = name; + Yaml = yaml; + } + + public string Name { get; } + public YamlDocument Yaml { get; } + } +} \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/Names.cs b/src/opulence/dotnet-opulence/Names.cs new file mode 100644 index 00000000..7f4f79a6 --- /dev/null +++ b/src/opulence/dotnet-opulence/Names.cs @@ -0,0 +1,32 @@ +using System; + +namespace Opulence +{ + internal static class Names + { + public static string NormalizeToDns(string name) + { + if (name is null) + { + throw new ArgumentNullException(nameof(name)); + } + + name = name.ToLowerInvariant(); + name = name.Replace('.', '-'); + name = name.Replace('_', '-'); + return name; + } + + public static string NormalizeToFriendly(string name) + { + if (name is null) + { + throw new ArgumentNullException(nameof(name)); + } + + name = name.Replace('.', '_'); + name = name.Replace('-', '_'); + return name; + } + } +} \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/OamApplicationGenerator.cs b/src/opulence/dotnet-opulence/OamApplicationGenerator.cs new file mode 100644 index 00000000..842f898b --- /dev/null +++ b/src/opulence/dotnet-opulence/OamApplicationGenerator.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using YamlDotNet.RepresentationModel; + +namespace Opulence +{ + internal static class OamApplicationGenerator + { + public static Task WriteOamApplicationAsync(TextWriter writer, OutputContext output, Application application, string applicationName, string environment) + { + if (writer is null) + { + throw new ArgumentNullException(nameof(writer)); + } + + if (output is null) + { + throw new ArgumentNullException(nameof(output)); + } + + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + if (applicationName is null) + { + throw new ArgumentNullException(nameof(applicationName)); + } + + if (environment is null) + { + throw new ArgumentNullException(nameof(environment)); + } + + var componentManifests = new List(); + var documents = new List(); + + foreach (var service in application.Services) + { + if (service.AppliesToEnvironment(environment)) + { + componentManifests.AddRange(service.Outputs.OfType()); + } + } + + var root = new YamlMappingNode(); + + root.Add("kind", "ApplicationConfiguration"); + root.Add("apiVersion", "core.oam.dev/v1alpha1"); + + var metadata = new YamlMappingNode(); + root.Add("metadata", metadata); + metadata.Add("name", applicationName.ToLowerInvariant()); + + var spec = new YamlMappingNode(); + root.Add("spec", spec); + + var components = new YamlSequenceNode(); + spec.Add("components", components); + + foreach (var manifest in componentManifests) + { + documents.Add(manifest.Yaml); + + var component = new YamlMappingNode(); + components.Add(component); + + component.Add("componentName", manifest.Name); + component.Add("instanceName", $"{environment.ToLowerInvariant()}-{manifest.Name}"); + } + + documents.Add(new YamlDocument(root)); + + var stream = new YamlStream(documents.ToArray()); + stream.Save(writer, assignAnchors: false); + + return Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/OamComponentGenerator.cs b/src/opulence/dotnet-opulence/OamComponentGenerator.cs new file mode 100644 index 00000000..07b1169f --- /dev/null +++ b/src/opulence/dotnet-opulence/OamComponentGenerator.cs @@ -0,0 +1,54 @@ +using System; +using System.Linq; +using YamlDotNet.RepresentationModel; + +namespace Opulence +{ + internal static class OamComponentGenerator + { + public static OamComponentOutput CreateOamComponent(OutputContext output, Application application, ServiceEntry service) + { + if (output is null) + { + throw new ArgumentNullException(nameof(output)); + } + + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + if (service is null) + { + throw new ArgumentNullException(nameof(service)); + } + + var root = new YamlMappingNode(); + + root.Add("kind", "ComponentSchematic"); + root.Add("apiVersion", "core.oam.dev/v1alpha1"); + + var metadata = new YamlMappingNode(); + root.Add("metadata", metadata); + metadata.Add("name", service.Service.Name); + + var spec = new YamlMappingNode(); + root.Add("spec", spec); + spec.Add("workloadType", "core.oam.dev/v1alpha1.Server"); + + var containers = new YamlSequenceNode(); + spec.Add("containers", containers); + + var images = service.Outputs.OfType(); + foreach (var image in images) + { + var container = new YamlMappingNode(); + containers.Add(container); + container.Add("name", service.Service.Name); // NOTE: to really support multiple images we'd need to generate unique names. + container.Add("image", $"{image.ImageName}:{image.ImageTag}"); + } + + return new OamComponentOutput(service.Service.Name, new YamlDocument(root)); + } + } +} \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/OamComponentOutput.cs b/src/opulence/dotnet-opulence/OamComponentOutput.cs new file mode 100644 index 00000000..71382e45 --- /dev/null +++ b/src/opulence/dotnet-opulence/OamComponentOutput.cs @@ -0,0 +1,27 @@ +using System; +using YamlDotNet.RepresentationModel; + +namespace Opulence +{ + internal sealed class OamComponentOutput : ServiceOutput, IYamlManifestOutput + { + public OamComponentOutput(string name, YamlDocument yaml) + { + if (name is null) + { + throw new ArgumentNullException(nameof(name)); + } + + if (yaml is null) + { + throw new ArgumentNullException(nameof(yaml)); + } + + Name = name; + Yaml = yaml; + } + + public string Name { get; } + public YamlDocument Yaml { get; } + } +} \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/OutputContext.cs b/src/opulence/dotnet-opulence/OutputContext.cs new file mode 100644 index 00000000..165d34ff --- /dev/null +++ b/src/opulence/dotnet-opulence/OutputContext.cs @@ -0,0 +1,256 @@ +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.CommandLine.IO; + +namespace Opulence +{ + internal sealed class OutputContext + { + private const int IndentAmount = 4; + + private readonly Stack steps; + private int indent; + + public OutputContext(IConsole console, Verbosity verbosity) + { + if (console is null) + { + throw new ArgumentNullException(nameof(console)); + } + + Console = console; + Verbosity = verbosity; + + steps = new Stack(); + } + + private IConsole Console { get; } + + public Verbosity Verbosity { get; } + + public StepTracker BeginStep(string title) + { + if (title is null) + { + throw new ArgumentNullException(nameof(title)); + } + + WriteInfoLine("💰 " + title); + + indent += IndentAmount; + var currentStep = new StepTracker(this, title); + steps.Push(currentStep); + + return currentStep; + } + + private void EndStep(StepTracker step) + { + if (!object.ReferenceEquals(step, steps.Peek())) + { + throw new InvalidOperationException($"Attempting to end a step that isn't running. Currently executing step: {steps.Peek()?.Title}"); + } + + indent -= IndentAmount; + steps.Pop(); + + if (step.Message != null) + { + WriteDebugLine(step.Message); + } + } + + private void Write(Verbosity verbosity, string message) + { + if (Verbosity >= verbosity) + { + if (indent > 0) + { + Console.Out.Write(new string(' ', indent)); + } + + Console.Out.Write(message); + } + } + + private void WriteLine(Verbosity verbosity, string message) + { + if (Verbosity >= verbosity) + { + if (indent > 0) + { + Console.Out.Write(new string(' ', indent)); + } + + Console.Out.WriteLine(message); + } + } + + public void WriteAlways(string message) + { + Write(Verbosity.Info, message); + } + + public void WriteAlwaysLine(string message) + { + WriteLine(Verbosity.Info, message); + } + + public void WriteInfoLine(string message) + { + WriteLine(Verbosity.Info, message); + } + + public void WriteDebugLine(string message) + { + WriteLine(Verbosity.Debug, message); + } + + public void WriteCommandLine(string process, string args) + { + WriteDebugLine($"> {process} {args}"); + } + + public bool Confirm(string prompt) + { + while (true) + { + WriteAlways(prompt + " (y/n): "); + + var key = System.Console.ReadKey(); + WriteAlwaysLine(string.Empty); + if (key.KeyChar == 'y' || key.KeyChar == 'Y') + { + return true; + } + else if (key.KeyChar == 'n' || key.KeyChar == 'N') + { + return false; + } + else + { + WriteAlwaysLine("Invalid Input."); + } + } + } + + public string Prompt(string prompt, bool allowEmpty = false) + { + while (true) + { + WriteAlways(prompt + ": "); + var line = System.Console.ReadLine(); + + if (allowEmpty || !string.IsNullOrEmpty(line)) + { + return line.Trim(); + } + } + } + +// + + + public void WriteBanner() + { + // From: http://patorjk.com/software/taag/#p=display&f=Big%20Money-nw&t=Opulence + WriteInfoLine(string.Empty); + WriteInfoLine(@" $$$$$$\ $$\ "); + WriteInfoLine(@"$$ __$$\ $$ | "); + WriteInfoLine(@"$$ / $$ | $$$$$$\ $$\ $$\ $$ | $$$$$$\ $$$$$$$\ $$$$$$$\ $$$$$$\ "); + WriteInfoLine(@"$$ | $$ |$$ __$$\ $$ | $$ |$$ |$$ __$$\ $$ __$$\ $$ _____|$$ __$$\ "); + WriteInfoLine(@"$$ | $$ |$$ / $$ |$$ | $$ |$$ |$$$$$$$$ |$$ | $$ |$$ / $$$$$$$$ |"); + WriteInfoLine(@"$$ | $$ |$$ | $$ |$$ | $$ |$$ |$$ ____|$$ | $$ |$$ | $$ ____|"); + WriteInfoLine(@" $$$$$$ |$$$$$$$ |\$$$$$$ |$$ |\$$$$$$$\ $$ | $$ |\$$$$$$$\ \$$$$$$$\ "); + WriteInfoLine(@" \______/ $$ ____/ \______/ \__| \_______|\__| \__| \_______| \_______|"); + WriteInfoLine(@" $$ | "); + WriteInfoLine(@" $$ | "); + WriteInfoLine(@" \__| "); + WriteInfoLine(@"--------------------------------------------------------------------------"); + WriteInfoLine(string.Empty); + WriteInfoLine("White-Glove service for .NET and Kubernetes..."); + WriteInfoLine("Someone will be right with you!"); + WriteInfoLine(string.Empty); + } + + public CapturedCommandOutput Capture() + { + return new CapturedCommandOutput(this); + } + + public sealed class CapturedCommandOutput + { + private readonly OutputContext output; + public CapturedCommandOutput(OutputContext output) + { + this.output = output; + } + + public void StdOut(string line) + { + if (output.Verbosity >= Verbosity.Debug) + { + output.Console.SetTerminalForegroundColor(ConsoleColor.Gray); + output.Console.Out.WriteLine(new string(' ', output.indent + IndentAmount) + line); + output.Console.ResetTerminalForegroundColor(); + } + } + + public void StdErr(string line) + { + if (output.Verbosity >= Verbosity.Info) + { + output.Console.SetTerminalForegroundColor(ConsoleColor.Red); + output.Console.Out.WriteLine(new string(' ', output.indent + IndentAmount)+ line); + output.Console.ResetTerminalForegroundColor(); + } + } + } + + public class StepTracker : IDisposable + { + private readonly OutputContext output; + private readonly string title; + private string? message; + private bool disposed; + + public StepTracker(OutputContext output, string title) + { + if (output is null) + { + throw new ArgumentNullException(nameof(output)); + } + + if (title is null) + { + throw new ArgumentNullException(nameof(title)); + } + + this.output = output; + this.title = title; + } + + public bool Completed => message != null; + + public string? Message => message; + public string Title => title; + + public void MarkComplete(string? message = null) + { + this.message = message ?? ("Done " + title); + } + + public void Dispose() + { + if (disposed) + { + return; + } + + output.EndStep(this); + disposed = true; + } + } + } +} \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/PackageCommand.cs b/src/opulence/dotnet-opulence/PackageCommand.cs new file mode 100644 index 00000000..39036b5b --- /dev/null +++ b/src/opulence/dotnet-opulence/PackageCommand.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.CommandLine.Invocation; +using System.IO; +using System.Text; +using System.Threading.Tasks; + +namespace Opulence +{ + public class PackageCommand + { + public static Command Create() + { + var command = new Command("package", "Package the project") + { + StandardOptions.Project, + StandardOptions.Verbosity, + StandardOptions.Force, + StandardOptions.Environment, + new Option(new []{ "-o", "--output" }, "Output directory") + { + Argument = new Argument("output", () => new DirectoryInfo(Environment.CurrentDirectory)) + { + Arity = ArgumentArity.ExactlyOne, + }, + Required = false, + }, + }; + command.Handler = CommandHandler.Create((console, project, output, environment, force, verbosity) => + { + return ExecuteAsync(new OutputContext(console, verbosity), project, output, environment, force); + }); + + return command; + } + + private static async Task ExecuteAsync(OutputContext output, FileInfo projectFile, DirectoryInfo outputDirectory, string environment, bool force) + { + output.WriteBanner(); + + var application = await ApplicationFactory.CreateApplicationAsync(output, projectFile); + + var steps = new List + { + new CombineStep() { Environment = environment, }, + new BuildDockerImageStep() { Environment = environment, }, + }; + + if (application.Globals.DeploymentKind == DeploymentKind.None) + { + // No extra steps + } + else if (application.Globals.DeploymentKind == DeploymentKind.Kubernetes) + { + steps.Add(new GenerateKubernetesManifestStep() { Environment = environment, }); + } + else if (application.Globals.DeploymentKind == DeploymentKind.Oam) + { + steps.Add(new GenerateOamComponentStep() { Environment = environment, }); + } + else + { + throw new InvalidOperationException($"Unknown DeploymentKind: " + application.Globals.DeploymentKind); + } + + // If this is command is for a project, then write out the component manifest + // for just the project. We won't run the "application package" part. + if (!string.Equals(".sln", projectFile.Extension, StringComparison.Ordinal)) + { + steps.Add(new WriteServiceYamlStep(){ OutputDirectory = outputDirectory, Force = force, }); + } + + var executor = new ServiceExecutor(output, application, steps); + foreach (var service in application.Services) + { + if (service.IsMatchForProject(application, projectFile)) + { + await executor.ExecuteAsync(service); + } + } + + if (string.Equals(".sln", projectFile.Extension, StringComparison.Ordinal)) + { + await PackageApplicationAsync(output, application, outputDirectory, Path.GetFileNameWithoutExtension(projectFile.Name), environment); + } + } + + private static async Task PackageApplicationAsync(OutputContext output, Application application, DirectoryInfo outputDirectory, string applicationName, string environment) + { + using var step = output.BeginStep("Writing Application Manifests..."); + var outputFile = Path.Combine(outputDirectory.FullName, $"{applicationName}-{environment}.yaml"); + output.WriteInfoLine($"Writing output to '{outputFile}'."); + + File.Delete(outputFile); + using var stream = File.OpenWrite(outputFile); + using var writer = new StreamWriter(stream, Encoding.UTF8, leaveOpen: true); + + if (application.Globals.DeploymentKind == DeploymentKind.None) + { + // No extra steps + } + else if (application.Globals.DeploymentKind == DeploymentKind.Kubernetes) + { + await ApplicationYamlWriter.WriteAsync(output, writer, application); + } + else if (application.Globals.DeploymentKind == DeploymentKind.Oam) + { + await OamApplicationGenerator.WriteOamApplicationAsync(writer, output, application, applicationName, environment); + } + else + { + throw new InvalidOperationException($"Unknown DeploymentKind: " + application.Globals.DeploymentKind); + } + + step.MarkComplete(); + } + } +} diff --git a/src/opulence/dotnet-opulence/Program.cs b/src/opulence/dotnet-opulence/Program.cs new file mode 100644 index 00000000..f3b2a877 --- /dev/null +++ b/src/opulence/dotnet-opulence/Program.cs @@ -0,0 +1,82 @@ +using System; +using System.CommandLine; +using System.CommandLine.Builder; +using System.CommandLine.Help; +using System.CommandLine.Invocation; +using System.CommandLine.IO; +using System.CommandLine.Parsing; +using System.CommandLine.Rendering; +using System.Threading.Tasks; + +namespace Opulence +{ + class Program + { + static async Task Main(string[] args) + { + var command = new RootCommand(); + command.AddCommand(InitCommand.Create()); + command.AddCommand(GenerateCommand.Create()); + command.AddCommand(PackageCommand.Create()); + command.AddCommand(PushCommand.Create()); + command.AddCommand(DeployCommand.Create()); + + command.Description = "White-Glove Service for .NET and Kubernetes"; + command.Handler = CommandHandler.Create(help => + { + help.Write(command); + return 1; + }); + + var builder = new CommandLineBuilder(command); + + // Parsing behavior + builder.UseHelp(); + builder.UseVersionOption(); + builder.UseDebugDirective(); + builder.UseParseErrorReporting(); + builder.ParseResponseFileAs(ResponseFileHandling.ParseArgsAsSpaceSeparated); + builder.UsePrefixes(new []{ "-", "--", }); // disable garbage windows conventions + + builder.CancelOnProcessTermination(); + builder.UseExceptionHandler(HandleException); + + // Allow fancy drawing. + builder.UseAnsiTerminalWhenAvailable(); + + var parser = builder.Build(); + return await parser.InvokeAsync(args); + } + + private static void HandleException(Exception exception, InvocationContext context) + { + context.Console.ResetTerminalForegroundColor(); + context.Console.SetTerminalForegroundColor(ConsoleColor.Red); + + if (exception is OperationCanceledException) + { + context.Console.Error.WriteLine("Oh dear! Operation canceled."); + } + else if (exception is CommandException command) + { + context.Console.Error.WriteLine($"Drats! '{context.ParseResult.CommandResult.Command.Name}' failed:"); + context.Console.Error.WriteLine($"\t{command.Message}"); + + if (command.InnerException != null) + { + context.Console.Error.WriteLine(); + context.Console.Error.WriteLine(command.InnerException.ToString()); + } + } + else + { + context.Console.Error.WriteLine("An unhandled exception has occurred, how unseemly: "); + context.Console.Error.WriteLine(exception.ToString()); + } + + context.Console.ResetTerminalForegroundColor(); + + context.ResultCode = 1; + } + } +} diff --git a/src/opulence/dotnet-opulence/ProjectReader.cs b/src/opulence/dotnet-opulence/ProjectReader.cs new file mode 100644 index 00000000..35104484 --- /dev/null +++ b/src/opulence/dotnet-opulence/ProjectReader.cs @@ -0,0 +1,118 @@ +using System; +using System.CommandLine.Invocation; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Semver; + +namespace Opulence +{ + internal static class ProjectReader + { + public static async Task ReadProjectDetailsAsync(OutputContext output, FileInfo projectFile, Project project) + { + if (output is null) + { + throw new ArgumentNullException(nameof(output)); + } + + if (projectFile is null) + { + throw new ArgumentNullException(nameof(projectFile)); + } + + if (project is null) + { + throw new ArgumentNullException(nameof(project)); + } + + using (var step = output.BeginStep("Reading Project Details...")) + { + await EvaluateMSBuildAsync(output, projectFile, project); + + if (!SemVersion.TryParse(project.Version, out var version)) + { + output.WriteInfoLine($"No version or invalid version 'application.Version' found, using default."); + version = new SemVersion(0, 1, 0); + project.Version = version.ToString(); + } + + step.MarkComplete(); + } + } + + private static async Task EvaluateMSBuildAsync(OutputContext output, FileInfo projectFile, Project project) + { + try + { + output.WriteDebugLine("Installing msbuild targets."); + TargetInstaller.Install(projectFile.FullName); + output.WriteDebugLine("Installed msbuild targets."); + } + catch (Exception ex) + { + throw new CommandException("Failed to install targets.", ex); + } + + var outputFilePath = Path.GetTempFileName(); + + try + { + var capture = output.Capture(); + var opulenceRoot = Path.GetDirectoryName(typeof(Program).Assembly.Location); + + var restore = string.Empty; + if (!File.Exists(Path.Combine(projectFile.DirectoryName, "obj", "project.assets.json"))) + { + restore = "/restore"; + } + + output.WriteDebugLine("Running 'dotnet msbuild'."); + output.WriteCommandLine("dotnet", $"msbuild {restore} /t:EvaluateOpulenceProjectInfo \"/p:OpulenceTargetLocation={opulenceRoot}\" \"/p:OpulenceOutputFilePath={outputFilePath}\""); + var exitCode = await Process.ExecuteAsync( + $"dotnet", + $"msbuild {restore} /t:EvaluateOpulenceProjectInfo \"/p:OpulenceTargetLocation={opulenceRoot}\" \"/p:OpulenceOutputFilePath={outputFilePath}\"", + workingDir: projectFile.DirectoryName, + stdOut: capture.StdOut, + stdErr: capture.StdErr); + + output.WriteDebugLine($"Done running 'dotnet msbuild' exit code: {exitCode}"); + if (exitCode != 0) + { + throw new CommandException("'dotnet msbuild' failed."); + } + + var lines = await File.ReadAllLinesAsync(outputFilePath); + for (var i = 0; i < lines.Length; i++) + { + var line = lines[i]; + if (line.StartsWith("version=")) + { + project.Version = line.Substring("version=".Length).Trim(); + output.WriteDebugLine($"Found application version: {line}"); + continue; + } + + if (line.StartsWith("tfm")) + { + project.TargetFramework = line.Substring("tfm=".Length).Trim(); + output.WriteDebugLine($"Found target framework: {line}"); + continue; + } + + if (line.StartsWith("frameworks=")) + { + var right = line.Substring("frameworks=".Length).Trim(); + project.Frameworks.AddRange(right.Split(",").Select(s => new Framework(s))); + output.WriteDebugLine($"Found shared frameworks: {line}"); + continue; + } + } + } + finally + { + File.Delete(outputFilePath); + } + } + } +} \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/Properties/launchSettings.json b/src/opulence/dotnet-opulence/Properties/launchSettings.json new file mode 100644 index 00000000..9cdc2632 --- /dev/null +++ b/src/opulence/dotnet-opulence/Properties/launchSettings.json @@ -0,0 +1,8 @@ +{ + "profiles": { + "dotnet-opulence": { + "commandName": "Project", + "commandLineArgs": "" + } + } +} \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/PushCommand.cs b/src/opulence/dotnet-opulence/PushCommand.cs new file mode 100644 index 00000000..91f3ca53 --- /dev/null +++ b/src/opulence/dotnet-opulence/PushCommand.cs @@ -0,0 +1,55 @@ +using System; +using System.CommandLine; +using System.CommandLine.Invocation; +using System.IO; +using System.Threading.Tasks; + +namespace Opulence +{ + public class PushCommand + { + public static Command Create() + { + var command = new Command("push", "Push the application to registry") + { + StandardOptions.Project, + StandardOptions.Verbosity, + StandardOptions.Environment, + }; + + command.Handler = CommandHandler.Create((console, project, environment, verbosity) => + { + return ExecuteAsync(new OutputContext(console, verbosity), project, environment); + }); + + return command; + } + + private static async Task ExecuteAsync(OutputContext output, FileInfo projectFile, string environment) + { + output.WriteBanner(); + + var application = await ApplicationFactory.CreateApplicationAsync(output, projectFile); + if (application.Globals.Registry?.Hostname == null) + { + throw new CommandException("A registry is required for push operations. run 'dotnet-opulence init'."); + } + + var steps = new ServiceExecutor.Step[] + { + new CombineStep() { Environment = environment, }, + new BuildDockerImageStep() { Environment = environment, }, + new PushDockerImageStep() { Environment = environment, }, + }; + + var executor = new ServiceExecutor(output, application, steps); + foreach (var service in application.Services) + { + if (service.IsMatchForProject(application, projectFile)) + { + await executor.ExecuteAsync(service); + } + } + } + } +} diff --git a/src/opulence/dotnet-opulence/PushDockerImageStep.cs b/src/opulence/dotnet-opulence/PushDockerImageStep.cs new file mode 100644 index 00000000..f60f8fd6 --- /dev/null +++ b/src/opulence/dotnet-opulence/PushDockerImageStep.cs @@ -0,0 +1,36 @@ +using System.Linq; +using System.Threading.Tasks; + +namespace Opulence +{ + internal sealed class PushDockerImageStep : ServiceExecutor.Step + { + public override string DisplayText => "Pushing Docker Image..."; + + public string Environment { get; set; } = "production"; + + public override async Task ExecuteAsync(OutputContext output, Application application, ServiceEntry service) + { + if (SkipWithoutProject(output, service, out var _)) + { + return; + } + + if (SkipWithoutContainerInfo(output, service, out var _)) + { + return; + } + + if (SkipForEnvironment(output, service, Environment)) + { + return; + } + + foreach (var image in service.Outputs.OfType()) + { + await DockerPush.ExecuteAsync(output, image.ImageName, image.ImageTag); + output.WriteInfoLine($"Pushed docker image: '{image.ImageName}:{image.ImageTag}'"); + } + } + } +} \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/Resources/Imports.targets b/src/opulence/dotnet-opulence/Resources/Imports.targets new file mode 100644 index 00000000..2f3ef3d9 --- /dev/null +++ b/src/opulence/dotnet-opulence/Resources/Imports.targets @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/ScriptRunner.cs b/src/opulence/dotnet-opulence/ScriptRunner.cs new file mode 100644 index 00000000..15fc73ae --- /dev/null +++ b/src/opulence/dotnet-opulence/ScriptRunner.cs @@ -0,0 +1,166 @@ +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Build.Construction; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Scripting; +using Microsoft.CodeAnalysis.Scripting; + +namespace Opulence +{ + internal static class ScriptRunner + { + public static async Task RunCustomizationScriptAsync(OutputContext output, FileInfo projectFile) + { + if (output is null) + { + throw new ArgumentNullException(nameof(output)); + } + + using (var step = output.BeginStep("Applying Application Customizations...")) + { + var scriptFilePath = DirectorySearch.AscendingSearch(projectFile.DirectoryName, "Opulence.csx"); + output.WriteDebugLine($"Looking for customization script above '{projectFile}'."); + if (!File.Exists(scriptFilePath)) + { + output.WriteDebugLine($"No customization script found."); + step.MarkComplete("Skipping..."); + return null; + } + + output.WriteInfoLine($"Configuring application using '{Path.GetFileName(scriptFilePath)}'."); + + var code = File.ReadAllText(scriptFilePath); + var script = CSharpScript.Create( + code, + options: ScriptOptions.Default.WithImports(new []{ "System", "System.Collections.Generic", }), + globalsType: typeof(PipelineHolder), + assemblyLoader: null); + script = script.ContinueWith(@"return await Pipeline.ExecuteAsync(__Pipeline);", options: ScriptOptions.Default); + + output.WriteDebugLine($"Compiling {Path.GetFileName(scriptFilePath)}'."); + script.Compile(); + var diagnostics = script.Compile(); + if (diagnostics.Length > 0) + { + var builder = new StringBuilder(); + output.WriteDebugLine($"Script '{scriptFilePath}' had compilation errors."); + builder.AppendLine($"Script '{scriptFilePath}' had compilation errors."); + foreach (var diagnostic in diagnostics) + { + output.WriteDebugLine(CSharpDiagnosticFormatter.Instance.Format(diagnostic)); + builder.AppendLine(CSharpDiagnosticFormatter.Instance.Format(diagnostic)); + } + + throw new CommandException(builder.ToString()); + } + output.WriteDebugLine($"Done compiling {Path.GetFileName(scriptFilePath)}'."); + + var pipeline = new CustomizationPipeline( + output, + rootDirectory: Path.GetDirectoryName(scriptFilePath)!, + name: Names.NormalizeToDns(Path.GetFileNameWithoutExtension(projectFile.Name)), + solution: null, + projectFile); + var holder = new PipelineHolder(pipeline); + + output.WriteDebugLine($"Running {Path.GetFileName(scriptFilePath)}'."); + object obj; + try + { + var result = await script.RunAsync(holder); + obj = result.ReturnValue; + } + catch (Exception ex) + { + throw new CommandException($"Failed executing {Path.GetFileName(scriptFilePath)}'.", ex); + } + + step.MarkComplete(); + return new ApplicationWrapper(obj, Path.GetDirectoryName(scriptFilePath)!); + } + } + + public static async Task RunCustomizationScriptAsync(OutputContext output, FileInfo solutionFile, SolutionFile solution) + { + if (output is null) + { + throw new ArgumentNullException(nameof(output)); + } + + if (solutionFile is null) + { + throw new ArgumentNullException(nameof(solutionFile)); + } + + if (solution is null) + { + throw new ArgumentNullException(nameof(solution)); + } + + using (var step = output.BeginStep("Applying Application Customizations...")) + { + var scriptFilePath = Path.Combine(solutionFile.DirectoryName, "Opulence.csx"); + output.WriteDebugLine($"Looking for customization script at '{scriptFilePath}'."); + if (!File.Exists(scriptFilePath)) + { + output.WriteDebugLine($"No customization script found."); + step.MarkComplete("Skipping..."); + return null; + } + + output.WriteInfoLine($"Configuring application using '{Path.GetFileName(scriptFilePath)}'."); + + var code = File.ReadAllText(scriptFilePath); + var script = CSharpScript.Create( + code, + options: ScriptOptions.Default.WithImports(new []{ "System", "System.Collections.Generic", }), + globalsType: typeof(PipelineHolder), + assemblyLoader: null); + script = script.ContinueWith(@"return await Pipeline.ExecuteAsync(__Pipeline);", options: ScriptOptions.Default); + + output.WriteDebugLine($"Compiling {Path.GetFileName(scriptFilePath)}'."); + script.Compile(); + var diagnostics = script.Compile(); + if (diagnostics.Length > 0) + { + var builder = new StringBuilder(); + output.WriteDebugLine($"Script '{scriptFilePath}' had compilation errors."); + builder.AppendLine($"Script '{scriptFilePath}' had compilation errors."); + foreach (var diagnostic in diagnostics) + { + output.WriteDebugLine(CSharpDiagnosticFormatter.Instance.Format(diagnostic)); + builder.AppendLine(CSharpDiagnosticFormatter.Instance.Format(diagnostic)); + } + + throw new CommandException(builder.ToString()); + } + output.WriteDebugLine($"Done compiling {Path.GetFileName(scriptFilePath)}'."); + + var pipeline = new CustomizationPipeline( + output, + rootDirectory: Path.GetDirectoryName(scriptFilePath)!, + name: Names.NormalizeToDns(Path.GetFileNameWithoutExtension(solutionFile.Name)), + solution, + projectFile: null); + var holder = new PipelineHolder(pipeline); + + output.WriteDebugLine($"Running {Path.GetFileName(scriptFilePath)}'."); + object obj; + try + { + var result = await script.RunAsync(holder); + obj = result.ReturnValue; + } + catch (Exception ex) + { + throw new CommandException($"Failed executing {Path.GetFileName(scriptFilePath)}'.", ex); + } + + step.MarkComplete(); + return new ApplicationWrapper(obj, Path.GetDirectoryName(scriptFilePath)!); + } + } + } +} \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/ServiceExecutor.cs b/src/opulence/dotnet-opulence/ServiceExecutor.cs new file mode 100644 index 00000000..4069f313 --- /dev/null +++ b/src/opulence/dotnet-opulence/ServiceExecutor.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Threading.Tasks; + +namespace Opulence +{ + internal sealed class ServiceExecutor + { + private readonly OutputContext output; + private readonly Application application; + private readonly Step[] steps; + + public ServiceExecutor(OutputContext output, Application application, IEnumerable steps) + { + if (output is null) + { + throw new ArgumentNullException(nameof(output)); + } + + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + if (steps is null) + { + throw new ArgumentNullException(nameof(steps)); + } + + this.output = output; + this.application = application; + this.steps = steps.ToArray(); + } + + public async Task ExecuteAsync(ServiceEntry service) + { + using var tracker = output.BeginStep($"Processing Service '{service.FriendlyName}'..."); + for (var i = 0; i < steps.Length; i++) + { + var step = steps[i]; + + using var stepTracker = output.BeginStep(step.DisplayText); + await step.ExecuteAsync(output, application, service); + stepTracker.MarkComplete(); + } + tracker.MarkComplete(); + } + + public abstract class Step + { + public abstract string DisplayText { get; } + + public abstract Task ExecuteAsync(OutputContext output, Application application, ServiceEntry service); + + protected bool SkipForEnvironment(OutputContext output, ServiceEntry service, string environment) + { + if (!service.AppliesToEnvironment(environment)) + { + output.WriteDebugLine($"Service '{service.FriendlyName}' is not part of environment '{environment}'. Skipping."); + return true; + } + + return false; + } + + protected bool SkipWithoutProject(OutputContext output, ServiceEntry service, [MaybeNullWhen(returnValue: true)] out Project project) + { + if (output is null) + { + throw new ArgumentNullException(nameof(output)); + } + + if (service is null) + { + throw new ArgumentNullException(nameof(service)); + } + + if (service.Service.Source is Project p) + { + project = p; + return false; + } + + output.WriteInfoLine($"Service '{service.FriendlyName}' does not have a project associated. Skipping."); + project = default!; + return true; + } + + protected bool SkipWithoutContainerInfo(OutputContext output, ServiceEntry service, [MaybeNullWhen(returnValue: true)] out ContainerInfo container) + { + if (output is null) + { + throw new ArgumentNullException(nameof(output)); + } + + if (service is null) + { + throw new ArgumentNullException(nameof(service)); + } + + if (service.Service.GeneratedAssets.Container is ContainerInfo c) + { + container = c; + return false; + } + + output.WriteInfoLine($"Service '{service.FriendlyName}' does not produce a container. Skipping."); + container = default!; + return true; + } + + protected bool SkipWithoutContainerOutput(OutputContext output, ServiceEntry service) + { + if (output is null) + { + throw new ArgumentNullException(nameof(output)); + } + + if (service is null) + { + throw new ArgumentNullException(nameof(service)); + } + + if (service.Outputs.OfType().Any()) + { + return false; + } + + output.WriteInfoLine($"Service '{service.FriendlyName}' does not have a container. Skipping."); + return true; + } + } + } +} \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/StandardOptions.cs b/src/opulence/dotnet-opulence/StandardOptions.cs new file mode 100644 index 00000000..53b66c78 --- /dev/null +++ b/src/opulence/dotnet-opulence/StandardOptions.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.CommandLine.Parsing; +using System.IO; +using System.Linq; + +namespace Opulence +{ + internal static class StandardOptions + { + private static readonly string[] AllOutputs = new string[] { "container", "chart", }; + + public static Option Environment + { + get + { + return new Option(new []{ "-e", "--environment" }, "Environemnt") + { + Argument = new Argument("environment", () => "production") + { + Arity = ArgumentArity.ExactlyOne, + }, + Required = false, + }; + } + } + + public static Option Force + { + get + { + return new Option("--force", "Force overwrite of existing files") + { + Argument = new Argument(), + }; + } + } + + public static Option Outputs + { + get + { + var argument = new Argument>(TryConvert) + { + Arity = ArgumentArity.ZeroOrMore, + }; + argument.AddSuggestions(AllOutputs); + argument.SetDefaultValue(new List(AllOutputs)); + + return new Option(new[]{ "-o", "--outputs" }, "Outputs to generate") + { + Argument = argument, + }; + + static bool TryConvert(SymbolResult symbol, out List outputs) + { + outputs = new List(); + + foreach (var token in symbol.Tokens) + { + if (!AllOutputs.Any(item => string.Equals(item, token.Value, StringComparison.OrdinalIgnoreCase))) + { + symbol.ErrorMessage = $"output '{token.Value}' is not recognized"; + outputs = default!; + return false; + } + + outputs.Add(token.Value.ToLowerInvariant()); + } + + return true; + } + } + } + + public static Option Project + { + get + { + + var argument = new Argument(TryParse, isDefault: true) + { + Arity = ArgumentArity.ZeroOrOne, + Name = "project-file or solution-file or directory", + }; + + return new Option(new [] { "-p", "--project", }, "Project file, Solution file or directory") + { + Argument = argument, + }; + + static bool TryFindProjectFile(string directoryPath, out string? projectFilePath, out string? errorMessage) + { + var matches = new List(); + foreach (var candidate in Directory.EnumerateFiles(directoryPath)) + { + if (Path.GetExtension(candidate).EndsWith(".sln")) + { + matches.Add(candidate); + } + + if (Path.GetExtension(candidate).EndsWith(".csproj")) + { + matches.Add(candidate); + } + } + + // Prefer solution if both are in the same directory. This helps + // avoid some conflicts. + if (matches.Any(m => m.EndsWith(".sln"))) + { + matches.RemoveAll(m => m.EndsWith(".csproj")); + } + + if (matches.Count == 0) + { + errorMessage = $"No project file or solution file was found in directory '{directoryPath}'."; + projectFilePath = default; + return false; + } + else if (matches.Count == 1) + { + errorMessage = null; + projectFilePath = matches[0]; + return true; + } + else + { + errorMessage = $"More than one project file or solution file was found in directory '{directoryPath}'."; + projectFilePath = default; + return false; + } + } + + static FileInfo TryParse(ArgumentResult result) + { + var token = result.Tokens.Count switch + { + 0 => ".", + 1 => result.Tokens[0].Value, + _ => throw new InvalidOperationException("Unexpected token count."), + }; + + if (File.Exists(token)) + { + return new FileInfo(token); + } + + if (Directory.Exists(token)) + { + if (TryFindProjectFile(token, out var filePath, out var errorMessage)) + { + return new FileInfo(filePath); + } + else + { + result.ErrorMessage = errorMessage; + return default!; + } + } + + result.ErrorMessage = $"The file '{token}' could not be found."; + return default!; + } + } + } + + public static Option Verbosity + { + get + { + return new Option(new [] { "-v", "--verbosity" }, "Output verbostiy") + { + Argument = new Argument("one of: quiet|info|debug", () => Opulence.Verbosity.Info) + { + Arity = ArgumentArity.ExactlyOne, + }, + Required = false, + }; + } + } + } +} \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/StepView.cs b/src/opulence/dotnet-opulence/StepView.cs new file mode 100644 index 00000000..cea611c6 --- /dev/null +++ b/src/opulence/dotnet-opulence/StepView.cs @@ -0,0 +1,7 @@ +namespace Opulence +{ + internal class StepView + { + + } +} \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/TargetInstaller.cs b/src/opulence/dotnet-opulence/TargetInstaller.cs new file mode 100644 index 00000000..9a7f0a0b --- /dev/null +++ b/src/opulence/dotnet-opulence/TargetInstaller.cs @@ -0,0 +1,47 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Reflection; + +namespace Opulence +{ + public static class TargetInstaller + { + public static void Install(string projectFilePath) + { + if (projectFilePath is null) + { + throw new ArgumentNullException(nameof(projectFilePath)); + } + + var projectDirectory = Path.GetDirectoryName(projectFilePath); + var intermediateDirectory = Path.Combine(projectDirectory!, "obj"); + + Directory.CreateDirectory(intermediateDirectory); + + var fileName = $"{Path.GetFileName(projectFilePath)}.opulence.targets"; + var targetFilePath = Path.Combine(intermediateDirectory, fileName); + + if (File.Exists(targetFilePath)) + { + return; + } + + var toolType = typeof(TargetInstaller); + var toolAssembly = toolType.GetTypeInfo().Assembly; + var toolImportTargetsResourceName = $"Opulence.Resources.Imports.targets"; + + using var stream = toolAssembly.GetManifestResourceStream(toolImportTargetsResourceName); + if (stream == null) + { + throw new CommandException("Failed to find resource. Valid names: " + string.Join(", ", toolAssembly.GetManifestResourceNames())); + } + + var targetBytes = new byte[stream.Length]; + stream.Read(targetBytes, 0, targetBytes.Length); + File.WriteAllBytes(targetFilePath, targetBytes); + } + } +} diff --git a/src/opulence/dotnet-opulence/TempDirectory.cs b/src/opulence/dotnet-opulence/TempDirectory.cs new file mode 100644 index 00000000..7b9c5241 --- /dev/null +++ b/src/opulence/dotnet-opulence/TempDirectory.cs @@ -0,0 +1,27 @@ +using System; +using System.IO; + +namespace Opulence +{ + internal class TempDirectory : IDisposable + { + public static TempDirectory Create() + { + var directoryPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(directoryPath); + return new TempDirectory(directoryPath); + } + + private TempDirectory(string directoryPath) + { + DirectoryPath = directoryPath; + } + + public string DirectoryPath { get; } + + public void Dispose() + { + Directory.Delete(DirectoryPath, recursive: true); + } + } +} \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/TempFile.cs b/src/opulence/dotnet-opulence/TempFile.cs new file mode 100644 index 00000000..626738ef --- /dev/null +++ b/src/opulence/dotnet-opulence/TempFile.cs @@ -0,0 +1,26 @@ +using System; +using System.IO; + +namespace Opulence +{ + internal class TempFile : IDisposable + { + public static TempFile Create() + { + return new TempFile(Path.GetTempFileName()); + } + + public void Dispose() + { + File.Delete(FilePath); + } + + private TempFile(string filePath) + { + FilePath = filePath; + } + + public string FilePath { get; } + + } +} \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/Templates.cs b/src/opulence/dotnet-opulence/Templates.cs new file mode 100644 index 00000000..6b9c9194 --- /dev/null +++ b/src/opulence/dotnet-opulence/Templates.cs @@ -0,0 +1,25 @@ +using System.IO; + +namespace Opulence +{ + internal static class Templates + { + public static string RootDirectoryPath + { + get + { + return Path.Combine( + Path.GetDirectoryName(typeof(HelmChartGenerator).Assembly.Location)!, + "Templates"); + } + } + + public static string HelmChartsDirectoryPath + { + get + { + return Path.Combine(RootDirectoryPath, "Helm"); + } + } + } +} \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/Templates/Helm/templates/_helpers.tpl b/src/opulence/dotnet-opulence/Templates/Helm/templates/_helpers.tpl new file mode 100644 index 00000000..5ee2d724 --- /dev/null +++ b/src/opulence/dotnet-opulence/Templates/Helm/templates/_helpers.tpl @@ -0,0 +1,45 @@ +{{/* vim: set filetype=mustache: */}} +{{/* +Expand the name of the chart. +*/}} +{{- define "thischart.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "thischart.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- if contains $name .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "thischart.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Common labels +*/}} +{{- define "thischart.labels" -}} +app.kubernetes.io/name: {{ include "thischart.name" . }} +helm.sh/chart: {{ include "thischart.chart" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end -}} diff --git a/src/opulence/dotnet-opulence/Templates/Helm/templates/deployment.yaml b/src/opulence/dotnet-opulence/Templates/Helm/templates/deployment.yaml new file mode 100644 index 00000000..2a763b04 --- /dev/null +++ b/src/opulence/dotnet-opulence/Templates/Helm/templates/deployment.yaml @@ -0,0 +1,30 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "thischart.fullname" . }} + labels: +{{ include "thischart.labels" . | indent 4 }} +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: {{ include "thischart.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + template: + metadata: + labels: + app.kubernetes.io/name: {{ include "thischart.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + spec: + containers: + - name: {{ .Chart.Name }} + image: {{ .Values.image.repository }}:{{ .Chart.AppVersion }} + imagePullPolicy: Always + livenessProbe: + httpGet: + path: /healthz + port: 80 + initialDelaySeconds: 5 + periodSeconds: 5 + ports: + - containerPort: 80 diff --git a/src/opulence/dotnet-opulence/Templates/Helm/templates/service.yaml b/src/opulence/dotnet-opulence/Templates/Helm/templates/service.yaml new file mode 100644 index 00000000..1c54673a --- /dev/null +++ b/src/opulence/dotnet-opulence/Templates/Helm/templates/service.yaml @@ -0,0 +1,16 @@ +kind: Service +apiVersion: v1 +metadata: + name: {{ include "thischart.name" . }} + labels: + app.kubernetes.io/name: {{ include "thischart.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} +spec: + selector: + app.kubernetes.io/name: {{ include "thischart.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + ports: + - name: http + protocol: TCP + port: 80 + type: LoadBalancer \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/Verbosity.cs b/src/opulence/dotnet-opulence/Verbosity.cs new file mode 100644 index 00000000..0c73b42a --- /dev/null +++ b/src/opulence/dotnet-opulence/Verbosity.cs @@ -0,0 +1,9 @@ +namespace Opulence +{ + internal enum Verbosity + { + Quiet, + Info, + Debug, + } +} \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/WriteServiceYamlStep.cs b/src/opulence/dotnet-opulence/WriteServiceYamlStep.cs new file mode 100644 index 00000000..b6db69f4 --- /dev/null +++ b/src/opulence/dotnet-opulence/WriteServiceYamlStep.cs @@ -0,0 +1,43 @@ +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using YamlDotNet.RepresentationModel; + +namespace Opulence +{ + internal sealed class WriteServiceYamlStep : ServiceExecutor.Step + { + public override string DisplayText => "Writing Manifests..."; + + public bool Force { get; set; } + + public DirectoryInfo OutputDirectory { get; set; } = default!; + + public override Task ExecuteAsync(OutputContext output, Application application, ServiceEntry service) + { + var yaml = service.Outputs.OfType().ToArray(); + if (yaml.Length == 0) + { + output.WriteDebugLine($"No yaml manifests found for service '{service.FriendlyName}'. Skipping."); + return Task.CompletedTask; + } + + var outputFilePath = Path.Combine(OutputDirectory.FullName, $"{service.Service.Name}.yaml"); + output.WriteInfoLine($"Writing output to '{outputFilePath}'."); + if (File.Exists(outputFilePath) && !Force) + { + throw new CommandException($"'{service.Service.Name}.yaml' already exists for project. use '--force' to overwrite."); + } + + File.Delete(outputFilePath); + + using var stream = File.OpenWrite(outputFilePath); + using var writer = new StreamWriter(stream, Encoding.UTF8, leaveOpen: true); + var yamlStream = new YamlStream(yaml.Select(y => y.Yaml)); + yamlStream.Save(writer, assignAnchors: false); + + return Task.CompletedTask; + } + } +} \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/build/Opulence.targets b/src/opulence/dotnet-opulence/build/Opulence.targets new file mode 100644 index 00000000..2588a3b0 --- /dev/null +++ b/src/opulence/dotnet-opulence/build/Opulence.targets @@ -0,0 +1,28 @@ + + + + + $(EvaluateOpulenceProjectInfoDependsOn); + ResolveReferences; + ResolvePackageDependenciesDesignTime; + PrepareResources; + GetAssemblyAttributes; + + + + + + + + <_Frameworks>@(FrameworkReference, ',') + + + <_Lines Include="version=$(InformationalVersion)" /> + <_Lines Include="tfm=$(TargetFramework)" /> + <_Lines Include="frameworks=$(_Frameworks)" /> + + + + + + diff --git a/src/opulence/dotnet-opulence/buildMultiTargeting/Opulence.targets b/src/opulence/dotnet-opulence/buildMultiTargeting/Opulence.targets new file mode 100644 index 00000000..4efd31e6 --- /dev/null +++ b/src/opulence/dotnet-opulence/buildMultiTargeting/Opulence.targets @@ -0,0 +1,20 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/opulence/dotnet-opulence/dotnet-opulence.csproj b/src/opulence/dotnet-opulence/dotnet-opulence.csproj new file mode 100644 index 00000000..c59f367c --- /dev/null +++ b/src/opulence/dotnet-opulence/dotnet-opulence.csproj @@ -0,0 +1,39 @@ + + + + Exe + netcoreapp3.1 + Opulence + true + Opulence.dotnet-opulence + opulence + Enable + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tye.sln b/tye.sln index 99ad83dd..fedc2973 100644 --- a/tye.sln +++ b/tye.sln @@ -11,6 +11,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{F19B02EB-A EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "E2ETest", "test\E2ETest\E2ETest.csproj", "{D15E5FF6-C1E7-4110-A2BE-06ADA7ACA82B}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "opulence", "opulence", "{E8219571-549D-4EFE-8E62-54082D707B9A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Opulence", "src\opulence\Opulence\Opulence.csproj", "{0A8A0C69-49CB-462E-A7D4-605F78786EEF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "dotnet-opulence", "src\opulence\dotnet-opulence\dotnet-opulence.csproj", "{BA03A616-4649-414D-A116-727D72F4BF50}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -48,9 +54,36 @@ Global {D15E5FF6-C1E7-4110-A2BE-06ADA7ACA82B}.Release|x64.Build.0 = Release|Any CPU {D15E5FF6-C1E7-4110-A2BE-06ADA7ACA82B}.Release|x86.ActiveCfg = Release|Any CPU {D15E5FF6-C1E7-4110-A2BE-06ADA7ACA82B}.Release|x86.Build.0 = Release|Any CPU + {0A8A0C69-49CB-462E-A7D4-605F78786EEF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0A8A0C69-49CB-462E-A7D4-605F78786EEF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0A8A0C69-49CB-462E-A7D4-605F78786EEF}.Debug|x64.ActiveCfg = Debug|Any CPU + {0A8A0C69-49CB-462E-A7D4-605F78786EEF}.Debug|x64.Build.0 = Debug|Any CPU + {0A8A0C69-49CB-462E-A7D4-605F78786EEF}.Debug|x86.ActiveCfg = Debug|Any CPU + {0A8A0C69-49CB-462E-A7D4-605F78786EEF}.Debug|x86.Build.0 = Debug|Any CPU + {0A8A0C69-49CB-462E-A7D4-605F78786EEF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0A8A0C69-49CB-462E-A7D4-605F78786EEF}.Release|Any CPU.Build.0 = Release|Any CPU + {0A8A0C69-49CB-462E-A7D4-605F78786EEF}.Release|x64.ActiveCfg = Release|Any CPU + {0A8A0C69-49CB-462E-A7D4-605F78786EEF}.Release|x64.Build.0 = Release|Any CPU + {0A8A0C69-49CB-462E-A7D4-605F78786EEF}.Release|x86.ActiveCfg = Release|Any CPU + {0A8A0C69-49CB-462E-A7D4-605F78786EEF}.Release|x86.Build.0 = Release|Any CPU + {BA03A616-4649-414D-A116-727D72F4BF50}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BA03A616-4649-414D-A116-727D72F4BF50}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BA03A616-4649-414D-A116-727D72F4BF50}.Debug|x64.ActiveCfg = Debug|Any CPU + {BA03A616-4649-414D-A116-727D72F4BF50}.Debug|x64.Build.0 = Debug|Any CPU + {BA03A616-4649-414D-A116-727D72F4BF50}.Debug|x86.ActiveCfg = Debug|Any CPU + {BA03A616-4649-414D-A116-727D72F4BF50}.Debug|x86.Build.0 = Debug|Any CPU + {BA03A616-4649-414D-A116-727D72F4BF50}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BA03A616-4649-414D-A116-727D72F4BF50}.Release|Any CPU.Build.0 = Release|Any CPU + {BA03A616-4649-414D-A116-727D72F4BF50}.Release|x64.ActiveCfg = Release|Any CPU + {BA03A616-4649-414D-A116-727D72F4BF50}.Release|x64.Build.0 = Release|Any CPU + {BA03A616-4649-414D-A116-727D72F4BF50}.Release|x86.ActiveCfg = Release|Any CPU + {BA03A616-4649-414D-A116-727D72F4BF50}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {9BFE4070-D6D8-41F8-B92E-F85C3DCD7AB8} = {8C662D59-A3CB-466F-8E85-A8E6BA5E7601} {D15E5FF6-C1E7-4110-A2BE-06ADA7ACA82B} = {F19B02EB-A372-417A-B2C2-EA0D5A3C76D5} + {E8219571-549D-4EFE-8E62-54082D707B9A} = {8C662D59-A3CB-466F-8E85-A8E6BA5E7601} + {0A8A0C69-49CB-462E-A7D4-605F78786EEF} = {E8219571-549D-4EFE-8E62-54082D707B9A} + {BA03A616-4649-414D-A116-727D72F4BF50} = {E8219571-549D-4EFE-8E62-54082D707B9A} EndGlobalSection EndGlobal