diff --git a/src/Microsoft.Tye.Core/ApplicationBuilder.cs b/src/Microsoft.Tye.Core/ApplicationBuilder.cs index c14004925..1a1e2f5c0 100644 --- a/src/Microsoft.Tye.Core/ApplicationBuilder.cs +++ b/src/Microsoft.Tye.Core/ApplicationBuilder.cs @@ -9,10 +9,11 @@ namespace Microsoft.Tye { public sealed class ApplicationBuilder { - public ApplicationBuilder(FileInfo source, string name) + public ApplicationBuilder(FileInfo source, string name, ContainerEngine containerEngine) { Source = source; Name = name; + ContainerEngine = containerEngine; } public FileInfo Source { get; set; } @@ -23,6 +24,8 @@ public ApplicationBuilder(FileInfo source, string name) public ContainerRegistry? Registry { get; set; } + public ContainerEngine ContainerEngine { get; set; } + public List Extensions { get; } = new List(); public List Services { get; } = new List(); diff --git a/src/Microsoft.Tye.Core/ApplicationFactory.cs b/src/Microsoft.Tye.Core/ApplicationFactory.cs index 476bdcb14..ac083f941 100644 --- a/src/Microsoft.Tye.Core/ApplicationFactory.cs +++ b/src/Microsoft.Tye.Core/ApplicationFactory.cs @@ -30,7 +30,7 @@ public static async Task CreateAsync(OutputContext output, F var rootConfig = ConfigFactory.FromFile(source); rootConfig.Validate(); - var root = new ApplicationBuilder(source, rootConfig.Name!) + var root = new ApplicationBuilder(source, rootConfig.Name!, new ContainerEngine(rootConfig.ContainerEngineType)) { Namespace = rootConfig.Namespace }; diff --git a/src/Microsoft.Tye.Core/BuildDockerImageStep.cs b/src/Microsoft.Tye.Core/BuildDockerImageStep.cs index ceb0431c0..42511e551 100644 --- a/src/Microsoft.Tye.Core/BuildDockerImageStep.cs +++ b/src/Microsoft.Tye.Core/BuildDockerImageStep.cs @@ -25,14 +25,9 @@ public override async Task ExecuteAsync(OutputContext output, ApplicationBuilder return; } - if (!await DockerDetector.Instance.IsDockerInstalled.Value) + if (!application.ContainerEngine.IsUsable(out string? unusableReason)) { - throw new CommandException($"Cannot generate a docker image for '{service.Name}' because docker is not installed."); - } - - if (!await DockerDetector.Instance.IsDockerConnectedToDaemon.Value) - { - throw new CommandException($"Cannot generate a docker image for '{service.Name}' because docker is not running."); + throw new CommandException($"Cannot generate a docker image for '{service.Name}' because {unusableReason}."); } if (project is DotnetProjectServiceBuilder dotnetProject) diff --git a/src/Microsoft.Tye.Core/ConfigModel/ConfigApplication.cs b/src/Microsoft.Tye.Core/ConfigModel/ConfigApplication.cs index 8c80683b8..c95854008 100644 --- a/src/Microsoft.Tye.Core/ConfigModel/ConfigApplication.cs +++ b/src/Microsoft.Tye.Core/ConfigModel/ConfigApplication.cs @@ -28,6 +28,8 @@ public class ConfigApplication public string? Registry { get; set; } + public ContainerEngineType? ContainerEngineType { get; set; } + public string? Network { get; set; } public List> Extensions { get; set; } = new List>(); diff --git a/src/Microsoft.Tye.Core/ConfigModel/ContainerEngineType.cs b/src/Microsoft.Tye.Core/ConfigModel/ContainerEngineType.cs new file mode 100644 index 000000000..ec7fce2f3 --- /dev/null +++ b/src/Microsoft.Tye.Core/ConfigModel/ContainerEngineType.cs @@ -0,0 +1,12 @@ +// 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. + +namespace Microsoft.Tye.ConfigModel +{ + public enum ContainerEngineType + { + Docker, + Podman + } +} diff --git a/src/Microsoft.Tye.Core/ContainerEngine.cs b/src/Microsoft.Tye.Core/ContainerEngine.cs new file mode 100644 index 000000000..2351a3805 --- /dev/null +++ b/src/Microsoft.Tye.Core/ContainerEngine.cs @@ -0,0 +1,181 @@ +// 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.Net; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Tye.ConfigModel; + +namespace Microsoft.Tye +{ + public class ContainerEngine + { + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10); + + // Used by tests: + public static ContainerEngine? s_default; + public static ContainerEngine Default + => (s_default ??= new ContainerEngine(default)); + + private bool _isUsable { get; } + private string? _unusableReason; + private bool _isPodman; + private string? _containerHost; + private string _aspnetUrlsHost; + + public string AspNetUrlsHost => _aspnetUrlsHost; + public string? ContainerHost => _containerHost; + + public Task ExecuteAsync( + string args, + string? workingDir = null, + Action? stdOut = null, + Action? stdErr = null, + params (string key, string value)[] environmentVariables) + => ProcessUtil.ExecuteAsync(CommandName, args, workingDir, stdOut, stdErr, environmentVariables); + + public Task RunAsync( + string arguments, + string? workingDirectory = null, + bool throwOnError = true, + IDictionary? environmentVariables = null, + Action? outputDataReceived = null, + Action? errorDataReceived = null, + Action? onStart = null, + Action? onStop = null, + CancellationToken cancellationToken = default) + => ProcessUtil.RunAsync(CommandName, arguments, workingDirectory, throwOnError, environmentVariables, + outputDataReceived, errorDataReceived, onStart, onStop, cancellationToken); + + private string CommandName + { + get + { + if (!_isUsable) + { + throw new InvalidOperationException($"Container engine is not usable: {_unusableReason}"); + } + return _isPodman ? "podman" : "docker"; + } + } + + public bool IsUsable(out string? unusableReason) + { + unusableReason = _unusableReason; + return _isUsable; + } + + public ContainerEngine(ContainerEngineType? containerEngine) + { + _isUsable = true; + _aspnetUrlsHost = "localhost"; + if ((!containerEngine.HasValue || containerEngine == ContainerEngineType.Podman) && + TryUsePodman(ref _unusableReason, ref _containerHost)) + { + _isPodman = true; + return; + } + if ((!containerEngine.HasValue || containerEngine == ContainerEngineType.Docker) && + TryUseDocker(ref _unusableReason, ref _containerHost, ref _aspnetUrlsHost)) + { + return; + } + _isUsable = false; + _unusableReason = "container engine is not installed."; + } + + private static bool TryUsePodman(ref string? unusableReason, ref string? containerHost) + { + ProcessResult result; + try + { + result = ProcessUtil.RunAsync("podman", "version -f \"{{ .Client.Version }}\"", throwOnError: false, cancellationToken: new CancellationTokenSource(Timeout).Token).Result; + } + catch + { + return false; + } + + if (result.ExitCode != 0) + { + unusableReason = $"podman version exited with {result.ExitCode}. Standard error: \"{result.StandardError}\"."; + return true; + } + + Version minVersion = new Version(3, 1); + if (Version.TryParse(result.StandardOutput, out Version? version) && + version < minVersion) + { + unusableReason = $"podman version '{result.StandardOutput}' is less than the required '{minVersion}'."; + return true; + } + + // Check if podman is configured to allow containers to access host services. + bool hostLoopbackEnabled = false; + string containersConfPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData, Environment.SpecialFolderOption.DoNotVerify), + "containers/containers.conf"); + string[] containersConf = File.Exists(containersConfPath) ? File.ReadAllLines(containersConfPath) : Array.Empty(); + // Poor man's TOML parsing. + foreach (var line in containersConf) + { + string trimmed = line.Replace(" ", ""); + if (trimmed.StartsWith("network_cmd_options=", StringComparison.InvariantCultureIgnoreCase) && + trimmed.Contains("\"allow_host_loopback=true\"")) + { + hostLoopbackEnabled = true; + break; + } + } + if (hostLoopbackEnabled) + { + containerHost = "10.0.2.2"; + } + return true; + } + + private static bool TryUseDocker(ref string? unusableReason, ref string? containerHost, ref string aspnetUrlsHost) + { + ProcessResult result; + try + { + result = ProcessUtil.RunAsync("docker", "version", throwOnError: false, cancellationToken: new CancellationTokenSource(Timeout).Token).Result; + } + catch + { + return false; + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + // See: https://github.com/docker/for-linux/issues/264 + // + // host.docker.internal is making it's way into linux docker but doesn't work yet + // instead we use the machine IP + var addresses = Dns.GetHostAddresses(Dns.GetHostName()); + containerHost = addresses[0].ToString(); + + // We need to bind to all interfaces on linux since the container -> host communication won't work + // if we use the IP address to reach out of the host. This works fine on osx and windows + // but doesn't work on linux. + aspnetUrlsHost = "*"; + } + else + { + containerHost = "host.docker.internal"; + } + + if (result.ExitCode != 0) + { + unusableReason = "docker is not connected."; + } + + return true; + } + } +} diff --git a/src/Microsoft.Tye.Core/DeployApplicationKubernetesManifestStep.cs b/src/Microsoft.Tye.Core/DeployApplicationKubernetesManifestStep.cs index 89a2a5e49..237940362 100644 --- a/src/Microsoft.Tye.Core/DeployApplicationKubernetesManifestStep.cs +++ b/src/Microsoft.Tye.Core/DeployApplicationKubernetesManifestStep.cs @@ -46,7 +46,7 @@ public override async Task ExecuteAsync(OutputContext output, ApplicationBuilder output.WriteDebugLine($"Running 'kubectl apply' in ${ns}"); output.WriteCommandLine("kubectl", $"apply -f \"{tempFile.FilePath}\""); var capture = output.Capture(); - var exitCode = await Process.ExecuteAsync( + var exitCode = await ProcessUtil.ExecuteAsync( $"kubectl", $"apply -f \"{tempFile.FilePath}\"", System.Environment.CurrentDirectory, @@ -81,7 +81,7 @@ public override async Task ExecuteAsync(OutputContext output, ApplicationBuilder var retries = 0; while (!done && retries < 60) { - var ingressExitCode = await Process.ExecuteAsync( + var ingressExitCode = await ProcessUtil.ExecuteAsync( "kubectl", $"get ingress {ingress.Name} -o jsonpath='{{..ip}}'", Environment.CurrentDirectory, diff --git a/src/Microsoft.Tye.Core/DockerContainerBuilder.cs b/src/Microsoft.Tye.Core/DockerContainerBuilder.cs index 043ef8147..4c5351975 100644 --- a/src/Microsoft.Tye.Core/DockerContainerBuilder.cs +++ b/src/Microsoft.Tye.Core/DockerContainerBuilder.cs @@ -43,8 +43,7 @@ public static async Task BuildContainerImageFromDockerFileAsync(OutputContext ou output.WriteDebugLine("Running 'docker build'."); output.WriteCommandLine("docker", $"build \"{contextDirectory}\" -t {container.ImageName}:{container.ImageTag} -f \"{dockerFilePath}\""); var capture = output.Capture(); - var exitCode = await Process.ExecuteAsync( - $"docker", + var exitCode = await application.ContainerEngine.ExecuteAsync( $"build \"{contextDirectory}\" -t {container.ImageName}:{container.ImageTag} -f \"{dockerFilePath}\"", new FileInfo(containerService.DockerFile).DirectoryName, stdOut: capture.StdOut, @@ -148,8 +147,7 @@ public static async Task BuildContainerImageAsync(OutputContext output, Applicat output.WriteDebugLine("Running 'docker build'."); output.WriteCommandLine("docker", $"build \"{contextDirectory}\" -t {container.ImageName}:{container.ImageTag} -f \"{dockerFilePath}\""); var capture = output.Capture(); - var exitCode = await Process.ExecuteAsync( - $"docker", + var exitCode = await application.ContainerEngine.ExecuteAsync( $"build \"{contextDirectory}\" -t {container.ImageName}:{container.ImageTag} -f \"{dockerFilePath}\"", project.ProjectFile.DirectoryName, stdOut: capture.StdOut, diff --git a/src/Microsoft.Tye.Core/DockerDetector.cs b/src/Microsoft.Tye.Core/DockerDetector.cs deleted file mode 100644 index 642421ba0..000000000 --- a/src/Microsoft.Tye.Core/DockerDetector.cs +++ /dev/null @@ -1,55 +0,0 @@ -// 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.Threading; -using System.Threading.Tasks; - -namespace Microsoft.Tye -{ - public class DockerDetector - { - private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10); - - public static DockerDetector Instance { get; } = new DockerDetector(); - - private DockerDetector() - { - IsDockerInstalled = new Lazy>(DetectDockerInstalled); - IsDockerConnectedToDaemon = new Lazy>(DetectDockerConnectedToDaemon); - } - - public Lazy> IsDockerInstalled { get; } - - public Lazy> IsDockerConnectedToDaemon { get; } - - private async Task DetectDockerInstalled() - { - try - { - await ProcessUtil.RunAsync("docker", "version", throwOnError: false, cancellationToken: new CancellationTokenSource(Timeout).Token); - return true; - } - catch (Exception) - { - // Unfortunately, process throws - return false; - } - } - - private async Task DetectDockerConnectedToDaemon() - { - try - { - var result = await ProcessUtil.RunAsync("docker", "version", throwOnError: false, cancellationToken: new CancellationTokenSource(Timeout).Token); - return result.ExitCode == 0; - } - catch (Exception) - { - // Unfortunately, process throws - return false; - } - } - } -} diff --git a/src/Microsoft.Tye.Core/DockerPush.cs b/src/Microsoft.Tye.Core/DockerPush.cs index c1c83b249..e1897de37 100644 --- a/src/Microsoft.Tye.Core/DockerPush.cs +++ b/src/Microsoft.Tye.Core/DockerPush.cs @@ -10,7 +10,7 @@ namespace Microsoft.Tye { internal static class DockerPush { - public static async Task ExecuteAsync(OutputContext output, string imageName, string imageTag) + public static async Task ExecuteAsync(OutputContext output, ContainerEngine containerEngine, string imageName, string imageTag) { if (output is null) { @@ -30,8 +30,7 @@ public static async Task ExecuteAsync(OutputContext output, string imageName, st output.WriteDebugLine("Running 'docker push'."); output.WriteCommandLine("docker", $"push {imageName}:{imageTag}"); var capture = output.Capture(); - var exitCode = await Process.ExecuteAsync( - $"docker", + var exitCode = await containerEngine.ExecuteAsync( $"push {imageName}:{imageTag}", stdOut: capture.StdOut, stdErr: capture.StdErr); diff --git a/src/Microsoft.Tye.Core/ProcessUtil.cs b/src/Microsoft.Tye.Core/ProcessUtil.cs index ef77ec448..df1c83550 100644 --- a/src/Microsoft.Tye.Core/ProcessUtil.cs +++ b/src/Microsoft.Tye.Core/ProcessUtil.cs @@ -23,6 +23,17 @@ public static class ProcessUtil private static readonly bool IsWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + public static Task ExecuteAsync( + string command, + string args, + string? workingDir = null, + Action? stdOut = null, + Action? stdErr = null, + params (string key, string value)[] environmentVariables) + { + return System.CommandLine.Invocation.Process.ExecuteAsync(command, args, workingDir, stdOut, stdErr, environmentVariables); + } + public static async Task RunAsync( string filename, string arguments, @@ -109,7 +120,7 @@ public static async Task RunAsync( if (throwOnError && process.ExitCode != 0) { - processLifetimeTask.TrySetException(new InvalidOperationException($"Command {filename} {arguments} returned exit code {process.ExitCode}")); + processLifetimeTask.TrySetException(new InvalidOperationException($"Command {filename} {arguments} returned exit code {process.ExitCode}. Standard error: \"{errorBuilder.ToString()}\"")); } else { diff --git a/src/Microsoft.Tye.Core/PushDockerImageStep.cs b/src/Microsoft.Tye.Core/PushDockerImageStep.cs index a093a9fdf..558ccd654 100644 --- a/src/Microsoft.Tye.Core/PushDockerImageStep.cs +++ b/src/Microsoft.Tye.Core/PushDockerImageStep.cs @@ -27,7 +27,7 @@ public override async Task ExecuteAsync(OutputContext output, ApplicationBuilder foreach (var image in service.Outputs.OfType()) { - await DockerPush.ExecuteAsync(output, image.ImageName, image.ImageTag); + await DockerPush.ExecuteAsync(output, application.ContainerEngine, image.ImageName, image.ImageTag); output.WriteInfoLine($"Pushed docker image: '{image.ImageName}:{image.ImageTag}'"); } } diff --git a/src/Microsoft.Tye.Core/Serialization/ConfigApplicationParser.cs b/src/Microsoft.Tye.Core/Serialization/ConfigApplicationParser.cs index c9a1038c3..7b1f1d5c6 100644 --- a/src/Microsoft.Tye.Core/Serialization/ConfigApplicationParser.cs +++ b/src/Microsoft.Tye.Core/Serialization/ConfigApplicationParser.cs @@ -2,6 +2,7 @@ // 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 Microsoft.Tye.ConfigModel; using YamlDotNet.RepresentationModel; @@ -29,6 +30,21 @@ public static void HandleConfigApplication(YamlMappingNode yamlMappingNode, Conf case "registry": app.Registry = YamlParser.GetScalarValue(key, child.Value); break; + case "containerEngine": + string engine = YamlParser.GetScalarValue(key, child.Value); + if (engine.Equals("docker", StringComparison.InvariantCultureIgnoreCase)) + { + app.ContainerEngineType = ContainerEngineType.Docker; + } + else if (engine.Equals("podman", StringComparison.InvariantCultureIgnoreCase)) + { + app.ContainerEngineType = ContainerEngineType.Podman; + } + else + { + throw new TyeYamlException($"Unknown container engine: \"{engine}\""); + } + break; case "ingress": YamlParser.ThrowIfNotYamlSequence(key, child.Value); ConfigIngressParser.HandleIngress((child.Value as YamlSequenceNode)!, app.Ingress); diff --git a/src/Microsoft.Tye.Core/ValidateIngressStep.cs b/src/Microsoft.Tye.Core/ValidateIngressStep.cs index f4709fb61..78c4b5568 100644 --- a/src/Microsoft.Tye.Core/ValidateIngressStep.cs +++ b/src/Microsoft.Tye.Core/ValidateIngressStep.cs @@ -122,7 +122,7 @@ public override async Task ExecuteAsync(OutputContext output, ApplicationBuilder output.WriteDebugLine($"Running 'minikube addons enable ingress'"); output.WriteCommandLine("minikube", "addon enable ingress"); var capture = output.Capture(); - var exitCode = await Process.ExecuteAsync( + var exitCode = await ProcessUtil.ExecuteAsync( $"minikube", $"addons enable ingress", System.Environment.CurrentDirectory, @@ -149,7 +149,7 @@ public override async Task ExecuteAsync(OutputContext output, ApplicationBuilder output.WriteDebugLine($"Running 'kubectl apply'"); output.WriteCommandLine("kubectl", $"apply -f \"https://aka.ms/tye/ingress/deploy\""); var capture = output.Capture(); - var exitCode = await Process.ExecuteAsync( + var exitCode = await ProcessUtil.ExecuteAsync( $"kubectl", $"apply -f \"https://aka.ms/tye/ingress/deploy\"", System.Environment.CurrentDirectory); diff --git a/src/Microsoft.Tye.Extensions.Configuration/TyeConfigurationExtensions.cs b/src/Microsoft.Tye.Extensions.Configuration/TyeConfigurationExtensions.cs index 284d8a337..2b6d68af1 100644 --- a/src/Microsoft.Tye.Extensions.Configuration/TyeConfigurationExtensions.cs +++ b/src/Microsoft.Tye.Extensions.Configuration/TyeConfigurationExtensions.cs @@ -3,6 +3,8 @@ // See the LICENSE file in the project root for more information. using System; +using System.Net; +using System.Net.Sockets; namespace Microsoft.Extensions.Configuration { @@ -21,6 +23,11 @@ public static class TyeConfigurationExtensions return null; } + if (IPAddress.TryParse(host, out IPAddress address) && address.AddressFamily == AddressFamily.InterNetworkV6) + { + host = "[" + host + "]"; + } + return new Uri(protocol + "://" + host + ":" + port + "/"); } diff --git a/src/Microsoft.Tye.Hosting/DockerImagePuller.cs b/src/Microsoft.Tye.Hosting/DockerImagePuller.cs index 369d7ef2f..5305c016d 100644 --- a/src/Microsoft.Tye.Hosting/DockerImagePuller.cs +++ b/src/Microsoft.Tye.Hosting/DockerImagePuller.cs @@ -36,36 +36,28 @@ public async Task StartAsync(Application application) return; } - if (!await DockerDetector.Instance.IsDockerInstalled.Value) + if (!application.ContainerEngine.IsUsable(out string? unusableReason)) { - _logger.LogError("Unable to detect docker installation. Docker is not installed."); + _logger.LogError($"Unable to pull image: {unusableReason}."); - throw new CommandException("Docker is not installed."); - } - - if (!await DockerDetector.Instance.IsDockerConnectedToDaemon.Value) - { - _logger.LogError("Unable to connect to docker daemon. Docker is not running."); - - throw new CommandException("Docker is not running."); + throw new CommandException($"Unable to pull image: {unusableReason}."); } var tasks = new Task[images.Count]; var index = 0; foreach (var image in images) { - tasks[index++] = PullContainerAsync(image); + tasks[index++] = PullContainerAsync(application, image); } await Task.WhenAll(tasks); } - private async Task PullContainerAsync(string image) + private async Task PullContainerAsync(Application application, string image) { await Task.Yield(); - var result = await ProcessUtil.RunAsync( - "docker", + var result = await application.ContainerEngine.RunAsync( $"images --filter \"reference={image}\" --format \"{{{{.ID}}}}\"", throwOnError: false); @@ -85,8 +77,7 @@ private async Task PullContainerAsync(string image) _logger.LogInformation("Running docker command {command}", command); - result = await ProcessUtil.RunAsync( - "docker", + result = await application.ContainerEngine.RunAsync( command, outputDataReceived: data => _logger.LogInformation("{Image}: " + data, image), errorDataReceived: data => _logger.LogInformation("{Image}: " + data, image), diff --git a/src/Microsoft.Tye.Hosting/DockerRunner.cs b/src/Microsoft.Tye.Hosting/DockerRunner.cs index d6e2ecbd7..0c5969c65 100644 --- a/src/Microsoft.Tye.Hosting/DockerRunner.cs +++ b/src/Microsoft.Tye.Hosting/DockerRunner.cs @@ -33,7 +33,7 @@ public DockerRunner(ILogger logger, ReplicaRegistry replicaRegistry) public async Task StartAsync(Application application) { - await PurgeFromPreviousRun(); + await PurgeFromPreviousRun(application); var containers = new List(); @@ -100,7 +100,7 @@ service.Description.RunInfo is IngressRunInfo || if (!string.IsNullOrEmpty(application.Network)) { - var dockerNetworkResult = await ProcessUtil.RunAsync("docker", $"network ls --filter \"name={application.Network}\" --format \"{{{{.ID}}}}\"", throwOnError: false); + var dockerNetworkResult = await application.ContainerEngine.RunAsync($"network ls --filter \"name={application.Network}\" --format \"{{{{.ID}}}}\"", throwOnError: false); if (dockerNetworkResult.ExitCode != 0) { _logger.LogError("{Network}: Run docker network ls command failed", application.Network); @@ -135,7 +135,7 @@ service.Description.RunInfo is IngressRunInfo || _logger.LogInformation("Running docker command {Command}", command); - var dockerNetworkResult = await ProcessUtil.RunAsync("docker", command, throwOnError: false); + var dockerNetworkResult = await application.ContainerEngine.RunAsync(command, throwOnError: false); if (dockerNetworkResult.ExitCode != 0) { @@ -148,17 +148,12 @@ service.Description.RunInfo is IngressRunInfo || // Stash information outside of the application services application.Items[typeof(DockerApplicationInformation)] = new DockerApplicationInformation(dockerNetwork, proxies); - 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); + StartContainerAsync(application, s, docker, dockerNetwork); } - - await Task.WhenAll(tasks); } public async Task StopAsync(Application application) @@ -195,29 +190,25 @@ public async Task StopAsync(Application application) _logger.LogInformation("Running docker command {Command}", command); // Clean up the network we created - await ProcessUtil.RunAsync("docker", command, throwOnError: false); + await application.ContainerEngine.RunAsync(command, throwOnError: false); } } - private async Task StartContainerAsync(Application application, Service service, DockerRunInfo docker, string? dockerNetwork) + private void StartContainerAsync(Application application, Service service, DockerRunInfo docker, string? dockerNetwork) { var serviceDescription = service.Description; - var environmentArguments = ""; - var volumes = ""; var workingDirectory = docker.WorkingDirectory != null ? $"-w \"{docker.WorkingDirectory}\"" : ""; - var hostname = "host.docker.internal"; - var dockerImage = docker.Image ?? service.Description.Name; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + var hostname = application.ContainerEngine.ContainerHost; + if (hostname == null) { - // See: https://github.com/docker/for-linux/issues/264 - // - // host.docker.internal is making it's way into linux docker but doesn't work yet - // instead we use the machine IP - var addresses = await Dns.GetHostAddressesAsync(Dns.GetHostName()); - hostname = addresses[0].ToString(); + _logger.LogError("Configuration doesn't allow containers to access services on the host."); + + throw new CommandException("Configuration doesn't allow containers to access services on the host."); } + var dockerImage = docker.Image ?? service.Description.Name; + async Task RunDockerContainer(IEnumerable<(int ExternalPort, int Port, int? ContainerPort, string? Protocol)> ports, CancellationToken cancellationToken) { var hasPorts = ports.Any(); @@ -276,17 +267,19 @@ async Task RunDockerContainer(IEnumerable<(int ExternalPort, int Port, int? Cont status.Environment = environment; + var environmentArguments = ""; foreach (var pair in environment) { environmentArguments += $"-e \"{pair.Key}={pair.Value}\" "; } + var volumes = ""; foreach (var volumeMapping in docker.VolumeMappings) { if (volumeMapping.Source != null) { var sourcePath = Path.GetFullPath(Path.Combine(application.ContextDirectory, volumeMapping.Source)); - volumes += $"-v \"{sourcePath}:{volumeMapping.Target}\" "; + volumes += $"-v \"{sourcePath}:{volumeMapping.Target}:{(volumeMapping.ReadOnly ? "ro," : "")}z\" "; } else if (volumeMapping.Name != null) { @@ -294,7 +287,13 @@ async Task RunDockerContainer(IEnumerable<(int ExternalPort, int Port, int? Cont } } - var command = $"run -d {workingDirectory} {volumes} {environmentArguments} {portString} --name {replica} --restart=unless-stopped {dockerImage} {docker.Args ?? ""}"; + var command = $"run -d {workingDirectory} {volumes} {environmentArguments} {portString} --name {replica} --restart=unless-stopped"; + if (!string.IsNullOrEmpty(dockerNetwork)) + { + status.DockerNetworkAlias = docker.NetworkAlias ?? serviceDescription!.Name; + command += $" --network {dockerNetwork} --network-alias {status.DockerNetworkAlias}"; + } + command += $" {dockerImage} {docker.Args ?? ""}"; if (!docker.IsProxy) { @@ -311,8 +310,7 @@ async Task RunDockerContainer(IEnumerable<(int ExternalPort, int Port, int? Cont status.DockerNetwork = dockerNetwork; WriteReplicaToStore(replica); - var result = await ProcessUtil.RunAsync( - "docker", + var result = await application.ContainerEngine.RunAsync( command, throwOnError: false, cancellationToken: cancellationToken, @@ -336,7 +334,7 @@ async Task RunDockerContainer(IEnumerable<(int ExternalPort, int Port, int? Cont while (string.IsNullOrEmpty(containerId)) { // Try to get the ID of the container - result = await ProcessUtil.RunAsync("docker", $"ps --no-trunc -f name={replica} --format " + "{{.ID}}"); + result = await application.ContainerEngine.RunAsync($"ps --no-trunc -f name={replica} --format " + "{{.ID}}"); containerId = result.ExitCode == 0 ? result.StandardOutput.Trim() : null; } @@ -347,21 +345,6 @@ async Task RunDockerContainer(IEnumerable<(int ExternalPort, int Port, int? Cont _logger.LogInformation("Running container {ContainerName} with ID {ContainerId}", replica, shortContainerId); - if (!string.IsNullOrEmpty(dockerNetwork)) - { - status.DockerNetworkAlias = docker.NetworkAlias ?? serviceDescription!.Name; - - var networkCommand = $"network connect {dockerNetwork} {replica} --alias {status.DockerNetworkAlias}"; - - service.Logs.OnNext($"[{replica}]: docker {networkCommand}"); - - _logger.LogInformation("Running docker command {Command}", networkCommand); - - result = await ProcessUtil.RunAsync("docker", networkCommand); - - PrintStdOutAndErr(service, replica, result); - } - var sentStartedEvent = false; while (!cancellationToken.IsCancellationRequested) @@ -369,7 +352,7 @@ async Task RunDockerContainer(IEnumerable<(int ExternalPort, int Port, int? Cont if (sentStartedEvent) { using var restartCts = new CancellationTokenSource(DockerStopTimeout); - result = await ProcessUtil.RunAsync("docker", $"restart {containerId}", throwOnError: false, cancellationToken: restartCts.Token); + result = await application.ContainerEngine.RunAsync($"restart {containerId}", throwOnError: false, cancellationToken: restartCts.Token); if (restartCts.IsCancellationRequested) { @@ -398,7 +381,7 @@ async Task RunDockerContainer(IEnumerable<(int ExternalPort, int Port, int? Cont while (!status.StoppingTokenSource.Token.IsCancellationRequested) { - var logsRes = await ProcessUtil.RunAsync("docker", $"logs -f {containerId}", + var logsRes = await application.ContainerEngine.RunAsync($"logs -f {containerId}", outputDataReceived: data => service.Logs.OnNext($"[{replica}]: {data}"), errorDataReceived: data => service.Logs.OnNext($"[{replica}]: {data}"), throwOnError: false, @@ -435,7 +418,7 @@ async Task RunDockerContainer(IEnumerable<(int ExternalPort, int Port, int? Cont _logger.LogInformation("Stopping container {ContainerName} with ID {ContainerId}", replica, shortContainerId); - result = await ProcessUtil.RunAsync("docker", $"stop {containerId}", throwOnError: false, cancellationToken: timeoutCts.Token); + result = await application.ContainerEngine.RunAsync($"stop {containerId}", throwOnError: false, cancellationToken: timeoutCts.Token); if (timeoutCts.IsCancellationRequested) { @@ -451,7 +434,7 @@ async Task RunDockerContainer(IEnumerable<(int ExternalPort, int Port, int? Cont _logger.LogInformation("Stopped container {ContainerName} with ID {ContainerId} exited with {ExitCode}", replica, shortContainerId, result.ExitCode); - result = await ProcessUtil.RunAsync("docker", $"rm {containerId}", throwOnError: false, cancellationToken: timeoutCts.Token); + result = await application.ContainerEngine.RunAsync($"rm {containerId}", throwOnError: false, cancellationToken: timeoutCts.Token); if (timeoutCts.IsCancellationRequested) { @@ -486,8 +469,7 @@ void Log(string data) arguments.Append($" --build-arg {buildArg.Key}={buildArg.Value}"); } - var dockerBuildResult = await ProcessUtil.RunAsync( - $"docker", + var dockerBuildResult = await application.ContainerEngine.RunAsync( arguments.ToString(), outputDataReceived: Log, errorDataReceived: Log, @@ -550,13 +532,13 @@ async Task BuildAndRunAsync(CancellationToken cancellationToken) service.Items[typeof(DockerInformation)] = dockerInfo; } - private async Task PurgeFromPreviousRun() + private async Task PurgeFromPreviousRun(Application application) { var dockerReplicas = await _replicaRegistry.GetEvents(DockerReplicaStore); foreach (var replica in dockerReplicas) { var container = replica["container"]; - await ProcessUtil.RunAsync("docker", $"rm -f {container}", throwOnError: false); + await application.ContainerEngine.RunAsync($"rm -f {container}", throwOnError: false); _logger.LogInformation("removed container {container} from previous run", container); } diff --git a/src/Microsoft.Tye.Hosting/Model/Application.cs b/src/Microsoft.Tye.Hosting/Model/Application.cs index 7430a0c3f..c91ade50e 100644 --- a/src/Microsoft.Tye.Hosting/Model/Application.cs +++ b/src/Microsoft.Tye.Hosting/Model/Application.cs @@ -12,17 +12,20 @@ namespace Microsoft.Tye.Hosting.Model { public class Application { - public Application(FileInfo source, Dictionary services) + public Application(FileInfo source, Dictionary services, ContainerEngine containerEngine) { Source = source.FullName; ContextDirectory = source.DirectoryName!; Services = services; + ContainerEngine = containerEngine; } public string Source { get; } public string ContextDirectory { get; } + public ContainerEngine ContainerEngine { get; set; } + public Dictionary Services { get; } public Dictionary Items { get; } = new Dictionary(); diff --git a/src/Microsoft.Tye.Hosting/Model/DockerVolume.cs b/src/Microsoft.Tye.Hosting/Model/DockerVolume.cs index 532678ca0..fce0131a2 100644 --- a/src/Microsoft.Tye.Hosting/Model/DockerVolume.cs +++ b/src/Microsoft.Tye.Hosting/Model/DockerVolume.cs @@ -7,15 +7,17 @@ namespace Microsoft.Tye.Hosting.Model { public class DockerVolume { - public DockerVolume(string? source, string? name, string target) + public DockerVolume(string? source, string? name, string target, bool readOnly = false) { Source = source; Name = name; Target = target; + ReadOnly = readOnly; } public string? Name { get; } public string? Source { get; } public string Target { get; } + public bool ReadOnly { get; } } } diff --git a/src/Microsoft.Tye.Hosting/ProcessRunner.cs b/src/Microsoft.Tye.Hosting/ProcessRunner.cs index 706cfadcc..2bec6e9ff 100644 --- a/src/Microsoft.Tye.Hosting/ProcessRunner.cs +++ b/src/Microsoft.Tye.Hosting/ProcessRunner.cs @@ -217,15 +217,10 @@ async Task RunApplicationAsync(IEnumerable<(int ExternalPort, int Port, string? if (hasPorts) { - // We need to bind to all interfaces on linux since the container -> host communication won't work - // if we use the IP address to reach out of the host. This works fine on osx and windows - // but doesn't work on linux. - var host = RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "*" : "localhost"; - // These are the ports that the application should use for binding // 1. Configure ASP.NET Core to bind to those same ports - environment["ASPNETCORE_URLS"] = string.Join(";", ports.Select(p => $"{p.Protocol ?? "http"}://{host}:{p.Port}")); + environment["ASPNETCORE_URLS"] = string.Join(";", ports.Select(p => $"{p.Protocol ?? "http"}://{application.ContainerEngine.AspNetUrlsHost}:{p.Port}")); // Set the HTTPS port for the redirect middleware foreach (var p in ports) diff --git a/src/Microsoft.Tye.Hosting/TransformProjectsIntoContainers.cs b/src/Microsoft.Tye.Hosting/TransformProjectsIntoContainers.cs index 528614812..99225ba30 100644 --- a/src/Microsoft.Tye.Hosting/TransformProjectsIntoContainers.cs +++ b/src/Microsoft.Tye.Hosting/TransformProjectsIntoContainers.cs @@ -87,10 +87,12 @@ private async Task TransformProjectToContainer(Service service, ProjectRunInfo p // This is .NET specific var userSecretStore = GetUserSecretsPathFromSecrets(); + Directory.CreateDirectory(userSecretStore); + if (!string.IsNullOrEmpty(userSecretStore)) { // Map the user secrets on this drive to user secrets - dockerRunInfo.VolumeMappings.Add(new DockerVolume(source: userSecretStore, name: null, target: "/root/.microsoft/usersecrets:ro")); + dockerRunInfo.VolumeMappings.Add(new DockerVolume(source: userSecretStore, name: null, target: "/root/.microsoft/usersecrets", readOnly: true)); } // Default to development environment @@ -116,7 +118,7 @@ private async Task TransformProjectToContainer(Service service, ProjectRunInfo p serviceDescription.Configuration.Add(new EnvironmentVariable("Kestrel__Certificates__Development__Password", certPassword)); // Certificate Path: https://github.com/dotnet/aspnetcore/blob/a9d702624a02ad4ebf593d9bf9c1c69f5702a6f5/src/Servers/Kestrel/Core/src/KestrelConfigurationLoader.cs#L419 - dockerRunInfo.VolumeMappings.Add(new DockerVolume(source: certificateDirectory.DirectoryPath, name: null, target: "/root/.aspnet/https:ro")); + dockerRunInfo.VolumeMappings.Add(new DockerVolume(source: certificateDirectory.DirectoryPath, name: null, target: "/root/.aspnet/https", readOnly: true)); } // Change the project into a container info diff --git a/src/schema/tye-schema.json b/src/schema/tye-schema.json index 688bb2a7e..65060d74f 100644 --- a/src/schema/tye-schema.json +++ b/src/schema/tye-schema.json @@ -13,6 +13,11 @@ "description": "Dockerhub username or hostname of remote registry. Used for tagging images.", "type": "string" }, + "containerEngine": { + "description": "Container engine.", + "type": "string", + "enum": ["docker", "podman"] + }, "namespace": { "description": "The Kubernetes namespace to use.", "type": "string" diff --git a/src/tye/ApplicationBuilderExtensions.cs b/src/tye/ApplicationBuilderExtensions.cs index 815accec0..3015564ec 100644 --- a/src/tye/ApplicationBuilderExtensions.cs +++ b/src/tye/ApplicationBuilderExtensions.cs @@ -213,7 +213,7 @@ public static Application ToHostingApplication(this ApplicationBuilder applicati services.Add(ingress.Name, new Service(description)); } - return new Application(application.Source, services) { Network = application.Network }; + return new Application(application.Source, services, application.ContainerEngine) { Network = application.Network }; } public static Tye.Hosting.Model.EnvironmentVariable ToHostingEnvironmentVariable(this EnvironmentVariableBuilder builder) diff --git a/test/E2ETest/TyeRunTests.cs b/test/E2ETest/TyeRunTests.cs index 873b8b9e5..19c13e635 100644 --- a/test/E2ETest/TyeRunTests.cs +++ b/test/E2ETest/TyeRunTests.cs @@ -9,6 +9,7 @@ using System.Linq; using System.Net; using System.Net.Http; +using System.Runtime.InteropServices; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading.Tasks; @@ -504,7 +505,7 @@ public async Task DockerNamedVolumeTest() }); // Delete the volume - await ProcessUtil.RunAsync("docker", $"volume rm {volumeName}"); + await ContainerEngine.Default.RunAsync($"volume rm {volumeName}"); } [ConditionalFact] @@ -521,7 +522,7 @@ public async Task DockerNetworkAssignmentTest() application.Network = dockerNetwork; // Create the existing network - await ProcessUtil.RunAsync("docker", $"network create {dockerNetwork}"); + await ContainerEngine.Default.RunAsync($"network create {dockerNetwork}"); var handler = new HttpClientHandler { @@ -560,7 +561,7 @@ await RunHostingApplication( finally { // Delete the network - await ProcessUtil.RunAsync("docker", $"network rm {dockerNetwork}"); + await ContainerEngine.Default.RunAsync($"network rm {dockerNetwork}"); } } @@ -771,6 +772,14 @@ public async Task IngressStaticFilesTest() [SkipIfDockerNotRunning] public async Task NginxIngressTest() { + // https://github.com/dotnet/tye/issues/428 + // nginx container fails to start succesfully on non-Windows because it + // can't resolve the upstream hosts. + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return; + } + using var projectDirectory = CopyTestProjectDirectory("nginx-ingress"); var projectFile = new FileInfo(Path.Combine(projectDirectory.DirectoryPath, "tye.yaml")); diff --git a/test/Test.Infrastructure/DockerAssert.cs b/test/Test.Infrastructure/DockerAssert.cs index eb80d332e..5e75e48a5 100644 --- a/test/Test.Infrastructure/DockerAssert.cs +++ b/test/Test.Infrastructure/DockerAssert.cs @@ -10,6 +10,7 @@ using System.Threading.Tasks; using Xunit.Abstractions; using Xunit.Sdk; +using Microsoft.Tye; namespace Test.Infrastructure { @@ -22,8 +23,7 @@ public static async Task AssertImageExistsAsync(ITestOutputHelper output, string var builder = new StringBuilder(); output.WriteLine($"> docker images \"{repository}\" --format \"{{{{.Repository}}}}\""); - var exitCode = await Process.ExecuteAsync( - "docker", + var exitCode = await ContainerEngine.Default.ExecuteAsync( $"images \"{repository}\" --format \"{{{{.Repository}}}}\"", stdOut: OnOutput, stdErr: OnOutput); @@ -33,12 +33,13 @@ public static async Task AssertImageExistsAsync(ITestOutputHelper output, string } var lines = builder.ToString().Split(new[] { '\r', '\n', }, StringSplitOptions.RemoveEmptyEntries); - if (lines.Any(line => line == repository)) + if (lines.Any(line => line == repository || + line == $"localhost/{repository}")) // podman format. { return; } - throw new XunitException($"Image '{repository}' was not found."); + throw new XunitException($"Image '{repository}' was not found in {builder.ToString()}."); void OnOutput(string text) { @@ -57,8 +58,7 @@ public static async Task DeleteDockerImagesAsync(ITestOutputHelper output, strin { output.WriteLine($"> docker rmi \"{id}\" --force"); - var exitCode = await Process.ExecuteAsync( - "docker", + var exitCode = await ContainerEngine.Default.ExecuteAsync( $"rmi \"{id}\" --force", stdOut: OnOutput, stdErr: OnOutput); @@ -82,8 +82,7 @@ public static async Task GetRunningContainersIdsAsync(ITestOutputHelpe var builder = new StringBuilder(); output.WriteLine($"> docker ps --format \"{{{{.ID}}}}\""); - var exitCode = await Process.ExecuteAsync( - "docker", + var exitCode = await ContainerEngine.Default.ExecuteAsync( $"ps --format \"{{{{.ID}}}}\"", stdOut: OnOutput, stdErr: OnOutput); @@ -110,8 +109,7 @@ private static async Task ListDockerImagesIdsAsync(ITestOutputHelper o var builder = new StringBuilder(); output.WriteLine($"> docker images -q \"{repository}\""); - var exitCode = await Process.ExecuteAsync( - "docker", + var exitCode = await ContainerEngine.Default.ExecuteAsync( $"images -q \"{repository}\"", stdOut: OnOutput, stdErr: OnOutput); diff --git a/test/Test.Infrastructure/SkipIfDockerNotRunningAttribute.cs b/test/Test.Infrastructure/SkipIfDockerNotRunningAttribute.cs index 27c0639bb..80b6a13a4 100644 --- a/test/Test.Infrastructure/SkipIfDockerNotRunningAttribute.cs +++ b/test/Test.Infrastructure/SkipIfDockerNotRunningAttribute.cs @@ -5,6 +5,7 @@ using System; using System.Runtime.InteropServices; using Microsoft.Tye; +using Microsoft.Tye.ConfigModel; namespace Test.Infrastructure { @@ -14,8 +15,8 @@ public class SkipIfDockerNotRunningAttribute : Attribute, ITestCondition public SkipIfDockerNotRunningAttribute() { // TODO Check performance of this. - IsMet = DockerDetector.Instance.IsDockerConnectedToDaemon.Value.GetAwaiter().GetResult() && !(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("AGENT_OS"))); - SkipReason = "Docker is not installed or running."; + IsMet = ContainerEngine.Default.IsUsable(out string unusableReason) && !(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("AGENT_OS"))); + SkipReason = $"Container engine not usable: {unusableReason}"; } public bool IsMet { get; } diff --git a/test/Test.Infrastructure/TestHelpers.cs b/test/Test.Infrastructure/TestHelpers.cs index 0ae89e2c7..df520f1bb 100644 --- a/test/Test.Infrastructure/TestHelpers.cs +++ b/test/Test.Infrastructure/TestHelpers.cs @@ -251,8 +251,8 @@ static async Task Purge(TyeHost host) var processRunner = new ProcessRunner(logger, replicaRegistry, new ProcessRunnerOptions()); var dockerRunner = new DockerRunner(logger, replicaRegistry); - await processRunner.StartAsync(new Application(new FileInfo(host.Application.Source), new Dictionary())); - await dockerRunner.StartAsync(new Application(new FileInfo(host.Application.Source), new Dictionary())); + await processRunner.StartAsync(new Application(new FileInfo(host.Application.Source), new Dictionary(), ContainerEngine.Default)); + await dockerRunner.StartAsync(new Application(new FileInfo(host.Application.Source), new Dictionary(), ContainerEngine.Default)); } await DoOperationAndWaitForReplicasToChangeState(host, ReplicaState.Stopped, replicas.Length, replicas.ToHashSet(), new HashSet(), TimeSpan.Zero, Purge);