From c7ab41bdafa2dd60fb85eb725a74db0288f607a3 Mon Sep 17 00:00:00 2001 From: christophergunn <6404007+christophergunn@users.noreply.github.com> Date: Fri, 20 Sep 2024 20:32:55 +0100 Subject: [PATCH 1/3] Adds 'requiredPorts' functionality I find people often have processes running locally which have port usages similar to those that we wish to spin-up via this library. E.g. a local proxy to a cloud instance of the DB which their app uses. This leads to annoyance and hunting through sometimes cryptic docker compose log lines to discover a simple problem which could have been proactively checked for (fast-fail) prior to docker compose up being invoked. This PR introduces an optional property 'RequiredPorts' which will be checked for before running docker compose up. If any ports are found to be in use, a custom exception is raised and the clashing ports are surfaced on the 'Ports' property of this exception instance. --- .../DockerFixtureTests.cs | 6 +- .../Utils/ObservableCounter.cs | 2 +- DockerComposeFixture/DockerFixture.cs | 61 +++++++++++++------ DockerComposeFixture/DockerFixtureOptions.cs | 23 ++++--- .../Exceptions/DockerComposeException.cs | 5 +- .../Exceptions/PortsUnavailableException.cs | 14 +++++ DockerComposeFixture/IDockerFixtureOptions.cs | 1 + DockerComposeFixture/Ports.cs | 31 ++++++++++ 8 files changed, 112 insertions(+), 31 deletions(-) create mode 100644 DockerComposeFixture/Exceptions/PortsUnavailableException.cs create mode 100644 DockerComposeFixture/Ports.cs diff --git a/DockerComposeFixture.Tests/DockerFixtureTests.cs b/DockerComposeFixture.Tests/DockerFixtureTests.cs index e73b251..ea75bfe 100644 --- a/DockerComposeFixture.Tests/DockerFixtureTests.cs +++ b/DockerComposeFixture.Tests/DockerFixtureTests.cs @@ -125,7 +125,7 @@ public void Init_Throws_IfTestIsNeverTrue() [Fact] public void Init_MonitorsServices_WhenTheyStartSlowly() { - Stopwatch stopwatch = new Stopwatch(); + var stopwatch = new Stopwatch(); var compose = new Mock(); compose.Setup(c => c.PauseMs).Returns(100); compose.Setup(c => c.Up()) @@ -158,7 +158,7 @@ public void Init_Throws_WhenServicesFailToStart() { var compose = new Mock(); compose.Setup(c => c.PauseMs).Returns(NumberOfMsInOneSec); - bool firstTime = true; + var firstTime = true; compose.Setup(c => c.PsWithJsonFormat()) .Returns(() => { @@ -191,7 +191,7 @@ public void Init_Throws_WhenYmlFileDoesntExist() compose.Setup(c => c.PsWithJsonFormat()) .Returns(new[] { "non-json-message", "{ \"Status\": \"Up 3 seconds\" }", "{ \"Status\": \"Up 15 seconds\" }" }); - string fileDoesntExist = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + var fileDoesntExist = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); Assert.Throws(() => new DockerFixture(null).Init(new[] { fileDoesntExist }, "up", "down", 120, null, compose.Object)); } diff --git a/DockerComposeFixture.Tests/Utils/ObservableCounter.cs b/DockerComposeFixture.Tests/Utils/ObservableCounter.cs index 6032bcc..24cac1b 100644 --- a/DockerComposeFixture.Tests/Utils/ObservableCounter.cs +++ b/DockerComposeFixture.Tests/Utils/ObservableCounter.cs @@ -16,7 +16,7 @@ public IDisposable Subscribe(IObserver observer) public void Count(int min = 1, int max = 10, int delay = 10) { - for (int i = min; i <= max; i++) + for (var i = min; i <= max; i++) { this.observalbes.ForEach(o => o.OnNext(i.ToString())); Thread.Sleep(delay); diff --git a/DockerComposeFixture/DockerFixture.cs b/DockerComposeFixture/DockerFixture.cs index e11bfd1..481f0d3 100644 --- a/DockerComposeFixture/DockerFixture.cs +++ b/DockerComposeFixture/DockerFixture.cs @@ -21,6 +21,7 @@ public class DockerFixture : IDisposable private ILogger[] loggers; private int startupTimeoutSecs; private readonly IMessageSink output; + private ushort[] requiredPorts; public DockerFixture(IMessageSink output) { @@ -31,7 +32,7 @@ public DockerFixture(IMessageSink output) /// Initialize docker compose services from file(s) but only once. /// If you call this multiple times on the same DockerFixture then it will be ignored. /// - /// Options that control how docker-compose is executed. + /// Options that control how docker compose is executed. public void InitOnce(Func setupOptions) { InitOnce(setupOptions, null); @@ -41,7 +42,7 @@ public void InitOnce(Func setupOptions) /// Initialize docker compose services from file(s) but only once. /// If you call this multiple times on the same DockerFixture then it will be ignored. /// - /// Options that control how docker-compose is executed. + /// Options that control how docker compose is executed. /// public void InitOnce(Func setupOptions, IDockerCompose dockerCompose) { @@ -56,7 +57,7 @@ public void InitOnce(Func setupOptions, IDockerCompose do /// /// Initialize docker compose services from file(s). /// - /// Options that control how docker-compose is executed + /// Options that control how docker compose is executed public void Init(Func setupOptions) { Init(setupOptions, null); @@ -65,18 +66,25 @@ public void Init(Func setupOptions) /// /// Initialize docker compose services from file(s). /// - /// Options that control how docker-compose is executed + /// Options that control how docker compose is executed /// public void Init(Func setupOptions, IDockerCompose compose) { var options = setupOptions(); options.Validate(); - string logFile = options.DebugLog + var logFile = options.DebugLog ? Path.Combine(Path.GetTempPath(), $"docker-compose-{DateTime.Now.Ticks}.log") : null; - this.Init(options.DockerComposeFiles, options.DockerComposeUpArgs, options.DockerComposeDownArgs, - options.StartupTimeoutSecs, options.CustomUpTest, compose, this.GetLoggers(logFile).ToArray()); + this.Init( + options.DockerComposeFiles, + options.DockerComposeUpArgs, + options.DockerComposeDownArgs, + options.StartupTimeoutSecs, + options.CustomUpTest, + compose, + this.GetLoggers(logFile).ToArray(), + options.RequiredPorts); } private IEnumerable GetLoggers(string file) @@ -97,15 +105,16 @@ private IEnumerable GetLoggers(string file) /// Initialize docker compose services from file(s). /// /// Array of docker compose files - /// Arguments to append after 'docker-compose -f file.yml up' - /// Arguments to append after 'docker-compose -f file.yml down' + /// Arguments to append after 'docker compose -f file.yml up' + /// Arguments to append after 'docker compose -f file.yml down' /// How long to wait for the application to start before giving up - /// Checks whether the docker-compose services have come up correctly based upon the output of docker-compose + /// Checks whether the docker compose services have come up correctly based upon the output of docker compose /// /// + /// Checks that these ports are available on the host network (not in use by other processes) public void Init(string[] dockerComposeFiles, string dockerComposeUpArgs, string dockerComposeDownArgs, int startupTimeoutSecs, Func customUpTest = null, - IDockerCompose dockerCompose = null, ILogger[] logger = null) + IDockerCompose dockerCompose = null, ILogger[] logger = null, ushort[] requiredPorts = null) { this.loggers = logger ?? GetLoggers(null).ToArray(); @@ -113,6 +122,7 @@ public void Init(string[] dockerComposeFiles, string dockerComposeUpArgs, string this.dockerCompose = dockerCompose ?? new DockerCompose(this.loggers); this.customUpTest = customUpTest; this.startupTimeoutSecs = startupTimeoutSecs; + this.requiredPorts = requiredPorts; this.dockerCompose.Init( string.Join(" ", @@ -129,7 +139,7 @@ private string GetComposeFilePath(string file) return file; } - DirectoryInfo curDir = new DirectoryInfo(AppDomain.CurrentDomain.BaseDirectory); + var curDir = new DirectoryInfo(AppDomain.CurrentDomain.BaseDirectory); if (File.Exists(Path.Combine(curDir.FullName, file))) { return Path.Combine(curDir.FullName, file); @@ -139,7 +149,7 @@ private string GetComposeFilePath(string file) { while (curDir != null) { - string curFile = Path.Combine(curDir.FullName, file); + var curFile = Path.Combine(curDir.FullName, file); if (File.Exists(curFile)) { return curFile; @@ -169,7 +179,7 @@ public static async Task Kill(string applicationName, bool killEverything = fals /// public static async Task Kill(Regex filterRx, bool killEverything = false) { - Process ps = Process.Start(new ProcessStartInfo("docker", "ps") + var ps = Process.Start(new ProcessStartInfo("docker", "ps") { UseShellExecute = false, RedirectStandardOutput = true @@ -187,7 +197,6 @@ public static async Task Kill(Regex filterRx, bool killEverything = false) { Process.Start("docker", $"kill {id}").WaitForExit(); } - } public virtual void Dispose() @@ -203,14 +212,21 @@ private void Start() this.Stop(); } + if (requiredPorts != null && requiredPorts.Length > 0) + { + this.loggers.Log("---- checking for port availability ----"); + this.CheckForRequiredPorts(); + this.loggers.Log("---- all required host ports are available ----"); + } + this.loggers.Log("---- starting docker services ----"); var upTask = this.dockerCompose.Up(); - for (int i = 0; i < this.startupTimeoutSecs; i++) + for (var i = 0; i < this.startupTimeoutSecs; i++) { if (upTask.IsCompleted) { - this.loggers.Log("docker-compose exited prematurely"); + this.loggers.Log("docker compose exited prematurely"); break; } this.loggers.Log($"---- checking docker services ({i + 1}/{this.startupTimeoutSecs}) ----"); @@ -235,7 +251,16 @@ private void Start() } throw new DockerComposeException(this.loggers.GetLoggedLines()); } - + + private void CheckForRequiredPorts() + { + var usedPorts = Ports.DetermineUtilizedPorts(requiredPorts); + if (usedPorts.Any()) + { + throw new PortsUnavailableException(this.loggers.GetLoggedLines(), usedPorts); + } + } + private (bool hasContainers, bool containersAreUp) CheckIfRunning() { var lines = dockerCompose.PsWithJsonFormat() diff --git a/DockerComposeFixture/DockerFixtureOptions.cs b/DockerComposeFixture/DockerFixtureOptions.cs index 5e2b04c..1132dd9 100644 --- a/DockerComposeFixture/DockerFixtureOptions.cs +++ b/DockerComposeFixture/DockerFixtureOptions.cs @@ -3,12 +3,21 @@ namespace DockerComposeFixture { /// - /// Options that control how docker-compose is executed + /// Options that control how docker compose is executed /// public class DockerFixtureOptions : IDockerFixtureOptions { /// - /// Checks whether the docker-compose services have come up correctly based upon the output of docker-compose + /// An array of ports required to be available on the host in order for the docker compose services + /// to start. Provides a fail-fast check along with aiding developers to easier debug issues related + /// to running other programs locally with clashing ports. + /// If null or empty - no ports are checked. + /// If any required ports are reserved by other processes - throws an 'PortsUnavailableException'. + /// + public ushort[] RequiredPorts { get; set; } + + /// + /// Checks whether the docker compose services have come up correctly based upon the output of docker compose /// public Func CustomUpTest { get; set; } @@ -19,17 +28,17 @@ public class DockerFixtureOptions : IDockerFixtureOptions /// public string[] DockerComposeFiles { get; set; } = new[] { "docker-compose.yml" }; /// - /// When true this logs docker-compose output to %temp%\docker-compose-*.log + /// When true this logs docker compose output to %temp%\docker-compose-*.log /// public bool DebugLog { get; set; } /// - /// Arguments to append after 'docker-compose -f file.yml up' - /// Default is 'docker-compose -f file.yml up' you can append '--build' if you want it to always build + /// Arguments to append after 'docker compose -f file.yml up' + /// Default is 'docker compose -f file.yml up' you can append '--build' if you want it to always build /// public string DockerComposeUpArgs { get; set; } = ""; /// - /// Arguments to append after 'docker-compose -f file.yml down' - /// Default is 'docker-compose -f file.yml down --remove-orphans' you can add '--rmi all' if you want to guarantee a fresh build on each test + /// Arguments to append after 'docker compose -f file.yml down' + /// Default is 'docker compose -f file.yml down --remove-orphans' you can add '--rmi all' if you want to guarantee a fresh build on each test /// public string DockerComposeDownArgs { get; set; } = "--remove-orphans"; diff --git a/DockerComposeFixture/Exceptions/DockerComposeException.cs b/DockerComposeFixture/Exceptions/DockerComposeException.cs index ba03c8b..918e5d3 100644 --- a/DockerComposeFixture/Exceptions/DockerComposeException.cs +++ b/DockerComposeFixture/Exceptions/DockerComposeException.cs @@ -2,9 +2,10 @@ namespace DockerComposeFixture.Exceptions { - public class DockerComposeException:Exception + public class DockerComposeException : Exception { - public DockerComposeException(string[] loggedLines):base($"docker-compose failed - see {nameof(DockerComposeOutput)} property") + public DockerComposeException(string[] loggedLines) + : base($"docker compose failed - see {nameof(DockerComposeOutput)} property") { this.DockerComposeOutput = loggedLines; } diff --git a/DockerComposeFixture/Exceptions/PortsUnavailableException.cs b/DockerComposeFixture/Exceptions/PortsUnavailableException.cs new file mode 100644 index 0000000..31e2ab3 --- /dev/null +++ b/DockerComposeFixture/Exceptions/PortsUnavailableException.cs @@ -0,0 +1,14 @@ +using System; + +namespace DockerComposeFixture.Exceptions +{ + public class PortsUnavailableException : DockerComposeException + { + public PortsUnavailableException(string[] loggedLines, ushort[] ports) : base(loggedLines) + { + Ports = ports; + } + + public ushort[] Ports { get; set; } + } +} \ No newline at end of file diff --git a/DockerComposeFixture/IDockerFixtureOptions.cs b/DockerComposeFixture/IDockerFixtureOptions.cs index a828638..4fda466 100644 --- a/DockerComposeFixture/IDockerFixtureOptions.cs +++ b/DockerComposeFixture/IDockerFixtureOptions.cs @@ -4,6 +4,7 @@ namespace DockerComposeFixture { public interface IDockerFixtureOptions { + UInt16[] RequiredPorts { get; set; } Func CustomUpTest { get; set; } string[] DockerComposeFiles { get; set; } bool DebugLog { get; set; } diff --git a/DockerComposeFixture/Ports.cs b/DockerComposeFixture/Ports.cs new file mode 100644 index 0000000..f47cd28 --- /dev/null +++ b/DockerComposeFixture/Ports.cs @@ -0,0 +1,31 @@ +using System; +using System.Linq; +using System.Net.NetworkInformation; + +namespace DockerComposeFixture +{ + public static class Ports + { + // check if network port is open + public static ushort[] DetermineUtilizedPorts(ushort[] ports) + { + return ports.Where(p => !IsPortAvailable(p)).ToArray(); + } + + private static bool IsPortAvailable(UInt16 port) + { + var isAvailable = true; + var ipGlobalProperties = IPGlobalProperties.GetIPGlobalProperties(); + var tcpConnInfoArray = ipGlobalProperties.GetActiveTcpListeners(); + + foreach (var endpoint in tcpConnInfoArray) { + if (endpoint.Port == port) { + isAvailable = false; + break; + } + } + + return isAvailable; + } + } +} \ No newline at end of file From 86d9585a4a5fd663621838a6e7b20011c60425c9 Mon Sep 17 00:00:00 2001 From: christophergunn <6404007+christophergunn@users.noreply.github.com> Date: Fri, 20 Sep 2024 20:53:47 +0100 Subject: [PATCH 2/3] Add integration tests for required ports functionality. --- .../IntegrationTests.cs | 2 + .../RequiredPortsIntegrationTests.cs | 82 +++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 DockerComposeFixture.Tests/RequiredPortsIntegrationTests.cs diff --git a/DockerComposeFixture.Tests/IntegrationTests.cs b/DockerComposeFixture.Tests/IntegrationTests.cs index 50af0da..1758fa6 100644 --- a/DockerComposeFixture.Tests/IntegrationTests.cs +++ b/DockerComposeFixture.Tests/IntegrationTests.cs @@ -42,6 +42,8 @@ public async Task EchoServer_SaysHello_WhenCalled() Assert.Contains("hello world", response); } + + public void Dispose() { File.Delete(this.dockerComposeFile); diff --git a/DockerComposeFixture.Tests/RequiredPortsIntegrationTests.cs b/DockerComposeFixture.Tests/RequiredPortsIntegrationTests.cs new file mode 100644 index 0000000..2ab50d7 --- /dev/null +++ b/DockerComposeFixture.Tests/RequiredPortsIntegrationTests.cs @@ -0,0 +1,82 @@ +using System; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using DockerComposeFixture.Exceptions; +using Xunit; + +namespace DockerComposeFixture.Tests; + +public class RequiredPortsIntegrationTests : IClassFixture, IDisposable +{ + private readonly DockerFixture dockerFixture; + private readonly string dockerComposeFile; + + private const string DockerCompose = @" +version: '3.4' +services: + echo_server: + image: hashicorp/http-echo + ports: + - 12871:8080 + command: -listen=:8080 -text=""hello world"" + "; + + public RequiredPortsIntegrationTests(DockerFixture dockerFixture) + { + this.dockerFixture = dockerFixture; + this.dockerComposeFile = Path.GetTempFileName(); + File.WriteAllText(this.dockerComposeFile, DockerCompose); + } + + [Fact] + public void Ports_DetermineUtilizedPorts_ReturnsThoseInUse() + { + // Arrange + const ushort portToOccupy = 12871; + var ipEndPoint = new IPEndPoint(IPAddress.Loopback, portToOccupy); + using Socket listener = new( + ipEndPoint.AddressFamily, + SocketType.Stream, + ProtocolType.Tcp); + listener.Bind(ipEndPoint); + listener.Listen(); + + // Act + var usedPorts = Ports.DetermineUtilizedPorts([12870, 12871, 12872]); + + // Assert + Assert.Single(usedPorts, p => p == 12871); + } + + [Fact] + public void GivenRequiredPortInUse_ThenExceptionIsThrownWithPortNumber() + { + const ushort portToOccupy = 12871; + var ipEndPoint = new IPEndPoint(IPAddress.Loopback, portToOccupy); + using Socket listener = new( + ipEndPoint.AddressFamily, + SocketType.Stream, + ProtocolType.Tcp); + listener.Bind(ipEndPoint); + listener.Listen(); + + var thrownException = Assert.Throws(() => + { + dockerFixture.InitOnce(() => new DockerFixtureOptions + { + DockerComposeFiles = new[] { this.dockerComposeFile }, + CustomUpTest = output => output.Any(l => l.Contains("server is listening")), + RequiredPorts = new ushort[] { portToOccupy } + }); + }); + + Assert.Equivalent(new ushort[] { portToOccupy }, thrownException.Ports); + } + + public void Dispose() + { + File.Delete(this.dockerComposeFile); + } +} \ No newline at end of file From 06f55bf9425b56f569390dd1006602a73aba6acd Mon Sep 17 00:00:00 2001 From: christophergunn <6404007+christophergunn@users.noreply.github.com> Date: Fri, 20 Sep 2024 20:55:55 +0100 Subject: [PATCH 3/3] Update version to v1.3.0. --- DockerComposeFixture.Tests/RequiredPortsIntegrationTests.cs | 2 ++ DockerComposeFixture/DockerComposeFixture.csproj | 6 +++--- DockerComposeFixture/DockerComposeFixture.nuspec | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/DockerComposeFixture.Tests/RequiredPortsIntegrationTests.cs b/DockerComposeFixture.Tests/RequiredPortsIntegrationTests.cs index 2ab50d7..73fe480 100644 --- a/DockerComposeFixture.Tests/RequiredPortsIntegrationTests.cs +++ b/DockerComposeFixture.Tests/RequiredPortsIntegrationTests.cs @@ -53,6 +53,7 @@ public void Ports_DetermineUtilizedPorts_ReturnsThoseInUse() [Fact] public void GivenRequiredPortInUse_ThenExceptionIsThrownWithPortNumber() { + // Arrange const ushort portToOccupy = 12871; var ipEndPoint = new IPEndPoint(IPAddress.Loopback, portToOccupy); using Socket listener = new( @@ -62,6 +63,7 @@ public void GivenRequiredPortInUse_ThenExceptionIsThrownWithPortNumber() listener.Bind(ipEndPoint); listener.Listen(); + // Act & Assert var thrownException = Assert.Throws(() => { dockerFixture.InitOnce(() => new DockerFixtureOptions diff --git a/DockerComposeFixture/DockerComposeFixture.csproj b/DockerComposeFixture/DockerComposeFixture.csproj index 739c416..1b7b955 100644 --- a/DockerComposeFixture/DockerComposeFixture.csproj +++ b/DockerComposeFixture/DockerComposeFixture.csproj @@ -2,7 +2,7 @@ netstandard2.1 - 1.2.2 + 1.3.0 false Joe Shearn Docker Compose Fixture @@ -13,8 +13,8 @@ https://github.com/devjoes/DockerComposeFixture/blob/master/LICENSE https://github.com/devjoes/DockerComposeFixture docker docker-compose xunit - Do not throw null reference if fixture is disposed of without being initialized. - 1.2.2.0 + Add RequiredPorts checking functionality. + 1.3.0.0 diff --git a/DockerComposeFixture/DockerComposeFixture.nuspec b/DockerComposeFixture/DockerComposeFixture.nuspec index 91cdcd9..1915d02 100644 --- a/DockerComposeFixture/DockerComposeFixture.nuspec +++ b/DockerComposeFixture/DockerComposeFixture.nuspec @@ -3,7 +3,7 @@ dockercomposefixture docker-compose Fixture - 1.2.2 + 1.3.0 Joe Shearn Joe Shearn false