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