From 69118b7890a18f788173d111308ee17ca2414a13 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Wed, 1 Apr 2020 09:26:06 -0700 Subject: [PATCH] Add support for container networking (#278) * Add support for container networking - This adds all containers in the tye.yaml to the same container network. - Container networking only works with a single replica today since the containers are named after their replicas. - Use the container host name and port when injecting env variables. This handles the replica case by falling back to the host ip and port. - This cleans up container to container communication when migrating docker-compose files. - Don't override assign container port if already set. - Remove StopAsync from TyeHost and exposed DisposeAsync. This patterns removes common clean up and hanging issues that occur. - Cleaned up run tests to use similar code to run, clean up and capture logs. * Move where we handle errors while shutting down the host * Fixed project -> container networking issue - Only use service name as the host name if both target and source re containers * Formatting... * Add service definition to the logs * Added env variables to disable console colors for process run * Hit the backend before the frontend * Small cleanup - Update docs to use host name for redis cli - Show docker network and network alias in the API --- docs/redis.md | 2 +- samples/mongo-sample/tye.yaml | 21 ++ samples/redis/tye.yaml | 8 + .../Dashboard/Pages/Index.razor | 4 +- src/Microsoft.Tye.Hosting/DockerRunner.cs | 81 ++++- src/Microsoft.Tye.Hosting/HttpProxyService.cs | 12 +- .../Model/Application.cs | 24 +- .../Model/DockerStatus.cs | 4 + .../Model/V1/V1ReplicaStatus.cs | 2 + src/Microsoft.Tye.Hosting/PortAssigner.cs | 5 +- src/Microsoft.Tye.Hosting/ProcessRunner.cs | 3 + src/Microsoft.Tye.Hosting/TyeDashboardApi.cs | 2 + src/Microsoft.Tye.Hosting/TyeHost.cs | 49 +-- src/tye/Program.RunCommand.cs | 2 +- test/E2ETest/TestHelpers.cs | 5 - test/E2ETest/TyePurgeTests.cs | 74 ++-- test/E2ETest/TyeRunTests.cs | 339 +++++++----------- 17 files changed, 341 insertions(+), 296 deletions(-) create mode 100644 samples/mongo-sample/tye.yaml create mode 100644 samples/redis/tye.yaml diff --git a/docs/redis.md b/docs/redis.md index 5bb73b29..3c506a7c 100644 --- a/docs/redis.md +++ b/docs/redis.md @@ -95,7 +95,7 @@ We just showed how `tye` makes it easier to communicate between 2 applications r - port: 6379 - name: redis-cli image: redis - args: "redis-cli -h host.docker.internal MONITOR" + args: "redis-cli -h redis MONITOR" ``` We've added 2 services to the `tye.yaml` file. The `redis` service itself and a `redis-cli` service that we will use to watch the data being sent to and retrieved from redis. diff --git a/samples/mongo-sample/tye.yaml b/samples/mongo-sample/tye.yaml new file mode 100644 index 00000000..33c0435e --- /dev/null +++ b/samples/mongo-sample/tye.yaml @@ -0,0 +1,21 @@ +services: +- name: mongo + image: mongo + env: + - name: ME_CONFIG_MONGODB_ADMINUSERNAME + value: root + - name: ME_CONFIG_MONGODB_ADMINPASSWORD + value: example + bindings: + - port: 27017 +- name: mongo-express + image: mongo-express + bindings: + - port: 8081 + containerPort: 8081 + protocol: http + env: + - name: ME_CONFIG_MONGODB_ADMINUSERNAME + value: root + - name: ME_CONFIG_MONGODB_ADMINPASSWORD + value: example \ No newline at end of file diff --git a/samples/redis/tye.yaml b/samples/redis/tye.yaml new file mode 100644 index 00000000..e914dc93 --- /dev/null +++ b/samples/redis/tye.yaml @@ -0,0 +1,8 @@ +services: + - name: redis + image: redis + bindings: + - port: 6379 + - name: redis-cli + image: redis + args: "redis-cli -h redis MONITOR" \ No newline at end of file diff --git a/src/Microsoft.Tye.Hosting/Dashboard/Pages/Index.razor b/src/Microsoft.Tye.Hosting/Dashboard/Pages/Index.razor index 3b218af8..53a9bd47 100644 --- a/src/Microsoft.Tye.Hosting/Dashboard/Pages/Index.razor +++ b/src/Microsoft.Tye.Hosting/Dashboard/Pages/Index.razor @@ -42,7 +42,7 @@ { if (b.Port != null) { - if (b.Protocol == null || b.Protocol == "http" || b.Protocol == "https") + if (b.Protocol == "http" || b.Protocol == "https") { var url = GetUrl(b); @url @@ -79,7 +79,7 @@ string GetUrl(ServiceBinding b) { - return $"{(b.Protocol ?? "http")}://{b.Host ?? "localhost"}:{b.Port}"; + return $"{(b.Protocol ?? "tcp")}://{b.Host ?? "localhost"}:{b.Port}"; } protected override void OnInitialized() diff --git a/src/Microsoft.Tye.Hosting/DockerRunner.cs b/src/Microsoft.Tye.Hosting/DockerRunner.cs index a07d4041..452bbc13 100644 --- a/src/Microsoft.Tye.Hosting/DockerRunner.cs +++ b/src/Microsoft.Tye.Hosting/DockerRunner.cs @@ -34,17 +34,53 @@ namespace Microsoft.Tye.Hosting { await PurgeFromPreviousRun(); - var tasks = new Task[application.Services.Count]; - var index = 0; + var containers = new List(); + foreach (var s in application.Services) { - tasks[index++] = s.Value.Description.RunInfo is DockerRunInfo docker ? StartContainerAsync(application, s.Value, docker) : Task.CompletedTask; + if (s.Value.Description.RunInfo is DockerRunInfo) + { + containers.Add(s.Value); + } + } + + if (containers.Count == 0) + { + return; + } + + string? dockerNetwork = null; + + // We're going to be making containers, only make a network if we have more than one (we assume they'll need to talk) + if (containers.Count > 1) + { + dockerNetwork = "tye_network_" + Guid.NewGuid().ToString().Substring(0, 10); + + application.Items["dockerNetwork"] = dockerNetwork; + + _logger.LogInformation("Creating docker network {Network}", dockerNetwork); + + var command = $"network create --driver bridge {dockerNetwork}"; + + _logger.LogInformation("Running docker command {Command}", command); + + await ProcessUtil.RunAsync("docker", command); + } + + var tasks = new Task[containers.Count]; + var index = 0; + + foreach (var s in containers) + { + var docker = (DockerRunInfo)s.Description.RunInfo!; + + tasks[index++] = StartContainerAsync(application, s, docker, dockerNetwork); } await Task.WhenAll(tasks); } - public Task StopAsync(Application application) + public async Task StopAsync(Application application) { var services = application.Services; @@ -56,10 +92,22 @@ namespace Microsoft.Tye.Hosting tasks[index++] = StopContainerAsync(state); } - return Task.WhenAll(tasks); + await Task.WhenAll(tasks); + + if (application.Items.TryGetValue("dockerNetwork", out var dockerNetwork)) + { + _logger.LogInformation("Removing docker network {Network}", dockerNetwork); + + var command = $"network rm {dockerNetwork}"; + + _logger.LogInformation("Running docker command {Command}", command); + + // Clean up the network we created + await ProcessUtil.RunAsync("docker", command, throwOnError: false); + } } - private async Task StartContainerAsync(Application application, Service service, DockerRunInfo docker) + private async Task StartContainerAsync(Application application, Service service, DockerRunInfo docker, string? dockerNetwork) { var serviceDescription = service.Description; var environmentArguments = ""; @@ -167,9 +215,10 @@ namespace Microsoft.Tye.Hosting 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}"); + service.Logs.OnNext($"[{replica}]: docker {command}"); status.DockerCommand = command; + status.DockerNetwork = dockerNetwork; WriteReplicaToStore(replica); var result = await ProcessUtil.RunAsync( @@ -208,6 +257,24 @@ namespace Microsoft.Tye.Hosting _logger.LogInformation("Running container {ContainerName} with ID {ContainerId}", replica, shortContainerId); + if (!string.IsNullOrEmpty(dockerNetwork)) + { + // If this is the only replica then the network alias is the service name + var alias = serviceDescription.Replicas == 1 ? serviceDescription.Name : replica; + + status.DockerNetworkAlias = alias; + + var networkCommand = $"network connect {dockerNetwork} {replica} --alias {alias}"; + + service.Logs.OnNext($"[{replica}]: docker {networkCommand}"); + + _logger.LogInformation("Running docker command {Command}", networkCommand); + + result = await ProcessUtil.RunAsync("docker", networkCommand); + + PrintStdOutAndErr(service, replica, result); + } + service.ReplicaEvents.OnNext(new ReplicaEvent(ReplicaState.Started, status)); _logger.LogInformation("Collecting docker logs for {ContainerName}.", replica); diff --git a/src/Microsoft.Tye.Hosting/HttpProxyService.cs b/src/Microsoft.Tye.Hosting/HttpProxyService.cs index 6834f9d2..8148ae73 100644 --- a/src/Microsoft.Tye.Hosting/HttpProxyService.cs +++ b/src/Microsoft.Tye.Hosting/HttpProxyService.cs @@ -154,12 +154,14 @@ namespace Microsoft.Tye.Hosting conventions.WithDisplayName(rule.Service); } - } - } - foreach (var app in _webApplications) - { - await app.StartAsync(); + await webApp.StartAsync(); + + foreach (var replica in service.Replicas) + { + service.ReplicaEvents.OnNext(new ReplicaEvent(ReplicaState.Started, replica.Value)); + } + } } } diff --git a/src/Microsoft.Tye.Hosting/Model/Application.cs b/src/Microsoft.Tye.Hosting/Model/Application.cs index 668427aa..0811f0f7 100644 --- a/src/Microsoft.Tye.Hosting/Model/Application.cs +++ b/src/Microsoft.Tye.Hosting/Model/Application.cs @@ -24,6 +24,8 @@ namespace Microsoft.Tye.Hosting.Model public Dictionary Services { get; } + public Dictionary Items { get; } = new Dictionary(); + public void PopulateEnvironment(Service service, Action set, string defaultHost = "localhost") { if (service.Description.Configuration != null) @@ -76,8 +78,9 @@ namespace Microsoft.Tye.Hosting.Model throw new InvalidOperationException($"Unable to resolve the desired value '{source.Kind}' from binding '{source.Binding}' for service '{source.Service}'."); } - void SetBinding(string serviceName, ServiceBinding b) + void SetBinding(ServiceDescription targetService, ServiceBinding b) { + var serviceName = targetService.Name.ToUpper(); var configName = ""; var envName = ""; @@ -107,12 +110,21 @@ namespace Microsoft.Tye.Hosting.Model if (b.Port != null) { - set($"SERVICE__{configName}__PORT", b.Port.Value.ToString()); - set($"{envName}_SERVICE_PORT", b.Port.Value.ToString()); + var port = (service.Description.RunInfo is DockerRunInfo && + targetService.RunInfo is DockerRunInfo && + targetService.Replicas == 1) ? b.ContainerPort ?? b.Port.Value : b.Port.Value; + + set($"SERVICE__{configName}__PORT", port.ToString()); + set($"{envName}_SERVICE_PORT", port.ToString()); } - set($"SERVICE__{configName}__HOST", b.Host ?? defaultHost); - set($"{envName}_SERVICE_HOST", b.Host ?? defaultHost); + // Use the container name as the host name if there's a single replica (current limitation) + var host = b.Host ?? (service.Description.RunInfo is DockerRunInfo && + targetService.RunInfo is DockerRunInfo && + targetService.Replicas == 1 ? targetService.Name : defaultHost); + + set($"SERVICE__{configName}__HOST", host); + set($"{envName}_SERVICE_HOST", host); } // Inject dependency information @@ -120,7 +132,7 @@ namespace Microsoft.Tye.Hosting.Model { foreach (var b in s.Description.Bindings) { - SetBinding(s.Description.Name.ToUpper(), b); + SetBinding(s.Description, b); } } } diff --git a/src/Microsoft.Tye.Hosting/Model/DockerStatus.cs b/src/Microsoft.Tye.Hosting/Model/DockerStatus.cs index a4a93075..c9688680 100644 --- a/src/Microsoft.Tye.Hosting/Model/DockerStatus.cs +++ b/src/Microsoft.Tye.Hosting/Model/DockerStatus.cs @@ -12,6 +12,10 @@ namespace Microsoft.Tye.Hosting.Model public string? DockerCommand { get; set; } + public string? DockerNetwork { get; set; } + + public string? DockerNetworkAlias { get; set; } + public string? ContainerId { get; set; } } } diff --git a/src/Microsoft.Tye.Hosting/Model/V1/V1ReplicaStatus.cs b/src/Microsoft.Tye.Hosting/Model/V1/V1ReplicaStatus.cs index 6617a49d..61e3861a 100644 --- a/src/Microsoft.Tye.Hosting/Model/V1/V1ReplicaStatus.cs +++ b/src/Microsoft.Tye.Hosting/Model/V1/V1ReplicaStatus.cs @@ -11,6 +11,8 @@ namespace Microsoft.Tye.Hosting.Model.V1 { public string? DockerCommand { get; set; } public string? ContainerId { get; set; } + public string? DockerNetwork { get; set; } + public string? DockerNetworkAlias { get; set; } public string? Name { get; set; } public IEnumerable? Ports { get; set; } public int? ExitCode { get; set; } diff --git a/src/Microsoft.Tye.Hosting/PortAssigner.cs b/src/Microsoft.Tye.Hosting/PortAssigner.cs index a16e85bb..3ef5b0af 100644 --- a/src/Microsoft.Tye.Hosting/PortAssigner.cs +++ b/src/Microsoft.Tye.Hosting/PortAssigner.cs @@ -84,14 +84,13 @@ namespace Microsoft.Tye.Hosting // Default the first http and https port to 80 and 443 if (httpBinding != null) { - httpBinding.ContainerPort = 80; + httpBinding.ContainerPort ??= 80; } if (httpsBinding != null) { - httpsBinding.ContainerPort = 443; + httpsBinding.ContainerPort ??= 443; } - } return Task.CompletedTask; diff --git a/src/Microsoft.Tye.Hosting/ProcessRunner.cs b/src/Microsoft.Tye.Hosting/ProcessRunner.cs index 8fda8639..4842b68a 100644 --- a/src/Microsoft.Tye.Hosting/ProcessRunner.cs +++ b/src/Microsoft.Tye.Hosting/ProcessRunner.cs @@ -129,6 +129,9 @@ namespace Microsoft.Tye.Hosting // Default to development environment ["DOTNET_ENVIRONMENT"] = "Development", ["ASPNETCORE_ENVIRONMENT"] = "Development", + // Remove the color codes from the console output + ["DOTNET_LOGGING__CONSOLE__DISABLECOLORS"] = "true", + ["ASPNETCORE_LOGGING__CONSOLE__DISABLECOLORS"] = "true" }; // Set up environment variables to use the version of dotnet we're using to run diff --git a/src/Microsoft.Tye.Hosting/TyeDashboardApi.cs b/src/Microsoft.Tye.Hosting/TyeDashboardApi.cs index 84c66651..caf7780c 100644 --- a/src/Microsoft.Tye.Hosting/TyeDashboardApi.cs +++ b/src/Microsoft.Tye.Hosting/TyeDashboardApi.cs @@ -185,6 +185,8 @@ namespace Microsoft.Tye.Hosting { replicaStatus.DockerCommand = dockerStatus.DockerCommand; replicaStatus.ContainerId = dockerStatus.ContainerId; + replicaStatus.DockerNetwork = dockerStatus.DockerNetwork; + replicaStatus.DockerNetworkAlias = dockerStatus.DockerNetworkAlias; } } diff --git a/src/Microsoft.Tye.Hosting/TyeHost.cs b/src/Microsoft.Tye.Hosting/TyeHost.cs index 1bc84d06..73fb033c 100644 --- a/src/Microsoft.Tye.Hosting/TyeHost.cs +++ b/src/Microsoft.Tye.Hosting/TyeHost.cs @@ -24,7 +24,7 @@ using Serilog.Filters; namespace Microsoft.Tye.Hosting { - public class TyeHost : IDisposable + public class TyeHost : IAsyncDisposable { private const int DefaultPort = 8000; private const int AutodetectPort = 0; @@ -101,25 +101,6 @@ namespace Microsoft.Tye.Hosting return app; } - public async Task StopAsync() - { - try - { - if (_processor != null) - { - await _processor.StopAsync(_application); - } - } - finally - { - if (DashboardWebApplication != null) - { - // Stop the host after everything else has been shutdown - await DashboardWebApplication.StopAsync(); - } - } - } - private static WebApplication BuildWebApplication( Application application, string[] args, @@ -292,8 +273,34 @@ namespace Microsoft.Tye.Hosting return new AggregateApplicationProcessor(processors); } - public void Dispose() + private async Task StopAsync() { + try + { + if (_processor != null) + { + await _processor.StopAsync(_application); + } + _processor = null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error while shutting down"); + } + finally + { + if (DashboardWebApplication != null) + { + // Stop the host after everything else has been shutdown + await DashboardWebApplication.StopAsync(); + } + } + } + + public async ValueTask DisposeAsync() + { + await StopAsync(); + _replicaRegistry?.Dispose(); DashboardWebApplication?.Dispose(); } diff --git a/src/tye/Program.RunCommand.cs b/src/tye/Program.RunCommand.cs index 5f42699b..2f0ee040 100644 --- a/src/tye/Program.RunCommand.cs +++ b/src/tye/Program.RunCommand.cs @@ -84,7 +84,7 @@ namespace Microsoft.Tye throw new CommandException($"No services found in \"{application.Source.Name}\""); } - using var host = new TyeHost(application.ToHostingApplication(), args, debug); + await using var host = new TyeHost(application.ToHostingApplication(), args, debug); await host.RunAsync(); }); diff --git a/test/E2ETest/TestHelpers.cs b/test/E2ETest/TestHelpers.cs index 01f3ce63..bc025b34 100644 --- a/test/E2ETest/TestHelpers.cs +++ b/test/E2ETest/TestHelpers.cs @@ -109,11 +109,6 @@ namespace E2ETest await startedTask.Task; } } - catch (TaskCanceledException) - { - await host.StopAsync(); - throw; - } finally { foreach (var observer in servicesStateObserver) diff --git a/test/E2ETest/TyePurgeTests.cs b/test/E2ETest/TyePurgeTests.cs index 86502639..a5c2e1da 100644 --- a/test/E2ETest/TyePurgeTests.cs +++ b/test/E2ETest/TyePurgeTests.cs @@ -49,27 +49,22 @@ namespace E2ETest try { await TestHelpers.StartHostAndWaitForReplicasToStart(host); - try - { - var pids = GetAllAppPids(host.Application); - - Assert.True(Directory.Exists(tyeDir.FullName)); - Assert.Subset(new HashSet(GetAllPids()), new HashSet(pids)); - - await TestHelpers.PurgeHostAndWaitForGivenReplicasToStop(host, - GetAllReplicasNames(host.Application)); - - var runningPids = new HashSet(GetAllPids()); - Assert.True(pids.All(pid => !runningPids.Contains(pid))); - } - finally - { - await host.StopAsync(); - } + + var pids = GetAllAppPids(host.Application); + + Assert.True(Directory.Exists(tyeDir.FullName)); + Assert.Subset(new HashSet(GetAllPids()), new HashSet(pids)); + + await TestHelpers.PurgeHostAndWaitForGivenReplicasToStop(host, + GetAllReplicasNames(host.Application)); + + var runningPids = new HashSet(GetAllPids()); + Assert.True(pids.All(pid => !runningPids.Contains(pid))); + } finally { - host.Dispose(); + await host.DisposeAsync(); Assert.False(Directory.Exists(tyeDir.FullName)); } } @@ -94,33 +89,28 @@ namespace E2ETest try { await TestHelpers.StartHostAndWaitForReplicasToStart(host); - try - { - var pids = GetAllAppPids(host.Application); - var containers = GetAllContainerIds(host.Application); - - Assert.True(Directory.Exists(tyeDir.FullName)); - Assert.Subset(new HashSet(GetAllPids()), new HashSet(pids)); - Assert.Subset(new HashSet(await DockerAssert.GetRunningContainersIdsAsync(_output)), - new HashSet(containers)); - - await TestHelpers.PurgeHostAndWaitForGivenReplicasToStop(host, - GetAllReplicasNames(host.Application)); - - var runningPids = new HashSet(GetAllPids()); - Assert.True(pids.All(pid => !runningPids.Contains(pid))); - var runningContainers = - new HashSet(await DockerAssert.GetRunningContainersIdsAsync(_output)); - Assert.True(containers.All(c => !runningContainers.Contains(c))); - } - finally - { - await host.StopAsync(); - } + + var pids = GetAllAppPids(host.Application); + var containers = GetAllContainerIds(host.Application); + + Assert.True(Directory.Exists(tyeDir.FullName)); + Assert.Subset(new HashSet(GetAllPids()), new HashSet(pids)); + Assert.Subset(new HashSet(await DockerAssert.GetRunningContainersIdsAsync(_output)), + new HashSet(containers)); + + await TestHelpers.PurgeHostAndWaitForGivenReplicasToStop(host, + GetAllReplicasNames(host.Application)); + + var runningPids = new HashSet(GetAllPids()); + Assert.True(pids.All(pid => !runningPids.Contains(pid))); + var runningContainers = + new HashSet(await DockerAssert.GetRunningContainersIdsAsync(_output)); + Assert.True(containers.All(c => !runningContainers.Contains(c))); + } finally { - host.Dispose(); + await host.DisposeAsync(); Assert.False(Directory.Exists(tyeDir.FullName)); } } diff --git a/test/E2ETest/TyeRunTests.cs b/test/E2ETest/TyeRunTests.cs index 1a903ff6..1355b5ef 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.CommandLine.IO; using System.IO; using System.Linq; using System.Net; @@ -11,7 +12,6 @@ using System.Text.Json; using System.Text.Json.Serialization; using System.Threading.Tasks; using Microsoft.Tye; -using Microsoft.Tye.ConfigModel; using Microsoft.Tye.Hosting; using Microsoft.Tye.Hosting.Model; using Microsoft.Tye.Hosting.Model.V1; @@ -52,54 +52,23 @@ namespace E2ETest var projectFile = new FileInfo(Path.Combine(tempDirectory.DirectoryPath, "test-project.csproj")); var outputContext = new OutputContext(_sink, Verbosity.Debug); var application = await ApplicationFactory.CreateAsync(outputContext, projectFile); - using var host = new TyeHost(application.ToHostingApplication(), Array.Empty()) - { - Sink = _sink, - }; - await host.StartAsync(); - try + var handler = new HttpClientHandler { - var handler = new HttpClientHandler - { - ServerCertificateCustomValidationCallback = (a, b, c, d) => true, - AllowAutoRedirect = false - }; + ServerCertificateCustomValidationCallback = (a, b, c, d) => true, + AllowAutoRedirect = false + }; - var client = new HttpClient(new RetryHandler(handler)); + var client = new HttpClient(new RetryHandler(handler)); - // Make sure dashboard and applications are up. - // Dashboard should be hosted in same process. - var dashboardUri = new Uri(host.DashboardWebApplication!.Addresses.First()); - var dashboardString = await client.GetStringAsync($"{dashboardUri}api/v1/services/test-project"); + await RunHostingApplication(application, Array.Empty(), async (app, uri) => + { + var testUri = await GetServiceUrl(client, uri, "test-project"); - var service = JsonSerializer.Deserialize(dashboardString, _options); - var binding = service.Description!.Bindings.Where(b => b.Protocol == "http").Single(); - var uriBackendProcess = new Uri($"{binding.Protocol}://localhost:{binding.Port}"); + var testResponse = await client.GetAsync(testUri); - // This isn't reliable right now because micronetes only guarantees the process starts, not that - // that kestrel started. - try - { - var appResponse = await client.GetAsync(uriBackendProcess); - Assert.Equal(HttpStatusCode.OK, appResponse.StatusCode); - } - finally - { - // If we failed, there's a good chance the service isn't running. Let's get the logs either way and put - // them in the output. - var request = new HttpRequestMessage(HttpMethod.Get, new Uri(dashboardUri, $"/api/v1/logs/{service.Description.Name}")); - var response = await client.SendAsync(request); - var text = await response.Content.ReadAsStringAsync(); - - _output.WriteLine($"Logs for service: {service.Description.Name}"); - _output.WriteLine(text); - } - } - finally - { - await host.StopAsync(); - } + Assert.True(testResponse.IsSuccessStatusCode); + }); } [Fact] @@ -112,31 +81,108 @@ namespace E2ETest var projectFile = new FileInfo(Path.Combine(tempDirectory.DirectoryPath, "tye.yaml")); var outputContext = new OutputContext(_sink, Verbosity.Debug); var application = await ApplicationFactory.CreateAsync(outputContext, projectFile); - using var host = new TyeHost(application.ToHostingApplication(), Array.Empty()) + + var handler = new HttpClientHandler { - Sink = _sink, + ServerCertificateCustomValidationCallback = (a, b, c, d) => true, + AllowAutoRedirect = false }; - await host.StartAsync(); - try + var client = new HttpClient(new RetryHandler(handler)); + + await RunHostingApplication(application, Array.Empty(), async (app, uri) => { - var handler = new HttpClientHandler - { - ServerCertificateCustomValidationCallback = (a, b, c, d) => true, - AllowAutoRedirect = false - }; + var frontendUri = await GetServiceUrl(client, uri, "frontend"); + var backendUri = await GetServiceUrl(client, uri, "backend"); - var client = new HttpClient(new RetryHandler(handler)); + var backendResponse = await client.GetAsync(backendUri); + var frontendResponse = await client.GetAsync(frontendUri); - var dashboardUri = new Uri(host.DashboardWebApplication!.Addresses.First()); + Assert.True(backendResponse.IsSuccessStatusCode); + Assert.True(frontendResponse.IsSuccessStatusCode); + }); + } - await CheckServiceIsUp(host.Application, client, "backend", dashboardUri); - await CheckServiceIsUp(host.Application, client, "frontend", dashboardUri); - } - finally + [ConditionalFact] + [SkipIfDockerNotRunning] + public async Task FrontendBackendRunTestWithDocker() + { + var projectDirectory = new DirectoryInfo(Path.Combine(TestHelpers.GetSolutionRootDirectory("tye"), "samples", "frontend-backend")); + using var tempDirectory = TempDirectory.Create(preferUserDirectoryOnMacOS: true); + DirectoryCopy.Copy(projectDirectory.FullName, tempDirectory.DirectoryPath); + + var projectFile = new FileInfo(Path.Combine(tempDirectory.DirectoryPath, "tye.yaml")); + var outputContext = new OutputContext(_sink, Verbosity.Debug); + var application = await ApplicationFactory.CreateAsync(outputContext, projectFile); + + var handler = new HttpClientHandler { - await host.StopAsync(); - } + ServerCertificateCustomValidationCallback = (a, b, c, d) => true, + AllowAutoRedirect = false + }; + + var client = new HttpClient(new RetryHandler(handler)); + + await RunHostingApplication(application, new[] { "--docker" }, async (app, uri) => + { + // Make sure we're running containers + Assert.True(app.Services.All(s => s.Value.Description.RunInfo is DockerRunInfo)); + + var frontendUri = await GetServiceUrl(client, uri, "frontend"); + var backendUri = await GetServiceUrl(client, uri, "backend"); + + var backendResponse = await client.GetAsync(backendUri); + var frontendResponse = await client.GetAsync(frontendUri); + + Assert.True(backendResponse.IsSuccessStatusCode); + Assert.True(frontendResponse.IsSuccessStatusCode); + }); + } + + [ConditionalFact] + [SkipIfDockerNotRunning] + public async Task FrontendProjectBackendDocker() + { + var projectDirectory = new DirectoryInfo(Path.Combine(TestHelpers.GetSolutionRootDirectory("tye"), "samples", "frontend-backend")); + using var tempDirectory = TempDirectory.Create(preferUserDirectoryOnMacOS: true); + DirectoryCopy.Copy(projectDirectory.FullName, tempDirectory.DirectoryPath); + + var projectFile = new FileInfo(Path.Combine(tempDirectory.DirectoryPath, "tye.yaml")); + var outputContext = new OutputContext(_sink, Verbosity.Debug); + var application = await ApplicationFactory.CreateAsync(outputContext, projectFile); + + // Transform the backend into a docker image for testing + var project = (ProjectServiceBuilder)application.Services.First(s => s.Name == "backend"); + application.Services.Remove(project); + + var outputFileName = project.AssemblyName + ".dll"; + var container = new ContainerServiceBuilder(project.Name, $"mcr.microsoft.com/dotnet/core/sdk:{project.TargetFrameworkVersion}"); + container.Volumes.Add(new VolumeBuilder(project.PublishDir, name: null, target: "/app")); + container.Args = $"dotnet /app/{outputFileName} {project.Args}"; + container.Bindings.AddRange(project.Bindings); + + await ProcessUtil.RunAsync("dotnet", $"publish \"{project.ProjectFile.FullName}\" /nologo", outputDataReceived: _sink.WriteLine, errorDataReceived: _sink.WriteLine); + application.Services.Add(container); + + var handler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = (a, b, c, d) => true, + AllowAutoRedirect = false + }; + + var client = new HttpClient(new RetryHandler(handler)); + + await RunHostingApplication(application, Array.Empty(), async (app, uri) => + { + var frontendUri = await GetServiceUrl(client, uri, "frontend"); + var backendUri = await GetServiceUrl(client, uri, "backend"); + + var backendResponse = await client.GetAsync(backendUri); + var frontendResponse = await client.GetAsync(frontendUri); + + Assert.True(backendResponse.IsSuccessStatusCode); + Assert.True(frontendResponse.IsSuccessStatusCode); + }); } [ConditionalFact] @@ -165,7 +211,7 @@ namespace E2ETest var client = new HttpClient(new RetryHandler(handler)); var args = new[] { "--docker" }; - await RunHostingApplication(application, args, _sink, async serviceApi => + await RunHostingApplication(application, args, async (app, serviceApi) => { var serviceUri = await GetServiceUrl(client, serviceApi, "volume-test"); @@ -180,7 +226,7 @@ namespace E2ETest Assert.Equal("Things saved to the volume!", await client.GetStringAsync(serviceUri)); }); - await RunHostingApplication(application, args, _sink, async serviceApi => + await RunHostingApplication(application, args, async (app, serviceApi) => { var serviceUri = await GetServiceUrl(client, serviceApi, "volume-test"); @@ -223,7 +269,7 @@ namespace E2ETest var client = new HttpClient(new RetryHandler(handler)); var args = new[] { "--docker" }; - await RunHostingApplication(application, args, _sink, async serviceApi => + await RunHostingApplication(application, args, async (app, serviceApi) => { var serviceUri = await GetServiceUrl(client, serviceApi, "volume-test"); @@ -244,10 +290,6 @@ namespace E2ETest var projectFile = new FileInfo(Path.Combine(tempDirectory.DirectoryPath, "tye.yaml")); var outputContext = new OutputContext(_sink, Verbosity.Debug); var application = await ApplicationFactory.CreateAsync(outputContext, projectFile); - using var host = new TyeHost(application.ToHostingApplication(), Array.Empty()) - { - Sink = _sink, - }; var handler = new HttpClientHandler { @@ -255,13 +297,13 @@ namespace E2ETest AllowAutoRedirect = false }; - using var client = new HttpClient(new RetryHandler(handler)); - await host.StartAsync(); - var serviceApi = new Uri(host.DashboardWebApplication!.Addresses.First()); + var client = new HttpClient(new RetryHandler(handler)); - try + await RunHostingApplication(application, Array.Empty(), async (app, uri) => { - var ingressUri = await GetServiceUrl(client, serviceApi, "ingress"); + using var client = new HttpClient(); + + var ingressUri = await GetServiceUrl(client, uri, "ingress"); var responseA = await client.GetAsync(ingressUri + "/A"); var responseB = await client.GetAsync(ingressUri + "/B"); @@ -279,64 +321,7 @@ namespace E2ETest Assert.StartsWith("Hello from Application A", await responseA.Content.ReadAsStringAsync()); Assert.StartsWith("Hello from Application B", await responseB.Content.ReadAsStringAsync()); - } - finally - { - // If we failed, there's a good chance the service isn't running. Let's get the logs either way and put - // them in the output. - foreach (var s in host.Application.Services.Values) - { - var request = new HttpRequestMessage(HttpMethod.Get, new Uri(serviceApi, $"/api/v1/logs/{s.Description.Name}")); - var response = await client.SendAsync(request); - var text = await response.Content.ReadAsStringAsync(); - - _output.WriteLine($"Logs for service: {s.Description.Name}"); - _output.WriteLine(text); - } - - await host.StopAsync(); - } - } - - [ConditionalFact] - [SkipIfDockerNotRunning] - public async Task FrontendBackendRunTestWithDocker() - { - var projectDirectory = new DirectoryInfo(Path.Combine(TestHelpers.GetSolutionRootDirectory("tye"), "samples", "frontend-backend")); - using var tempDirectory = TempDirectory.Create(preferUserDirectoryOnMacOS: true); - DirectoryCopy.Copy(projectDirectory.FullName, tempDirectory.DirectoryPath); - - var projectFile = new FileInfo(Path.Combine(tempDirectory.DirectoryPath, "tye.yaml")); - var outputContext = new OutputContext(_sink, Verbosity.Debug); - var application = await ApplicationFactory.CreateAsync(outputContext, projectFile); - using var host = new TyeHost(application.ToHostingApplication(), new[] { "--docker" }) - { - Sink = _sink, - }; - - await host.StartAsync(); - try - { - // Make sure we're running 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()); - - await CheckServiceIsUp(host.Application, client, "backend", dashboardUri, timeout: TimeSpan.FromSeconds(60)); - await CheckServiceIsUp(host.Application, client, "frontend", dashboardUri, timeout: TimeSpan.FromSeconds(60)); - } - finally - { - await host.StopAsync(); - } + }); } [Fact] @@ -351,110 +336,58 @@ namespace E2ETest // Debug targets can be null if not specified, so make sure calling host.Start does not throw. var outputContext = new OutputContext(_sink, Verbosity.Debug); var application = await ApplicationFactory.CreateAsync(outputContext, projectFile); - using var host = new TyeHost(application.ToHostingApplication(), Array.Empty()) + await using var host = new TyeHost(application.ToHostingApplication(), Array.Empty()) { Sink = _sink, }; await host.StartAsync(); - - await host.StopAsync(); } - private async Task GetServiceUrl(HttpClient client, Uri serviceApi, string serviceName) + private async Task GetServiceUrl(HttpClient client, Uri uri, string serviceName) { - var serviceResult = await client.GetStringAsync($"{serviceApi}api/v1/services/{serviceName}"); + var serviceResult = await client.GetStringAsync($"{uri}api/v1/services/{serviceName}"); var service = JsonSerializer.Deserialize(serviceResult, _options); var binding = service.Description!.Bindings.Where(b => b.Protocol == "http").Single(); return $"{binding.Protocol ?? "http"}://localhost:{binding.Port}"; } - private async Task RunHostingApplication(ApplicationBuilder application, string[] args, TestOutputLogEventSink sink, Func execute) + private async Task RunHostingApplication(ApplicationBuilder application, string[] args, Func execute) { - using var host = new TyeHost(application.ToHostingApplication(), args) + await using var host = new TyeHost(application.ToHostingApplication(), args) { - Sink = sink, + Sink = _sink, }; - await StartHostAndWaitForReplicasToStart(host); - - var serviceApi = new Uri(host.DashboardWebApplication!.Addresses.First()); - try { - await execute(serviceApi!); + await StartHostAndWaitForReplicasToStart(host); + + var uri = new Uri(host.DashboardWebApplication!.Addresses.First()); + + await execute(host.Application, uri!); } finally { - using (var client = new HttpClient()) + if (host.DashboardWebApplication != null) { - // If we failed, there's a good chance the service isn't running. Let's get the logs either way and put - // them in the output. + var uri = new Uri(host.DashboardWebApplication!.Addresses.First()); + + using var client = new HttpClient(); + foreach (var s in host.Application.Services.Values) { - var request = new HttpRequestMessage(HttpMethod.Get, new Uri(serviceApi, $"/api/v1/logs/{s.Description.Name}")); - var response = await client.SendAsync(request); - var text = await response.Content.ReadAsStringAsync(); + var logs = await client.GetStringAsync(new Uri(uri, $"/api/v1/logs/{s.Description.Name}")); _output.WriteLine($"Logs for service: {s.Description.Name}"); - _output.WriteLine(text); - } - } + _output.WriteLine(logs); - await host.StopAsync(); - } - } - - private async Task CheckServiceIsUp(Application application, HttpClient client, string serviceName, Uri dashboardUri, TimeSpan? timeout = default) - { - // make sure backend is up before frontend - var dashboardString = await client.GetStringAsync($"{dashboardUri}api/v1/services/{serviceName}"); - - var service = JsonSerializer.Deserialize(dashboardString, _options); - var binding = service.Description!.Bindings.Where(b => b.Protocol == "http").Single(); - var uriBackendProcess = new Uri($"{binding.Protocol}://localhost:{binding.Port}"); + var description = await client.GetStringAsync(new Uri(uri, $"/api/v1/services/{s.Description.Name}")); - var startTime = DateTime.UtcNow; - try - { - // Wait up until the timeout to see if we can access the service. - // For instance if we have to pull a base-image it can take a while. - while (timeout.HasValue && startTime + timeout.Value > DateTime.UtcNow) - { - try - { - await client.GetAsync(uriBackendProcess); - break; - } - catch (HttpRequestException) - { - await Task.Delay(TimeSpan.FromSeconds(3)); + _output.WriteLine($"Service defintion: {s.Description.Name}"); + _output.WriteLine(description); } } - - var appResponse = await client.GetAsync(uriBackendProcess); - var content = await appResponse.Content.ReadAsStringAsync(); - _output.WriteLine(content); - Assert.Equal(HttpStatusCode.OK, appResponse.StatusCode); - if (serviceName == "frontend") - { - Assert.Matches("Frontend Listening IP: (.+)\n", content); - Assert.Matches("Backend Listening IP: (.+)\n", content); - } - } - finally - { - // If we failed, there's a good chance the service isn't running. Let's get the logs either way and put - // them in the output. - foreach (var s in application.Services.Values) - { - var request = new HttpRequestMessage(HttpMethod.Get, new Uri(dashboardUri, $"/api/v1/logs/{s.Description.Name}")); - var response = await client.SendAsync(request); - var text = await response.Content.ReadAsStringAsync(); - - _output.WriteLine($"Logs for service: {s.Description.Name}"); - _output.WriteLine(text); - } } } }