diff --git a/src/Testcontainers/Containers/ExecFailedException.cs b/src/Testcontainers/Containers/ExecFailedException.cs new file mode 100644 index 000000000..aed316606 --- /dev/null +++ b/src/Testcontainers/Containers/ExecFailedException.cs @@ -0,0 +1,62 @@ +namespace DotNet.Testcontainers.Containers +{ + using System; + using System.Linq; + using System.Text; + using JetBrains.Annotations; + + /// + /// Represents an exception that is thrown when executing a command inside a + /// running container fails. + /// + [PublicAPI] + public sealed class ExecFailedException : Exception + { + private static readonly string[] LineEndings = new[] { "\r\n", "\n" }; + + /// + /// Initializes a new instance of the class. + /// + /// The result of the failed command execution. + public ExecFailedException(ExecResult execResult) + : base(CreateMessage(execResult)) + { + ExecResult = execResult; + } + + /// + /// Gets the result of the failed command execution inside the container. + /// + public ExecResult ExecResult { get; } + + private static string CreateMessage(ExecResult execResult) + { + var exceptionInfo = new StringBuilder(256); + exceptionInfo.Append($"Process exited with code {execResult.ExitCode}."); + + if (!string.IsNullOrEmpty(execResult.Stdout)) + { + var stdoutLines = execResult.Stdout + .Split(LineEndings, StringSplitOptions.RemoveEmptyEntries) + .Select(line => " " + line); + + exceptionInfo.AppendLine(); + exceptionInfo.AppendLine(" Stdout: "); + exceptionInfo.Append(string.Join(Environment.NewLine, stdoutLines)); + } + + if (!string.IsNullOrEmpty(execResult.Stderr)) + { + var stderrLines = execResult.Stderr + .Split(LineEndings, StringSplitOptions.RemoveEmptyEntries) + .Select(line => " " + line); + + exceptionInfo.AppendLine(); + exceptionInfo.AppendLine(" Stderr: "); + exceptionInfo.Append(string.Join(Environment.NewLine, stderrLines)); + } + + return exceptionInfo.ToString(); + } + } +} diff --git a/src/Testcontainers/Containers/ExecResultExtensions.cs b/src/Testcontainers/Containers/ExecResultExtensions.cs new file mode 100644 index 000000000..f51e04bf2 --- /dev/null +++ b/src/Testcontainers/Containers/ExecResultExtensions.cs @@ -0,0 +1,33 @@ +namespace DotNet.Testcontainers.Containers +{ + using System; + using System.Threading.Tasks; + + /// + /// Extension methods for working with instances. + /// + public static class ExecResultExtensions + { + /// + /// Awaits the and throws an exception if the result's exit code is not successful. + /// + /// The task returning an . + /// A list of exit codes that should be treated as successful. If none are provided, only exit code 0 is treated as successful. + /// The if the exit code is in the list of success exit codes. + /// Thrown if the exit code is not in the list of success exit codes. + public static async Task ThrowOnFailure(this Task execTask, params long[] successExitCodes) + { + successExitCodes = successExitCodes == null || successExitCodes.Length == 0 ? new long[] { 0 } : successExitCodes; + + var execResult = await execTask + .ConfigureAwait(false); + + if (Array.IndexOf(successExitCodes, execResult.ExitCode) < 0) + { + throw new ExecFailedException(execResult); + } + + return execResult; + } + } +} diff --git a/tests/Testcontainers.Platform.Linux.Tests/ExecFailedExceptionTest.cs b/tests/Testcontainers.Platform.Linux.Tests/ExecFailedExceptionTest.cs new file mode 100644 index 000000000..152038618 --- /dev/null +++ b/tests/Testcontainers.Platform.Linux.Tests/ExecFailedExceptionTest.cs @@ -0,0 +1,50 @@ +namespace Testcontainers.Tests; + +public sealed class ExecFailedExceptionTest +{ + public static readonly List> ExecResultTestData + = new List> + { + new TheoryDataRow + ( + new ExecResult("Stdout\nStdout", "Stderr\nStderr", 1), + "Process exited with code 1." + Environment.NewLine + + " Stdout: " + Environment.NewLine + + " Stdout" + Environment.NewLine + + " Stdout" + Environment.NewLine + + " Stderr: " + Environment.NewLine + + " Stderr" + Environment.NewLine + + " Stderr" + ), + new TheoryDataRow + ( + new ExecResult("Stdout\nStdout", string.Empty, 1), + "Process exited with code 1." + Environment.NewLine + + " Stdout: " + Environment.NewLine + + " Stdout" + Environment.NewLine + + " Stdout" + ), + new TheoryDataRow + ( + new ExecResult(string.Empty, "Stderr\nStderr", 1), + "Process exited with code 1." + Environment.NewLine + + " Stderr: " + Environment.NewLine + + " Stderr" + Environment.NewLine + + " Stderr" + ), + new TheoryDataRow + ( + new ExecResult(string.Empty, string.Empty, 1), + "Process exited with code 1." + ), + }; + + [Theory] + [MemberData(nameof(ExecResultTestData))] + public void ExecFailedExceptionCreatesExpectedMessage(ExecResult execResult, string message) + { + var exception = new ExecFailedException(execResult); + Assert.Equal(execResult, exception.ExecResult); + Assert.Equal(message, exception.Message); + } +} \ No newline at end of file diff --git a/tests/Testcontainers.Platform.Linux.Tests/ExecResultExtensionsTest.cs b/tests/Testcontainers.Platform.Linux.Tests/ExecResultExtensionsTest.cs new file mode 100644 index 000000000..c5a1baba3 --- /dev/null +++ b/tests/Testcontainers.Platform.Linux.Tests/ExecResultExtensionsTest.cs @@ -0,0 +1,50 @@ +namespace Testcontainers.Tests; + +public sealed class ExecResultExtensionsTest : IAsyncLifetime +{ + private readonly IContainer _container = new ContainerBuilder() + .WithImage(CommonImages.Alpine) + .WithCommand(CommonCommands.SleepInfinity) + .Build(); + + public async ValueTask InitializeAsync() + { + await _container.StartAsync() + .ConfigureAwait(false); + } + + public ValueTask DisposeAsync() + { + return _container.DisposeAsync(); + } + + [Fact] + public async Task ExecAsyncShouldSucceedWhenCommandReturnsZeroExitCode() + { + // Given + var command = new[] { "true" }; + + // When + var exception = await Record.ExceptionAsync(() => _container.ExecAsync(command, TestContext.Current.CancellationToken).ThrowOnFailure()) + .ConfigureAwait(true); + + // Then + Assert.Null(exception); + } + + [Fact] + public async Task ExecAsyncShouldThrowExecFailedExceptionWhenCommandFails() + { + // Given + var command = new[] { "/bin/sh", "-c", "echo out; echo err >&2; exit 1" }; + + // When + var exception = await Assert.ThrowsAsync(() => _container.ExecAsync(command, TestContext.Current.CancellationToken).ThrowOnFailure()) + .ConfigureAwait(true); + + // Then + Assert.Equal(1, exception.ExecResult.ExitCode); + Assert.Equal("out", exception.ExecResult.Stdout.Trim()); + Assert.Equal("err", exception.ExecResult.Stderr.Trim()); + } +} \ No newline at end of file