diff --git a/src/Microsoft.Tye.Hosting/DockerRunner.cs b/src/Microsoft.Tye.Hosting/DockerRunner.cs index e5cb44a3..ff6f0265 100644 --- a/src/Microsoft.Tye.Hosting/DockerRunner.cs +++ b/src/Microsoft.Tye.Hosting/DockerRunner.cs @@ -68,6 +68,8 @@ namespace Microsoft.Tye.Hosting var serviceDescription = service.Description; var environmentArguments = ""; + var volumes = ""; + var workingDirectory = docker.WorkingDirectory != null ? $"-w {docker.WorkingDirectory}" : ""; var dockerInfo = new DockerInformation(new Task[service.Description.Replicas]); @@ -95,6 +97,9 @@ namespace Microsoft.Tye.Hosting { status.Ports = ports.Select(p => p.Port); + // These ports should also be passed in not assuming ASP.NET Core + environment["ASPNETCORE_URLS"] = string.Join(";", ports.Select(p => $"{p.Protocol ?? "http"}://*:{p.Port}")); + portString = string.Join(" ", ports.Select(p => $"-p {p.Port}:{p.InternalPort ?? p.Port}")); foreach (var p in ports) @@ -112,7 +117,12 @@ namespace Microsoft.Tye.Hosting environmentArguments += $"-e {pair.Key}={pair.Value} "; } - var command = $"run -d {environmentArguments} {portString} --name {replica} --restart=unless-stopped {docker.Image} {docker.Args ?? ""}"; + foreach (var pair in docker.VolumeMappings) + { + volumes += $"-v {pair.Key}:{pair.Value} "; + } + + var command = $"run -d {workingDirectory} {volumes} {environmentArguments} {portString} --name {replica} --restart=unless-stopped {docker.Image} {docker.Args ?? ""}"; _logger.LogInformation("Running docker command {Command}", command); service.Logs.OnNext($"[{replica}]: {command}"); diff --git a/src/Microsoft.Tye.Hosting/Model/DockerRunInfo.cs b/src/Microsoft.Tye.Hosting/Model/DockerRunInfo.cs index b435ceb3..e9ca5d1e 100644 --- a/src/Microsoft.Tye.Hosting/Model/DockerRunInfo.cs +++ b/src/Microsoft.Tye.Hosting/Model/DockerRunInfo.cs @@ -2,6 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using System.Collections.Generic; + namespace Microsoft.Tye.Hosting.Model { public class DockerRunInfo : RunInfo @@ -12,6 +14,10 @@ namespace Microsoft.Tye.Hosting.Model Args = args; } + public string? WorkingDirectory { get; set; } + + public Dictionary VolumeMappings { get; } = new Dictionary(); + public string? Args { get; } public string Image { get; } diff --git a/src/Microsoft.Tye.Hosting/Model/ServiceDescription.cs b/src/Microsoft.Tye.Hosting/Model/ServiceDescription.cs index f74c3fe5..743148e4 100644 --- a/src/Microsoft.Tye.Hosting/Model/ServiceDescription.cs +++ b/src/Microsoft.Tye.Hosting/Model/ServiceDescription.cs @@ -16,7 +16,7 @@ namespace Microsoft.Tye.Hosting.Model public string Name { get; } - public RunInfo? RunInfo { get; } + public RunInfo? RunInfo { get; set; } public int Replicas { get; set; } = 1; public List Bindings { get; } = new List(); diff --git a/src/Microsoft.Tye.Hosting/TransformProjectsIntoContainers.cs b/src/Microsoft.Tye.Hosting/TransformProjectsIntoContainers.cs new file mode 100644 index 00000000..9f854ac5 --- /dev/null +++ b/src/Microsoft.Tye.Hosting/TransformProjectsIntoContainers.cs @@ -0,0 +1,100 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Tye.Hosting.Model; + +namespace Microsoft.Tye.Hosting +{ + public class TransformProjectsIntoContainers : IApplicationProcessor + { + private readonly ILogger _logger; + + public TransformProjectsIntoContainers(ILogger logger) + { + _logger = logger; + } + + public Task StartAsync(Model.Application application) + { + // This transforms a ProjectRunInfo into + var tasks = new List(); + foreach (var s in application.Services.Values) + { + if (s.Description.RunInfo is ProjectRunInfo project) + { + tasks.Add(TransformProjectToContainer(application, s, project)); + } + } + + return Task.WhenAll(tasks); + } + + private async Task TransformProjectToContainer(Model.Application application, Model.Service service, ProjectRunInfo project) + { + var serviceDescription = service.Description; + var serviceName = serviceDescription.Name; + + var expandedProject = Environment.ExpandEnvironmentVariables(project.Project); + var fullProjectPath = Path.GetFullPath(Path.Combine(application.ContextDirectory, expandedProject)); + service.Status.ProjectFilePath = fullProjectPath; + + // Sometimes building can fail because of file locking (like files being open in VS) + _logger.LogInformation("Building project {ProjectFile}", service.Status.ProjectFilePath); + + service.Logs.OnNext($"dotnet build \"{service.Status.ProjectFilePath}\" /nologo"); + + var buildResult = await ProcessUtil.RunAsync("dotnet", $"build \"{service.Status.ProjectFilePath}\" /nologo", + outputDataReceived: data => service.Logs.OnNext(data), + throwOnError: false); + + if (buildResult.ExitCode != 0) + { + _logger.LogInformation("Building {ProjectFile} failed with exit code {ExitCode}: " + buildResult.StandardOutput + buildResult.StandardError, service.Status.ProjectFilePath, buildResult.ExitCode); + return; + } + + var targetFramework = GetTargetFramework(service.Status.ProjectFilePath); + + // We transform the project information into the following docker command: + // docker run -w /app -v {projectDir}:/app -it {image} dotnet /app/bin/Debug/{tfm}/{outputfile}.dll + var containerImage = DetermineContainerImage(targetFramework); + var outputFileName = Path.GetFileNameWithoutExtension(service.Status.ProjectFilePath) + ".dll"; + var dockerRunInfo = new DockerRunInfo(containerImage, $"dotnet /app/bin/Debug/{targetFramework}/{outputFileName} {project.Args}") + { + WorkingDirectory = "/app" + }; + dockerRunInfo.VolumeMappings[Path.GetDirectoryName(service.Status.ProjectFilePath)!] = "/app"; + + // Change the project into a container info + serviceDescription.RunInfo = dockerRunInfo; + } + + private static string DetermineContainerImage(string targetFramework) + { + // TODO: Determine the base iamge from the tfm + return "mcr.microsoft.com/dotnet/core/sdk:3.1-buster"; + } + + private static string GetTargetFramework(string? projectFilePath) + { + // TODO: Use msbuild to get the target path + var debugOutputPath = Path.Combine(Path.GetDirectoryName(projectFilePath)!, "bin", "Debug"); + + var tfms = Directory.Exists(debugOutputPath) ? Directory.GetDirectories(debugOutputPath) : Array.Empty(); + + return tfms.Select(tfm => new DirectoryInfo(tfm).Name).FirstOrDefault() ?? "netcoreapp3.1"; + } + + public Task StopAsync(Model.Application application) + { + return Task.CompletedTask; + } + } +} diff --git a/src/Microsoft.Tye.Hosting/TyeHost.cs b/src/Microsoft.Tye.Hosting/TyeHost.cs index 5b0455dc..d0b389e6 100644 --- a/src/Microsoft.Tye.Hosting/TyeHost.cs +++ b/src/Microsoft.Tye.Hosting/TyeHost.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using System; +using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Net; @@ -14,12 +15,11 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Microsoft.Tye.Hosting.Diagnostics; using Serilog; using Serilog.Core; using Serilog.Events; using Serilog.Filters; -using Microsoft.Tye.Hosting.Diagnostics; -using Microsoft.Tye.Hosting.Model; namespace Microsoft.Tye.Hosting { @@ -246,13 +246,21 @@ namespace Microsoft.Tye.Hosting // Print out what providers were selected and their values diagnosticOptions.DumpDiagnostics(logger); - var processor = new AggregateApplicationProcessor(new IApplicationProcessor[] { + var processors = new List + { new EventPipeDiagnosticsRunner(logger, diagnosticsCollector), new ProxyService(logger), new DockerRunner(logger), new ProcessRunner(logger, ProcessRunnerOptions.FromArgs(args)), - }); - return processor; + }; + + // If the docker command is specified then transport the ProjectRunInfo into DockerRunInfo + if (args.Contains("--docker")) + { + processors.Insert(0, new TransformProjectsIntoContainers(logger)); + } + + return new AggregateApplicationProcessor(processors); } public void Dispose() diff --git a/src/tye/Program.RunCommand.cs b/src/tye/Program.RunCommand.cs index 5f36d760..815ab917 100644 --- a/src/tye/Program.RunCommand.cs +++ b/src/tye/Program.RunCommand.cs @@ -55,6 +55,12 @@ namespace Microsoft.Tye Required = false }); + command.AddOption(new Option("--docker") + { + Description = "Run projects as docker containers.", + Required = false + }); + command.Handler = CommandHandler.Create(async (console, path) => { // Workaround for https://github.com/dotnet/command-line-api/issues/723#issuecomment-593062654 diff --git a/test/E2ETest/TyeRunTests.cs b/test/E2ETest/TyeRunTests.cs index db0db01d..2ffb09e2 100644 --- a/test/E2ETest/TyeRunTests.cs +++ b/test/E2ETest/TyeRunTests.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using System; +using System.Diagnostics; using System.IO; using System.Linq; using System.Net; @@ -11,6 +12,7 @@ using System.Threading.Tasks; using Microsoft.Tye; using Microsoft.Tye.ConfigModel; using Microsoft.Tye.Hosting; +using Microsoft.Tye.Hosting.Model; using Xunit; using Xunit.Abstractions; @@ -131,6 +133,46 @@ namespace E2ETest } } + [ConditionalFact] + [SkipIfDockerNotRunning] + public async Task FrontendBackendRunTestWithDocker() + { + var projectDirectory = new DirectoryInfo(Path.Combine(TestHelpers.GetSolutionRootDirectory("tye"), "samples", "frontend-backend")); + using var tempDirectory = TempDirectory.Create(); + DirectoryCopy.Copy(projectDirectory.FullName, tempDirectory.DirectoryPath); + + var projectFile = new FileInfo(Path.Combine(tempDirectory.DirectoryPath, "tye.yaml")); + using var host = new TyeHost(ConfigFactory.FromFile(projectFile).ToHostingApplication(), new[] { "--docker" }) + { + Sink = sink, + }; + + await host.StartAsync(); + try + { + // Make sure we're runningn containers + Assert.True(host.Application.Services.All(s => s.Value.Description.RunInfo is DockerRunInfo)); + + var handler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = (a, b, c, d) => true, + AllowAutoRedirect = false + }; + + var client = new HttpClient(new RetryHandler(handler)); + + var dashboardUri = new Uri(host.DashboardWebApplication!.Addresses.First()); + var dashboardResponse = await client.GetStringAsync(dashboardUri); + + await CheckServiceIsUp(host.Application, client, "backend", dashboardUri); + await CheckServiceIsUp(host.Application, client, "frontend", dashboardUri); + } + finally + { + await host.StopAsync(); + } + } + private async Task CheckServiceIsUp(Microsoft.Tye.Hosting.Model.Application application, HttpClient client, string serviceName, Uri dashboardUri) { // make sure backend is up before frontend