diff --git a/docs/reference/schema.md b/docs/reference/schema.md index 0b1584443..66b73e4eb 100644 --- a/docs/reference/schema.md +++ b/docs/reference/schema.md @@ -101,6 +101,10 @@ Specifies the list of ingresses. Specifies the list of services. Applications must have at least one service. +#### `solution` (string) + +Indicates the solution file (.sln) or filter (.slnf) to use when building project-based services in watch mode. If omitted, those services will be built individually. Specifying the solution [filter] can help reduce repeated builds of shared libraries when in watch mode. + ## Service `Service` elements appear in a list within the `services` root property. diff --git a/src/Microsoft.Tye.Core/ApplicationBuilder.cs b/src/Microsoft.Tye.Core/ApplicationBuilder.cs index 5123067c5..23d0bdd65 100644 --- a/src/Microsoft.Tye.Core/ApplicationBuilder.cs +++ b/src/Microsoft.Tye.Core/ApplicationBuilder.cs @@ -36,5 +36,6 @@ public ApplicationBuilder(FileInfo source, string name, ContainerEngine containe public List Ingress { get; } = new List(); public string? Network { get; set; } + public string? BuildSolution { get; internal set; } } } diff --git a/src/Microsoft.Tye.Core/ApplicationFactory.cs b/src/Microsoft.Tye.Core/ApplicationFactory.cs index 45665651b..6e7fdc7f2 100644 --- a/src/Microsoft.Tye.Core/ApplicationFactory.cs +++ b/src/Microsoft.Tye.Core/ApplicationFactory.cs @@ -32,7 +32,8 @@ public static async Task CreateAsync(OutputContext output, F var root = new ApplicationBuilder(source, rootConfig.Name!, new ContainerEngine(rootConfig.ContainerEngineType), rootConfig.DashboardPort) { - Namespace = rootConfig.Namespace + Namespace = rootConfig.Namespace, + BuildSolution = rootConfig.BuildSolution, }; queue.Enqueue((rootConfig, new HashSet())); diff --git a/src/Microsoft.Tye.Core/ConfigModel/ConfigApplication.cs b/src/Microsoft.Tye.Core/ConfigModel/ConfigApplication.cs index 219fac3b3..309f3cdbb 100644 --- a/src/Microsoft.Tye.Core/ConfigModel/ConfigApplication.cs +++ b/src/Microsoft.Tye.Core/ConfigModel/ConfigApplication.cs @@ -26,6 +26,8 @@ public class ConfigApplication public int? DashboardPort { get; set; } + public string? BuildSolution { get; set; } + public string? Namespace { get; set; } public string? Registry { get; set; } diff --git a/src/Microsoft.Tye.Core/MsBuild/SolutionFile.cs b/src/Microsoft.Tye.Core/MsBuild/SolutionFile.cs index 1986ed762..999a091e6 100644 --- a/src/Microsoft.Tye.Core/MsBuild/SolutionFile.cs +++ b/src/Microsoft.Tye.Core/MsBuild/SolutionFile.cs @@ -239,7 +239,7 @@ internal string SolutionFileDirectory #region Methods - internal bool ProjectShouldBuild(string projectFile) + public bool ProjectShouldBuild(string projectFile) { return _solutionFilter?.Contains(FileUtilities.FixFilePath(projectFile)) != false; } diff --git a/src/Microsoft.Tye.Core/Serialization/ConfigApplicationParser.cs b/src/Microsoft.Tye.Core/Serialization/ConfigApplicationParser.cs index 68a1225b7..a7ccc6ae9 100644 --- a/src/Microsoft.Tye.Core/Serialization/ConfigApplicationParser.cs +++ b/src/Microsoft.Tye.Core/Serialization/ConfigApplicationParser.cs @@ -21,6 +21,9 @@ public static void HandleConfigApplication(YamlMappingNode yamlMappingNode, Conf case "name": app.Name = YamlParser.GetScalarValue(key, child.Value); break; + case "solution": + app.BuildSolution = YamlParser.GetScalarValue(key, child.Value); + break; case "namespace": app.Namespace = YamlParser.GetScalarValue(key, child.Value); break; diff --git a/src/Microsoft.Tye.Hosting/BuildWatcher.cs b/src/Microsoft.Tye.Hosting/BuildWatcher.cs new file mode 100644 index 000000000..efae0d57a --- /dev/null +++ b/src/Microsoft.Tye.Hosting/BuildWatcher.cs @@ -0,0 +1,293 @@ +// 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.Linq; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using Microsoft.Build.Construction; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Tye.Hosting +{ + internal sealed class BuildWatcher : IAsyncDisposable + { + private CancellationTokenSource? _cancellationTokenSource; + private readonly SemaphoreSlim _lock = new SemaphoreSlim(1, 1); + private readonly ILogger _logger; + private Task? _processor; + private Channel? _queue; + + public BuildWatcher(ILogger logger) + { + _logger = logger; + } + + public Task StartAsync(string? solutionPath, string workingDirectory) + { + return WithLockAsync( + async () => + { + await ResetAsync(); + + _queue = Channel.CreateUnbounded(); + _cancellationTokenSource = new CancellationTokenSource(); + _processor = Task.Run(() => ProcessTaskQueueAsync(_logger, _queue.Reader, solutionPath, workingDirectory, _cancellationTokenSource.Token)); + }); + } + + public Task StopAsync() + { + return WithLockAsync(ResetAsync); + } + + public async Task BuildProjectFileAsync(string projectFilePath) + { + var request = new BuildRequest(projectFilePath); + + await WithLockAsync( + async () => + { + if (_queue == null) + { + throw new InvalidOperationException("The worker is not running."); + } + + await _queue.Writer.WriteAsync(request); + }); + + return await request.Task; + } + + #region IAsyncDisposable Members + + public async ValueTask DisposeAsync() + { + await WithLockAsync(ResetAsync); + + _lock.Dispose(); + } + + #endregion + + private async Task WithLockAsync(Func action) + { + await _lock.WaitAsync(); + + try + { + await action(); + } + finally + { + _lock.Release(); + } + } + + private async Task ResetAsync() + { + if (_queue != null) + { + _queue.Writer.TryComplete(); + _queue = null; + } + + if (_cancellationTokenSource != null) + { + _cancellationTokenSource.Cancel(); + _cancellationTokenSource.Dispose(); + _cancellationTokenSource = null; + } + + if (_processor != null) + { + await _processor; + + _processor = null; + } + } + + private static string GetProjectName(SolutionFile solution, string projectFile) + { + foreach (var project in solution.ProjectsInOrder) + { + if (project.AbsolutePath == projectFile) + { + return project.ProjectName; + } + } + + throw new InvalidOperationException($"Could not find project in solution: {projectFile}"); + } + + private static async Task ProcessTaskQueueAsync( + ILogger logger, + ChannelReader requestReader, + string? solutionPath, + string workingDirectory, + CancellationToken cancellationToken) + { + logger.LogInformation("Build Watcher: Watching for builds..."); + + try + { + while (await requestReader.WaitToReadAsync(cancellationToken)) + { + var delay = TimeSpan.FromMilliseconds(250); + + logger.LogInformation("Build Watcher: Builds requested; waiting {DelayInMs}ms for more...", delay.TotalMilliseconds); + + await Task.Delay(delay); + + logger.LogInformation("Build Watcher: Getting requests..."); + + var requests = new List(); + + while (requestReader.TryRead(out var request)) + { + requests.Add(request); + } + + logger.LogInformation("Build Watcher: Processing {Count} requests...", requests.Count); + + var solution = (solutionPath != null) ? SolutionFile.Parse(solutionPath) : null; + + var solutionBatch = new Dictionary>(); // store the list of promises + var projectBatch = new Dictionary>(); + + foreach (var request in requests) + { + if (solution?.ProjectShouldBuild(request.ProjectFilePath) == true) + { + if (!solutionBatch.ContainsKey(request.ProjectFilePath)) + { + solutionBatch.Add(request.ProjectFilePath, new List()); + } + + solutionBatch[request.ProjectFilePath].Add(request); + } + else + { + // this will also prevent us building multiple times if a project is used by multiple services + if (!projectBatch.ContainsKey(request.ProjectFilePath)) + { + projectBatch.Add(request.ProjectFilePath, new List()); + } + + projectBatch[request.ProjectFilePath].Add(request); + } + } + + async Task WithRequestCompletion(IEnumerable requests, Func> buildFunc) + { + try + { + int exitCode = await buildFunc(); + + foreach (var request in requests) + { + request.Complete(exitCode); + } + } + catch (Exception ex) + { + foreach (var request in requests) + { + request.Complete(ex); + } + } + } + + var tasks = new List(); + + if (solutionBatch.Any()) + { + var targets = String.Join(",", solutionBatch.Keys.Select(key => GetProjectName(solution!, key))); + + tasks.Add( + WithRequestCompletion( + solutionBatch.Values.SelectMany(x => x), + async () => + { + logger.LogInformation("Build Watcher: Building {Targets} of solution {SolutionPath}...", targets, solutionPath); + + var buildResult = await ProcessUtil.RunAsync("dotnet", $"msbuild {solutionPath} -target:{targets}", throwOnError: false, workingDirectory: workingDirectory, cancellationToken: cancellationToken); + + if (buildResult.ExitCode != 0) + { + logger.LogInformation("Build Watcher: Solution build failed with exit code {ExitCode}: \r\n" + buildResult.StandardOutput, buildResult.ExitCode); + } + + return buildResult.ExitCode; + })); + } + + foreach (var project in projectBatch) + { + tasks.Add( + WithRequestCompletion( + project.Value, + async () => + { + logger.LogInformation("Build Watcher: Building project {ProjectPath}...", project.Key); + + var buildResult = await ProcessUtil.RunAsync("dotnet", $"build \"{project.Key}\" /nologo", throwOnError: false, workingDirectory: workingDirectory, cancellationToken: cancellationToken); + + if (buildResult.ExitCode != 0) + { + logger.LogInformation("Build Watcher: Project build failed with exit code {ExitCode}: \r\n" + buildResult.StandardOutput, buildResult.ExitCode); + } + + return buildResult.ExitCode; + })); + } + + logger.LogInformation("Build Watcher: Waiting for builds to complete..."); + + // NOTE: WithRequestCompletion() will trap exceptions so build errors should not bubble up from WhenAll(). + + await Task.WhenAll(tasks); + + logger.LogInformation("Build Watcher: Done with requests; waiting for more..."); + } + } + catch (OperationCanceledException) + { + // NO-OP: Trap exception due to cancellation. + } + catch (Exception ex) + { + logger.LogError(ex, "Build Watcher: Error while processing builds."); + } + + logger.LogInformation("Build Watcher: Done watching."); + } + + private class BuildRequest + { + private readonly TaskCompletionSource _result = new TaskCompletionSource(); + + public BuildRequest(string projectFilePath) + { + ProjectFilePath = projectFilePath; + } + + public string ProjectFilePath { get; } + + public Task Task => _result.Task; + + public void Complete(int exitCode) + { + _result.TrySetResult(exitCode); + } + + public void Complete(Exception ex) + { + _result.TrySetException(ex); + } + } + } +} diff --git a/src/Microsoft.Tye.Hosting/Model/Application.cs b/src/Microsoft.Tye.Hosting/Model/Application.cs index 2a394a136..97f36e65d 100644 --- a/src/Microsoft.Tye.Hosting/Model/Application.cs +++ b/src/Microsoft.Tye.Hosting/Model/Application.cs @@ -39,6 +39,7 @@ public Application(string name, FileInfo source, int? dashboardPort, Dictionary< public Dictionary Items { get; } = new Dictionary(); public string? Network { get; set; } + public string? BuildSolution { get; set; } public void PopulateEnvironment(Service service, Action set, string defaultHost = "localhost") { diff --git a/src/Microsoft.Tye.Hosting/ProcessRunner.cs b/src/Microsoft.Tye.Hosting/ProcessRunner.cs index d0aa09a23..4dc8f1248 100644 --- a/src/Microsoft.Tye.Hosting/ProcessRunner.cs +++ b/src/Microsoft.Tye.Hosting/ProcessRunner.cs @@ -26,23 +26,31 @@ public class ProcessRunner : IApplicationProcessor private readonly ProcessRunnerOptions _options; private readonly ReplicaRegistry _replicaRegistry; + private readonly BuildWatcher _watchBuilderWorker; + public ProcessRunner(ILogger logger, ReplicaRegistry replicaRegistry, ProcessRunnerOptions options) { _logger = logger; _replicaRegistry = replicaRegistry; _options = options; + + _watchBuilderWorker = new BuildWatcher(logger); } public async Task StartAsync(Application application) { await PurgeFromPreviousRun(); + await _watchBuilderWorker.StartAsync(application.BuildSolution, application.ContextDirectory); + await BuildAndRunProjects(application); } - public Task StopAsync(Application application) + public async Task StopAsync(Application application) { - return KillRunningProcesses(application.Services); + await _watchBuilderWorker.StopAsync(); + + await KillRunningProcesses(application.Services); } private async Task BuildAndRunProjects(Application application) @@ -358,12 +366,9 @@ async Task RunApplicationAsync(IEnumerable<(int ExternalPort, int Port, string? { if (service.Description.RunInfo is ProjectRunInfo) { - var buildResult = await ProcessUtil.RunAsync("dotnet", $"build \"{service.Status.ProjectFilePath}\" /nologo", throwOnError: false, workingDirectory: application.ContextDirectory); - if (buildResult.ExitCode != 0) - { - _logger.LogInformation("Building projects failed with exit code {ExitCode}: \r\n" + buildResult.StandardOutput, buildResult.ExitCode); - } - return buildResult.ExitCode; + var exitCode = await _watchBuilderWorker!.BuildProjectFileAsync(service.Status.ProjectFilePath!); + _logger.LogInformation($"Built {service.Status.ProjectFilePath} with exit code {exitCode}"); + return exitCode; } return 0; diff --git a/src/schema/tye-schema.json b/src/schema/tye-schema.json index f7fcf9da1..23dc0a895 100644 --- a/src/schema/tye-schema.json +++ b/src/schema/tye-schema.json @@ -45,6 +45,10 @@ "$ref": "#/definitions/extension" } }, + "solution": { + "description": "Indicates the solution file (.sln) or filter (.slnf) to use when building project-based services in watch mode. If omitted, those services will be built individually. Specifying the solution [filter] can help reduce repeated builds of shared libraries when in watch mode.", + "type": "string" + }, "services": { "description": "The application's services.", "type": "array", diff --git a/src/tye/ApplicationBuilderExtensions.cs b/src/tye/ApplicationBuilderExtensions.cs index 0eef6831a..6f1ff5c45 100644 --- a/src/tye/ApplicationBuilderExtensions.cs +++ b/src/tye/ApplicationBuilderExtensions.cs @@ -221,7 +221,11 @@ public static Application ToHostingApplication(this ApplicationBuilder applicati services.Add(ingress.Name, new Service(description, ServiceSource.Host)); } - return new Application(application.Name, application.Source, application.DashboardPort, services, application.ContainerEngine) { Network = application.Network }; + return new Application(application.Name, application.Source, application.DashboardPort, services, application.ContainerEngine) + { + Network = application.Network, + BuildSolution = application.BuildSolution + }; } public static Tye.Hosting.Model.EnvironmentVariable ToHostingEnvironmentVariable(this EnvironmentVariableBuilder builder)