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/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..73fe480 --- /dev/null +++ b/DockerComposeFixture.Tests/RequiredPortsIntegrationTests.cs @@ -0,0 +1,84 @@ +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() + { + // 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 & Assert + 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 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/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 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