From f7f3c49e4dbdbf97d0477ebc09360935d279c81b Mon Sep 17 00:00:00 2001 From: Andre Hofmeister <9199345+HofmeisterAn@users.noreply.github.com> Date: Mon, 19 Jun 2023 08:57:41 +0200 Subject: [PATCH] feat: Add support for copying directories and files to a container (#913) --- src/Testcontainers.Kafka/KafkaBuilder.cs | 2 +- .../MariaDbContainer.cs | 2 +- .../MongoDbContainer.cs | 2 +- src/Testcontainers.MsSql/MsSqlContainer.cs | 2 +- src/Testcontainers.MySql/MySqlContainer.cs | 2 +- src/Testcontainers.Oracle/OracleContainer.cs | 2 +- .../PostgreSqlContainer.cs | 2 +- src/Testcontainers.Redis/RedisContainer.cs | 2 +- .../RedpandaBuilder.cs | 2 +- .../Clients/ITestcontainersClient.cs | 32 +++++ .../Clients/TestcontainersClient.cs | 73 +++++++--- src/Testcontainers/Configurations/Unix.cs | 60 ++++++++ .../Configurations/UnixFileMode.cs | 78 ++++++++++ .../Volumes/BinaryResourceMapping.cs | 5 +- .../Volumes/FileResourceMapping.cs | 7 +- .../Volumes/IResourceMapping.cs | 5 + src/Testcontainers/Configurations/Windows.cs | 6 + .../Containers/DockerContainer.cs | 34 +++++ src/Testcontainers/Containers/IContainer.cs | 43 ++++++ .../Containers/TarOutputMemoryStream.cs | 130 +++++++++++++++++ .../Images/DockerfileArchive.cs | 10 +- .../KafkaContainerTest.cs | 6 +- .../TarOutputMemoryStreamTest.cs | 135 ++++++++++++++++++ .../Usings.cs | 2 + .../RedpandaContainerTest.cs | 6 +- .../Unix/CopyResourceMappingContainerTest.cs | 7 +- .../Unix/TestcontainersContainerTest.cs | 2 +- 27 files changed, 611 insertions(+), 48 deletions(-) create mode 100644 src/Testcontainers/Configurations/UnixFileMode.cs create mode 100644 src/Testcontainers/Containers/TarOutputMemoryStream.cs create mode 100644 tests/Testcontainers.Platform.Linux.Tests/TarOutputMemoryStreamTest.cs diff --git a/src/Testcontainers.Kafka/KafkaBuilder.cs b/src/Testcontainers.Kafka/KafkaBuilder.cs index 88f5bfe93..39824e925 100644 --- a/src/Testcontainers.Kafka/KafkaBuilder.cs +++ b/src/Testcontainers.Kafka/KafkaBuilder.cs @@ -84,7 +84,7 @@ protected override KafkaBuilder Init() startupScript.Append("echo '' > /etc/confluent/docker/ensure"); startupScript.Append(lf); startupScript.Append("/etc/confluent/docker/run"); - return container.CopyFileAsync(StartupScriptFilePath, Encoding.Default.GetBytes(startupScript.ToString()), 493, ct: ct); + return container.CopyAsync(Encoding.Default.GetBytes(startupScript.ToString()), StartupScriptFilePath, Unix.FileMode755, ct); }); } diff --git a/src/Testcontainers.MariaDb/MariaDbContainer.cs b/src/Testcontainers.MariaDb/MariaDbContainer.cs index a2e7f83f2..ec9f7b49d 100644 --- a/src/Testcontainers.MariaDb/MariaDbContainer.cs +++ b/src/Testcontainers.MariaDb/MariaDbContainer.cs @@ -42,7 +42,7 @@ public async Task ExecScriptAsync(string scriptContent, Cancellation { var scriptFilePath = string.Join("/", string.Empty, "tmp", Guid.NewGuid().ToString("D"), Path.GetRandomFileName()); - await CopyFileAsync(scriptFilePath, Encoding.Default.GetBytes(scriptContent), 493, 0, 0, ct) + await CopyAsync(Encoding.Default.GetBytes(scriptContent), scriptFilePath, Unix.FileMode644, ct) .ConfigureAwait(false); return await ExecAsync(new[] { "mysql", "--protocol=TCP", $"--port={MariaDbBuilder.MariaDbPort}", $"--user={_configuration.Username}", $"--password={_configuration.Password}", _configuration.Database, $"--execute=source {scriptFilePath};" }, ct) diff --git a/src/Testcontainers.MongoDb/MongoDbContainer.cs b/src/Testcontainers.MongoDb/MongoDbContainer.cs index 5271abba3..88ad50832 100644 --- a/src/Testcontainers.MongoDb/MongoDbContainer.cs +++ b/src/Testcontainers.MongoDb/MongoDbContainer.cs @@ -40,7 +40,7 @@ public async Task ExecScriptAsync(string scriptContent, Cancellation { var scriptFilePath = string.Join("/", string.Empty, "tmp", Guid.NewGuid().ToString("D"), Path.GetRandomFileName()); - await CopyFileAsync(scriptFilePath, Encoding.Default.GetBytes(scriptContent), 493, 0, 0, ct) + await CopyAsync(Encoding.Default.GetBytes(scriptContent), scriptFilePath, Unix.FileMode644, ct) .ConfigureAwait(false); return await ExecAsync(new MongoDbShellCommand($"load('{scriptFilePath}')", _configuration.Username, _configuration.Password), ct) diff --git a/src/Testcontainers.MsSql/MsSqlContainer.cs b/src/Testcontainers.MsSql/MsSqlContainer.cs index 666d236a5..865f38e20 100644 --- a/src/Testcontainers.MsSql/MsSqlContainer.cs +++ b/src/Testcontainers.MsSql/MsSqlContainer.cs @@ -42,7 +42,7 @@ public async Task ExecScriptAsync(string scriptContent, Cancellation { var scriptFilePath = string.Join("/", string.Empty, "tmp", Guid.NewGuid().ToString("D"), Path.GetRandomFileName()); - await CopyFileAsync(scriptFilePath, Encoding.Default.GetBytes(scriptContent), 493, 0, 0, ct) + await CopyAsync(Encoding.Default.GetBytes(scriptContent), scriptFilePath, Unix.FileMode644, ct) .ConfigureAwait(false); return await ExecAsync(new[] { "/opt/mssql-tools/bin/sqlcmd", "-b", "-r", "1", "-U", _configuration.Username, "-P", _configuration.Password, "-i", scriptFilePath }, ct) diff --git a/src/Testcontainers.MySql/MySqlContainer.cs b/src/Testcontainers.MySql/MySqlContainer.cs index 229087975..59dc19c8b 100644 --- a/src/Testcontainers.MySql/MySqlContainer.cs +++ b/src/Testcontainers.MySql/MySqlContainer.cs @@ -42,7 +42,7 @@ public async Task ExecScriptAsync(string scriptContent, Cancellation { var scriptFilePath = string.Join("/", string.Empty, "tmp", Guid.NewGuid().ToString("D"), Path.GetRandomFileName()); - await CopyFileAsync(scriptFilePath, Encoding.Default.GetBytes(scriptContent), 493, 0, 0, ct) + await CopyAsync(Encoding.Default.GetBytes(scriptContent), scriptFilePath, Unix.FileMode644, ct) .ConfigureAwait(false); return await ExecAsync(new[] { "mysql", "--protocol=TCP", $"--port={MySqlBuilder.MySqlPort}", $"--user={_configuration.Username}", $"--password={_configuration.Password}", _configuration.Database, $"--execute=source {scriptFilePath};" }, ct) diff --git a/src/Testcontainers.Oracle/OracleContainer.cs b/src/Testcontainers.Oracle/OracleContainer.cs index b2fe36f85..fa8cdab92 100644 --- a/src/Testcontainers.Oracle/OracleContainer.cs +++ b/src/Testcontainers.Oracle/OracleContainer.cs @@ -37,7 +37,7 @@ public async Task ExecScriptAsync(string scriptContent, Cancellation { var scriptFilePath = string.Join("/", string.Empty, "tmp", Guid.NewGuid().ToString("D"), Path.GetRandomFileName()); - await CopyFileAsync(scriptFilePath, Encoding.Default.GetBytes(scriptContent), 493, 0, 0, ct) + await CopyAsync(Encoding.Default.GetBytes(scriptContent), scriptFilePath, Unix.FileMode644, ct) .ConfigureAwait(false); return await ExecAsync(new[] { "/bin/sh", "-c", $"exit | sqlplus -LOGON -SILENT {_configuration.Username}/{_configuration.Password}@localhost:1521/{_configuration.Database} @{scriptFilePath}" }, ct) diff --git a/src/Testcontainers.PostgreSql/PostgreSqlContainer.cs b/src/Testcontainers.PostgreSql/PostgreSqlContainer.cs index de74d5159..3ac48447c 100644 --- a/src/Testcontainers.PostgreSql/PostgreSqlContainer.cs +++ b/src/Testcontainers.PostgreSql/PostgreSqlContainer.cs @@ -42,7 +42,7 @@ public async Task ExecScriptAsync(string scriptContent, Cancellation { var scriptFilePath = string.Join("/", string.Empty, "tmp", Guid.NewGuid().ToString("D"), Path.GetRandomFileName()); - await CopyFileAsync(scriptFilePath, Encoding.Default.GetBytes(scriptContent), 493, 0, 0, ct) + await CopyAsync(Encoding.Default.GetBytes(scriptContent), scriptFilePath, Unix.FileMode644, ct) .ConfigureAwait(false); return await ExecAsync(new[] { "psql", "--username", _configuration.Username, "--dbname", _configuration.Database, "--file", scriptFilePath }, ct) diff --git a/src/Testcontainers.Redis/RedisContainer.cs b/src/Testcontainers.Redis/RedisContainer.cs index 64a913b66..ec3691919 100644 --- a/src/Testcontainers.Redis/RedisContainer.cs +++ b/src/Testcontainers.Redis/RedisContainer.cs @@ -33,7 +33,7 @@ public async Task ExecScriptAsync(string scriptContent, Cancellation { var scriptFilePath = string.Join("/", string.Empty, "tmp", Guid.NewGuid().ToString("D"), Path.GetRandomFileName()); - await CopyFileAsync(scriptFilePath, Encoding.Default.GetBytes(scriptContent), 493, 0, 0, ct) + await CopyAsync(Encoding.Default.GetBytes(scriptContent), scriptFilePath, Unix.FileMode644, ct) .ConfigureAwait(false); return await ExecAsync(new[] { "redis-cli", "--eval", scriptFilePath, "0" }, ct) diff --git a/src/Testcontainers.Redpanda/RedpandaBuilder.cs b/src/Testcontainers.Redpanda/RedpandaBuilder.cs index d2d80f89b..91cb37069 100644 --- a/src/Testcontainers.Redpanda/RedpandaBuilder.cs +++ b/src/Testcontainers.Redpanda/RedpandaBuilder.cs @@ -61,7 +61,7 @@ protected override RedpandaBuilder Init() startupScript.Append("--mode dev-container "); startupScript.Append("--kafka-addr PLAINTEXT://0.0.0.0:29092,OUTSIDE://0.0.0.0:9092 "); startupScript.Append("--advertise-kafka-addr PLAINTEXT://127.0.0.1:29092,OUTSIDE://" + container.Hostname + ":" + container.GetMappedPublicPort(RedpandaPort)); - return container.CopyFileAsync(StartupScriptFilePath, Encoding.Default.GetBytes(startupScript.ToString()), 493, ct: ct); + return container.CopyAsync(Encoding.Default.GetBytes(startupScript.ToString()), StartupScriptFilePath, Unix.FileMode755, ct); }); } diff --git a/src/Testcontainers/Clients/ITestcontainersClient.cs b/src/Testcontainers/Clients/ITestcontainersClient.cs index 52a2000de..ffde40d90 100644 --- a/src/Testcontainers/Clients/ITestcontainersClient.cs +++ b/src/Testcontainers/Clients/ITestcontainersClient.cs @@ -2,6 +2,7 @@ namespace DotNet.Testcontainers.Clients { using System; using System.Collections.Generic; + using System.IO; using System.Threading; using System.Threading.Tasks; using Docker.DotNet.Models; @@ -103,6 +104,37 @@ internal interface ITestcontainersClient /// Task that completes when the shell command has been executed. Task ExecAsync(string id, IList command, CancellationToken ct = default); + /// + /// Copies the content of an implementation of to the container. + /// + /// The container id. + /// The resource mapping to add to the archive. + /// Cancellation token. + /// A task that completes when the content has been copied. + Task CopyAsync(string id, IResourceMapping resourceMapping, CancellationToken ct = default); + + /// + /// Copies a test host directory to the container. + /// + /// The container id. + /// The source directory to be copied. + /// The target directory path to copy the files to. + /// The POSIX file mode permission. + /// Cancellation token. + /// A task that completes when the directory has been copied. + Task CopyAsync(string id, DirectoryInfo source, string target, UnixFileMode fileMode, CancellationToken ct = default); + + /// + /// Copies a test host file to the container. + /// + /// The container id. + /// The source file to be copied. + /// The target directory path to copy the file to. + /// The POSIX file mode permission. + /// Cancellation token. + /// A task that completes when the file has been copied. + Task CopyAsync(string id, FileInfo source, string target, UnixFileMode fileMode, CancellationToken ct = default); + /// /// Copies a file to the container. /// diff --git a/src/Testcontainers/Clients/TestcontainersClient.cs b/src/Testcontainers/Clients/TestcontainersClient.cs index 9dbba84e5..d10c5925f 100644 --- a/src/Testcontainers/Clients/TestcontainersClient.cs +++ b/src/Testcontainers/Clients/TestcontainersClient.cs @@ -26,7 +26,7 @@ internal sealed class TestcontainersClient : ITestcontainersClient public const string TestcontainersSessionIdLabel = TestcontainersLabel + ".session-id"; - private readonly string _osRootDirectory = Path.GetPathRoot(Directory.GetCurrentDirectory()); + private static readonly string OSRootDirectory = Path.GetPathRoot(Directory.GetCurrentDirectory()); private readonly DockerRegistryAuthenticationProvider _registryAuthenticationProvider; @@ -79,7 +79,7 @@ private TestcontainersClient( public IDockerSystemOperations System { get; } /// - public bool IsRunningInsideDocker => File.Exists(Path.Combine(_osRootDirectory, ".dockerenv")); + public bool IsRunningInsideDocker => File.Exists(Path.Combine(OSRootDirectory, ".dockerenv")); /// public Task GetContainerExitCodeAsync(string id, CancellationToken ct = default) @@ -147,7 +147,7 @@ await Container.RemoveAsync(id, ct) catch (DockerApiException e) { // The Docker daemon may already start the progress to removes the container (AutoRemove): - // https://docs.docker.com/engine/api/v1.41/#operation/ContainerCreate. + // https://docs.docker.com/engine/api/v1.43/#operation/ContainerCreate. if (!e.Message.Contains($"removal of container {id} is already in progress")) { throw; @@ -162,11 +162,58 @@ public Task ExecAsync(string id, IList command, Cancellation return Container.ExecAsync(id, command, ct); } + /// + public async Task CopyAsync(string id, IResourceMapping resourceMapping, CancellationToken ct = default) + { + using (var tarOutputMemStream = new TarOutputMemoryStream()) + { + await tarOutputMemStream.AddAsync(resourceMapping, ct) + .ConfigureAwait(false); + + tarOutputMemStream.Close(); + tarOutputMemStream.Seek(0, SeekOrigin.Begin); + + await Container.ExtractArchiveToContainerAsync(id, "/", tarOutputMemStream, ct) + .ConfigureAwait(false); + } + } + + /// + public async Task CopyAsync(string id, DirectoryInfo source, string target, UnixFileMode fileMode, CancellationToken ct = default) + { + using (var tarOutputMemStream = new TarOutputMemoryStream(target)) + { + await tarOutputMemStream.AddAsync(source, true, fileMode, ct) + .ConfigureAwait(false); + + tarOutputMemStream.Close(); + tarOutputMemStream.Seek(0, SeekOrigin.Begin); + + await Container.ExtractArchiveToContainerAsync(id, "/", tarOutputMemStream, ct) + .ConfigureAwait(false); + } + } + + /// + public async Task CopyAsync(string id, FileInfo source, string target, UnixFileMode fileMode, CancellationToken ct = default) + { + using (var tarOutputMemStream = new TarOutputMemoryStream(target)) + { + await tarOutputMemStream.AddAsync(source, fileMode, ct) + .ConfigureAwait(false); + + tarOutputMemStream.Close(); + tarOutputMemStream.Seek(0, SeekOrigin.Begin); + + await Container.ExtractArchiveToContainerAsync(id, "/", tarOutputMemStream, ct) + .ConfigureAwait(false); + } + } + /// public async Task CopyFileAsync(string id, string filePath, byte[] fileContent, int accessMode, int userId, int groupId, CancellationToken ct = default) { - IOperatingSystem os = new Unix(dockerEndpointAuthConfig: null); - var containerPath = os.NormalizePath(filePath); + var containerPath = Unix.Instance.NormalizePath(filePath); using (var tarOutputMemStream = new MemoryStream()) { @@ -200,7 +247,7 @@ await tarOutputStream.CloseEntryAsync(ct) tarOutputMemStream.Seek(0, SeekOrigin.Begin); - await Container.ExtractArchiveToContainerAsync(id, Path.AltDirectorySeparatorChar.ToString(), tarOutputMemStream, ct) + await Container.ExtractArchiveToContainerAsync(id, "/", tarOutputMemStream, ct) .ConfigureAwait(false); } } @@ -210,8 +257,7 @@ public async Task ReadFileAsync(string id, string filePath, Cancellation { Stream tarStream; - IOperatingSystem os = new Unix(dockerEndpointAuthConfig: null); - var containerPath = os.NormalizePath(filePath); + var containerPath = Unix.Instance.NormalizePath(filePath); try { @@ -252,15 +298,6 @@ public async Task ReadFileAsync(string id, string filePath, Cancellation /// public async Task RunAsync(IContainerConfiguration configuration, CancellationToken ct = default) { - async Task CopyResourceMappingAsync(string containerId, IResourceMapping resourceMapping) - { - var resourceMappingContent = await resourceMapping.GetAllBytesAsync(ct) - .ConfigureAwait(false); - - await CopyFileAsync(containerId, resourceMapping.Target, resourceMappingContent, 420, 0, 0, ct) - .ConfigureAwait(false); - } - if (TestcontainersSettings.ResourceReaperEnabled && ResourceReaper.DefaultSessionId.Equals(configuration.SessionId)) { var isWindowsEngineEnabled = await System.GetIsWindowsEngineEnabled(ct) @@ -302,7 +339,7 @@ await Network.ConnectAsync("bridge", id, ct) if (configuration.ResourceMappings.Any()) { - await Task.WhenAll(configuration.ResourceMappings.Values.Select(resourceMapping => CopyResourceMappingAsync(id, resourceMapping))) + await Task.WhenAll(configuration.ResourceMappings.Values.Select(resourceMapping => CopyAsync(id, resourceMapping, ct))) .ConfigureAwait(false); } diff --git a/src/Testcontainers/Configurations/Unix.cs b/src/Testcontainers/Configurations/Unix.cs index f7203c003..508fcc545 100644 --- a/src/Testcontainers/Configurations/Unix.cs +++ b/src/Testcontainers/Configurations/Unix.cs @@ -10,6 +10,60 @@ namespace DotNet.Testcontainers.Configurations [PublicAPI] public sealed class Unix : IOperatingSystem { + /// + /// Represents the Unix file mode 644, which grants read and write permissions to the user and read permissions to the group and others. + /// + public const UnixFileMode FileMode644 = + UnixFileMode.UserRead | + UnixFileMode.UserWrite | + UnixFileMode.GroupRead | + UnixFileMode.OtherRead; + + /// + /// Represents the Unix file mode 666, which grants read and write permissions to the user, group, and others. + /// + public const UnixFileMode FileMode666 = + UnixFileMode.UserRead | + UnixFileMode.UserWrite | + UnixFileMode.GroupRead | + UnixFileMode.GroupWrite | + UnixFileMode.OtherRead | + UnixFileMode.OtherWrite; + + /// + /// Represents the Unix file mode 700, which grants read, write, and execute permissions to the user, and no permissions to the group and others. + /// + public const UnixFileMode FileMode700 = + UnixFileMode.UserRead | + UnixFileMode.UserWrite | + UnixFileMode.UserExecute; + + /// + /// Represents the Unix file mode 755, which grants read, write, and execute permissions to the user, and read and execute permissions to the group and others. + /// + public const UnixFileMode FileMode755 = + UnixFileMode.UserRead | + UnixFileMode.UserWrite | + UnixFileMode.UserExecute | + UnixFileMode.GroupRead | + UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | + UnixFileMode.OtherExecute; + + /// + /// Represents the Unix file mode 777, which grants read, write, and execute permissions to the user, group, and others. + /// + public const UnixFileMode FileMode777 = + UnixFileMode.UserRead | + UnixFileMode.UserWrite | + UnixFileMode.UserExecute | + UnixFileMode.GroupRead | + UnixFileMode.GroupWrite | + UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | + UnixFileMode.OtherWrite | + UnixFileMode.OtherExecute; + /// /// Initializes a new instance of the class. /// @@ -49,6 +103,12 @@ public Unix(IDockerEndpointAuthenticationConfiguration dockerEndpointAuthConfig) DockerEndpointAuthConfig = dockerEndpointAuthConfig; } + /// + /// Gets the instance. + /// + public static IOperatingSystem Instance { get; } + = new Unix(dockerEndpointAuthConfig: null); + /// public IDockerEndpointAuthenticationConfiguration DockerEndpointAuthConfig { get; } diff --git a/src/Testcontainers/Configurations/UnixFileMode.cs b/src/Testcontainers/Configurations/UnixFileMode.cs new file mode 100644 index 000000000..10ec64238 --- /dev/null +++ b/src/Testcontainers/Configurations/UnixFileMode.cs @@ -0,0 +1,78 @@ +namespace DotNet.Testcontainers.Configurations +{ + using System; + using JetBrains.Annotations; + + /// + /// Unix file mode. + /// + [PublicAPI] + [Flags] + public enum UnixFileMode + { + /// + /// No permissions. + /// + None = 0, + + /// + /// Execute permission for others. + /// + OtherExecute = 1, + + /// + /// Write permission for others. + /// + OtherWrite = 2, + + /// + /// Read permission for others. + /// + OtherRead = 4, + + /// + /// Execute permission for group. + /// + GroupExecute = 8, + + /// + /// Write permission for group. + /// + GroupWrite = 16, + + /// + /// Read permission for group. + /// + GroupRead = 32, + + /// + /// Execute permission for owner. + /// + UserExecute = 64, + + /// + /// Write permission for owner. + /// + UserWrite = 128, + + /// + /// Read permission for owner. + /// + UserRead = 256, + + /// + /// Sticky bit permission. + /// + StickyBit = 512, + + /// + /// Set Group permission. + /// + SetGroup = 1024, + + /// + /// Set User permission. + /// + SetUser = 2048, + } +} diff --git a/src/Testcontainers/Configurations/Volumes/BinaryResourceMapping.cs b/src/Testcontainers/Configurations/Volumes/BinaryResourceMapping.cs index 97eb29c30..f3ccfceff 100644 --- a/src/Testcontainers/Configurations/Volumes/BinaryResourceMapping.cs +++ b/src/Testcontainers/Configurations/Volumes/BinaryResourceMapping.cs @@ -13,8 +13,9 @@ internal class BinaryResourceMapping : FileResourceMapping /// /// The byte array content to map in the container. /// The absolute path of a file to map in the container. - public BinaryResourceMapping(byte[] resourceContent, string containerPath) - : base(string.Empty, containerPath) + /// The POSIX file mode permission. + public BinaryResourceMapping(byte[] resourceContent, string containerPath, UnixFileMode fileMode = Unix.FileMode644) + : base(string.Empty, containerPath, fileMode) { _resourceContent = resourceContent; } diff --git a/src/Testcontainers/Configurations/Volumes/FileResourceMapping.cs b/src/Testcontainers/Configurations/Volumes/FileResourceMapping.cs index d6bf01c0e..8613d78e4 100644 --- a/src/Testcontainers/Configurations/Volumes/FileResourceMapping.cs +++ b/src/Testcontainers/Configurations/Volumes/FileResourceMapping.cs @@ -12,11 +12,13 @@ internal class FileResourceMapping : IResourceMapping /// /// The absolute path of a file to map on the host system. /// The absolute path of a file to map in the container. - public FileResourceMapping(string hostPath, string containerPath) + /// The POSIX file mode permission. + public FileResourceMapping(string hostPath, string containerPath, UnixFileMode fileMode = Unix.FileMode644) { Type = MountType.Bind; Source = hostPath; Target = containerPath; + FileMode = fileMode; AccessMode = AccessMode.ReadOnly; } @@ -32,6 +34,9 @@ public FileResourceMapping(string hostPath, string containerPath) /// public string Target { get; } + /// + public UnixFileMode FileMode { get; } + /// public Task CreateAsync(CancellationToken ct = default) { diff --git a/src/Testcontainers/Configurations/Volumes/IResourceMapping.cs b/src/Testcontainers/Configurations/Volumes/IResourceMapping.cs index ec087df65..fff96d910 100644 --- a/src/Testcontainers/Configurations/Volumes/IResourceMapping.cs +++ b/src/Testcontainers/Configurations/Volumes/IResourceMapping.cs @@ -10,6 +10,11 @@ namespace DotNet.Testcontainers.Configurations [PublicAPI] public interface IResourceMapping : IMount { + /// + /// Gets the Unix file mode. + /// + UnixFileMode FileMode { get; } + /// /// Gets the byte array content of the resource mapping. /// diff --git a/src/Testcontainers/Configurations/Windows.cs b/src/Testcontainers/Configurations/Windows.cs index 8bd21b7a2..36e36eeeb 100644 --- a/src/Testcontainers/Configurations/Windows.cs +++ b/src/Testcontainers/Configurations/Windows.cs @@ -49,6 +49,12 @@ public Windows(IDockerEndpointAuthenticationConfiguration dockerEndpointAuthConf DockerEndpointAuthConfig = dockerEndpointAuthConfig; } + /// + /// Gets the instance. + /// + public static IOperatingSystem Instance { get; } + = new Windows(dockerEndpointAuthConfig: null); + /// public IDockerEndpointAuthenticationConfiguration DockerEndpointAuthConfig { get; } diff --git a/src/Testcontainers/Containers/DockerContainer.cs b/src/Testcontainers/Containers/DockerContainer.cs index a46227624..cdbacb4e5 100644 --- a/src/Testcontainers/Containers/DockerContainer.cs +++ b/src/Testcontainers/Containers/DockerContainer.cs @@ -3,6 +3,7 @@ namespace DotNet.Testcontainers.Containers using System; using System.Collections.Generic; using System.Globalization; + using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -278,6 +279,39 @@ await UnsafeStopAsync(ct) } } + /// + public Task CopyAsync(byte[] fileContent, string filePath, UnixFileMode fileMode = Unix.FileMode644, CancellationToken ct = default) + { + return _client.CopyAsync(Id, new BinaryResourceMapping(fileContent, filePath, fileMode), ct); + } + + /// + public Task CopyAsync(string source, string target, UnixFileMode fileMode = Unix.FileMode644, CancellationToken ct = default) + { + var fileAttributes = File.GetAttributes(source); + + if ((fileAttributes & FileAttributes.Directory) == FileAttributes.Directory) + { + return CopyAsync(new DirectoryInfo(source), target, fileMode, ct); + } + else + { + return CopyAsync(new FileInfo(source), target, fileMode, ct); + } + } + + /// + public Task CopyAsync(FileInfo source, string target, UnixFileMode fileMode = Unix.FileMode644, CancellationToken ct = default) + { + return _client.CopyAsync(Id, source, target, fileMode, ct); + } + + /// + public Task CopyAsync(DirectoryInfo source, string target, UnixFileMode fileMode = Unix.FileMode644, CancellationToken ct = default) + { + return _client.CopyAsync(Id, source, target, fileMode, ct); + } + /// public Task CopyFileAsync(string filePath, byte[] fileContent, int accessMode = 384, int userId = 0, int groupId = 0, CancellationToken ct = default) { diff --git a/src/Testcontainers/Containers/IContainer.cs b/src/Testcontainers/Containers/IContainer.cs index 1a4d82f50..53b130592 100644 --- a/src/Testcontainers/Containers/IContainer.cs +++ b/src/Testcontainers/Containers/IContainer.cs @@ -2,8 +2,10 @@ namespace DotNet.Testcontainers.Containers { using System; using System.Collections.Generic; + using System.IO; using System.Threading; using System.Threading.Tasks; + using DotNet.Testcontainers.Configurations; using DotNet.Testcontainers.Images; using JetBrains.Annotations; using Microsoft.Extensions.Logging; @@ -164,6 +166,46 @@ public interface IContainer : IAsyncDisposable /// Thrown when a Testcontainers task gets canceled. Task StopAsync(CancellationToken ct = default); + /// + /// Copies a test host file to the container. + /// + /// The byte array content of the file. + /// The target file path to copy the file to. + /// The POSIX file mode permission. + /// Cancellation token. + /// + Task CopyAsync(byte[] fileContent, string filePath, UnixFileMode fileMode = Unix.FileMode644, CancellationToken ct = default); + + /// + /// Copies a test host directory or file to the container. + /// + /// The source directory or file to be copied. + /// The target directory path to copy the files to. + /// The POSIX file mode permission. + /// Cancellation token. + /// A task that completes when the directory or file has been copied. + Task CopyAsync(string source, string target, UnixFileMode fileMode = Unix.FileMode644, CancellationToken ct = default); + + /// + /// Copies a test host directory to the container. + /// + /// The source directory to be copied. + /// The target directory path to copy the files to. + /// The POSIX file mode permission. + /// Cancellation token. + /// A task that completes when the directory has been copied. + Task CopyAsync(DirectoryInfo source, string target, UnixFileMode fileMode = Unix.FileMode644, CancellationToken ct = default); + + /// + /// Copies a test host file to the container. + /// + /// The source file to be copied. + /// The target directory path to copy the file to. + /// The POSIX file mode permission. + /// Cancellation token. + /// A task that completes when the file has been copied. + Task CopyAsync(FileInfo source, string target, UnixFileMode fileMode = Unix.FileMode644, CancellationToken ct = default); + /// /// Copies a file to the container. /// @@ -182,6 +224,7 @@ public interface IContainer : IAsyncDisposable ///
  • 644 octal 🠒 110_100_100 binary 🠒 420 decimal
  • /// /// + [Obsolete("Use CopyAsync(byte[], string, UnixFileMode, CancellationToken) or one of its overloads.")] Task CopyFileAsync(string filePath, byte[] fileContent, int accessMode = 384, int userId = 0, int groupId = 0, CancellationToken ct = default); /// diff --git a/src/Testcontainers/Containers/TarOutputMemoryStream.cs b/src/Testcontainers/Containers/TarOutputMemoryStream.cs new file mode 100644 index 000000000..eaca4613f --- /dev/null +++ b/src/Testcontainers/Containers/TarOutputMemoryStream.cs @@ -0,0 +1,130 @@ +namespace DotNet.Testcontainers.Containers +{ + using System; + using System.IO; + using System.Text; + using System.Threading; + using System.Threading.Tasks; + using DotNet.Testcontainers.Configurations; + using ICSharpCode.SharpZipLib.Tar; + + /// + /// Represent a tar archive file. + /// + public sealed class TarOutputMemoryStream : TarOutputStream + { + private readonly string _targetDirectoryPath; + + /// + /// Initializes a new instance of the class. + /// + /// The target directory path to extract the files to. + public TarOutputMemoryStream(string targetDirectoryPath) + : this() + { + _targetDirectoryPath = targetDirectoryPath; + } + + /// + /// Initializes a new instance of the class. + /// + public TarOutputMemoryStream() + : base(new MemoryStream(), Encoding.Default) + { + IsStreamOwner = false; + } + + /// + /// Adds the content of an implementation of to the archive. + /// + /// The resource mapping to add to the archive. + /// Cancellation token. + public async Task AddAsync(IResourceMapping resourceMapping, CancellationToken ct = default) + { + var fileContent = await resourceMapping.GetAllBytesAsync(ct) + .ConfigureAwait(false); + + var targetFilePath = Unix.Instance.NormalizePath(resourceMapping.Target); + + var tarEntry = new TarEntry(new TarHeader()); + tarEntry.TarHeader.Name = targetFilePath; + tarEntry.TarHeader.Mode = (int)resourceMapping.FileMode; + tarEntry.TarHeader.ModTime = DateTime.UtcNow; + tarEntry.Size = fileContent.Length; + + await PutNextEntryAsync(tarEntry, ct) + .ConfigureAwait(false); + +#if NETSTANDARD2_1_OR_GREATER + await WriteAsync(fileContent, ct) + .ConfigureAwait(false); +#else + await WriteAsync(fileContent, 0, fileContent.Length, ct) + .ConfigureAwait(false); +#endif + + await CloseEntryAsync(ct) + .ConfigureAwait(false); + } + + /// + /// Adds a file to the archive. + /// + /// The file to add to the archive. + /// The POSIX file mode permission. + /// Cancellation token. + /// A task that completes when the file has been added to the archive. + public Task AddAsync(FileInfo file, UnixFileMode fileMode, CancellationToken ct = default) + { + return AddAsync(file.Directory, file, fileMode, ct); + } + + /// + /// Adds a directory to the archive. + /// + /// The directory to add to the archive. + /// A value indicating whether the current directory and all its subdirectories are included or not. + /// The POSIX file mode permission. + /// Cancellation token. + public async Task AddAsync(DirectoryInfo directory, bool recurse, UnixFileMode fileMode, CancellationToken ct = default) + { + var searchOption = recurse ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; + + foreach (var file in directory.GetFiles("*", searchOption)) + { + await AddAsync(directory, file, fileMode, ct) + .ConfigureAwait(false); + } + } + + /// + /// Adds a file to the archive. + /// + /// The root directory of the file to add to the archive. + /// The file to add to the archive. + /// The POSIX file mode permission. + /// Cancellation token. + public async Task AddAsync(DirectoryInfo directory, FileInfo file, UnixFileMode fileMode, CancellationToken ct = default) + { + using (var stream = new FileStream(file.FullName, FileMode.Open, FileAccess.Read)) + { + var targetFilePath = Unix.Instance.NormalizePath(Path.Combine(_targetDirectoryPath, file.FullName.Substring(directory.FullName.Length + 1))); + + var tarEntry = new TarEntry(new TarHeader()); + tarEntry.TarHeader.Name = targetFilePath; + tarEntry.TarHeader.Mode = (int)fileMode; + tarEntry.TarHeader.ModTime = file.LastWriteTimeUtc; + tarEntry.Size = stream.Length; + + await PutNextEntryAsync(tarEntry, ct) + .ConfigureAwait(false); + + await stream.CopyToAsync(this, 81920, ct) + .ConfigureAwait(false); + + await CloseEntryAsync(ct) + .ConfigureAwait(false); + } + } + } +} diff --git a/src/Testcontainers/Images/DockerfileArchive.cs b/src/Testcontainers/Images/DockerfileArchive.cs index 416f2cfaf..933d1f927 100644 --- a/src/Testcontainers/Images/DockerfileArchive.cs +++ b/src/Testcontainers/Images/DockerfileArchive.cs @@ -17,8 +17,6 @@ namespace DotNet.Testcontainers.Images /// internal sealed class DockerfileArchive : ITarArchive { - private static readonly IOperatingSystem OS = new Unix(dockerEndpointAuthConfig: null); - private readonly DirectoryInfo _dockerfileDirectory; private readonly FileInfo _dockerfile; @@ -69,9 +67,9 @@ public DockerfileArchive(DirectoryInfo dockerfileDirectory, FileInfo dockerfile, /// public async Task Tar(CancellationToken ct = default) { - var dockerfileDirectoryPath = OS.NormalizePath(_dockerfileDirectory.FullName); + var dockerfileDirectoryPath = Unix.Instance.NormalizePath(_dockerfileDirectory.FullName); - var dockerfileFilePath = OS.NormalizePath(_dockerfile.ToString()); + var dockerfileFilePath = Unix.Instance.NormalizePath(_dockerfile.ToString()); var dockerfileArchiveFileName = Regex.Replace(_image.FullName, "[^a-zA-Z0-9]", "-", RegexOptions.None, TimeSpan.FromSeconds(1)).ToLowerInvariant(); @@ -105,7 +103,7 @@ public async Task Tar(CancellationToken ct = default) await tarOutputStream.PutNextEntryAsync(entry, ct) .ConfigureAwait(false); - await inputStream.CopyToAsync(tarOutputStream, 4096, ct) + await inputStream.CopyToAsync(tarOutputStream, 81920, ct) .ConfigureAwait(false); await tarOutputStream.CloseEntryAsync(ct) @@ -133,7 +131,7 @@ private static IEnumerable GetFiles(string directory) return Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories) .AsParallel() .Select(Path.GetFullPath) - .Select(OS.NormalizePath) + .Select(Unix.Instance.NormalizePath) .ToArray(); } } diff --git a/tests/Testcontainers.Kafka.Tests/KafkaContainerTest.cs b/tests/Testcontainers.Kafka.Tests/KafkaContainerTest.cs index 0e82488ed..fe67aa2ea 100644 --- a/tests/Testcontainers.Kafka.Tests/KafkaContainerTest.cs +++ b/tests/Testcontainers.Kafka.Tests/KafkaContainerTest.cs @@ -31,10 +31,8 @@ public async Task ConsumerReturnsProducerMessage() consumerConfig.GroupId = "sample-consumer"; consumerConfig.AutoOffsetReset = AutoOffsetReset.Earliest; - var message = new Message - { - Value = Guid.NewGuid().ToString("D"), - }; + var message = new Message(); + message.Value = Guid.NewGuid().ToString("D"); // When ConsumeResult result; diff --git a/tests/Testcontainers.Platform.Linux.Tests/TarOutputMemoryStreamTest.cs b/tests/Testcontainers.Platform.Linux.Tests/TarOutputMemoryStreamTest.cs new file mode 100644 index 000000000..35a488609 --- /dev/null +++ b/tests/Testcontainers.Platform.Linux.Tests/TarOutputMemoryStreamTest.cs @@ -0,0 +1,135 @@ +namespace Testcontainers.Tests; + +public abstract class TarOutputMemoryStreamTest +{ + private const string TargetDirectoryPath = "/tmp"; + + private readonly TarOutputMemoryStream _tarOutputMemoryStream = new TarOutputMemoryStream(TargetDirectoryPath); + + private readonly FileInfo _testFile = new FileInfo(Path.Combine(TestSession.TempDirectoryPath, Path.GetRandomFileName())); + + protected TarOutputMemoryStreamTest() + { + using var fileStream = _testFile.Create(); + fileStream.WriteByte(13); + } + + [Fact] + public void TarFileContainsTestFile() + { + // Given + IList actual = new List(); + + _tarOutputMemoryStream.Close(); + _tarOutputMemoryStream.Seek(0, SeekOrigin.Begin); + + // When + using var tarIn = TarArchive.CreateInputTarArchive(_tarOutputMemoryStream, Encoding.Default); + tarIn.ProgressMessageEvent += (_, entry, _) => actual.Add(entry.Name); + tarIn.ListContents(); + + // Then + Assert.Contains(actual, file => file.EndsWith(_testFile.Name)); + } + + [UsedImplicitly] + public sealed class FromResourceMapping : TarOutputMemoryStreamTest, IResourceMapping, IAsyncLifetime, IDisposable + { + public MountType Type + => MountType.Bind; + + public AccessMode AccessMode + => AccessMode.ReadOnly; + + public string Source + => string.Empty; + + public string Target + => string.Join("/", TargetDirectoryPath, _testFile.Name); + + public UnixFileMode FileMode + => Unix.FileMode644; + + public Task InitializeAsync() + { + return _tarOutputMemoryStream.AddAsync(this); + } + + public Task DisposeAsync() + { + return Task.CompletedTask; + } + + public void Dispose() + { + _tarOutputMemoryStream.Dispose(); + } + + public Task CreateAsync(CancellationToken ct = default) + { + return Task.CompletedTask; + } + + public Task DeleteAsync(CancellationToken ct = default) + { + return Task.CompletedTask; + } + + public Task GetAllBytesAsync(CancellationToken ct = default) + { + return File.ReadAllBytesAsync(_testFile.FullName, ct); + } + } + + [UsedImplicitly] + public sealed class FromFile : TarOutputMemoryStreamTest, IAsyncLifetime, IDisposable + { + public Task InitializeAsync() + { + return _tarOutputMemoryStream.AddAsync(_testFile, Unix.FileMode644); + } + + public Task DisposeAsync() + { + return Task.CompletedTask; + } + + public void Dispose() + { + _tarOutputMemoryStream.Dispose(); + } + } + + [UsedImplicitly] + public sealed class FromDirectory : TarOutputMemoryStreamTest, IAsyncLifetime, IDisposable + { + public Task InitializeAsync() + { + return _tarOutputMemoryStream.AddAsync(_testFile.Directory, true, Unix.FileMode644); + } + + public Task DisposeAsync() + { + return Task.CompletedTask; + } + + public void Dispose() + { + _tarOutputMemoryStream.Dispose(); + } + } + + public sealed class UnixFileModeTest + { + [Theory] + [InlineData(Unix.FileMode644, "644")] + [InlineData(Unix.FileMode666, "666")] + [InlineData(Unix.FileMode700, "700")] + [InlineData(Unix.FileMode755, "755")] + [InlineData(Unix.FileMode777, "777")] + public void UnixFileModeResolvesToPosixFilePermission(UnixFileMode fileMode, string posixFilePermission) + { + Assert.Equal(Convert.ToInt32(posixFilePermission, 8), Convert.ToInt32(fileMode)); + } + } +} \ No newline at end of file diff --git a/tests/Testcontainers.Platform.Linux.Tests/Usings.cs b/tests/Testcontainers.Platform.Linux.Tests/Usings.cs index 12286a320..95437ced0 100644 --- a/tests/Testcontainers.Platform.Linux.Tests/Usings.cs +++ b/tests/Testcontainers.Platform.Linux.Tests/Usings.cs @@ -1,6 +1,7 @@ global using System; global using System.Collections.Generic; global using System.Globalization; +global using System.IO; global using System.Net; global using System.Net.Sockets; global using System.Text; @@ -11,5 +12,6 @@ global using DotNet.Testcontainers.Commons; global using DotNet.Testcontainers.Configurations; global using DotNet.Testcontainers.Containers; +global using ICSharpCode.SharpZipLib.Tar; global using JetBrains.Annotations; global using Xunit; \ No newline at end of file diff --git a/tests/Testcontainers.Redpanda.Tests/RedpandaContainerTest.cs b/tests/Testcontainers.Redpanda.Tests/RedpandaContainerTest.cs index 582899008..8438850eb 100644 --- a/tests/Testcontainers.Redpanda.Tests/RedpandaContainerTest.cs +++ b/tests/Testcontainers.Redpanda.Tests/RedpandaContainerTest.cs @@ -31,10 +31,8 @@ public async Task ConsumerReturnsProducerMessage() consumerConfig.GroupId = "sample-consumer"; consumerConfig.AutoOffsetReset = AutoOffsetReset.Earliest; - var message = new Message - { - Value = Guid.NewGuid().ToString("D"), - }; + var message = new Message(); + message.Value = Guid.NewGuid().ToString("D"); // When ConsumeResult result; diff --git a/tests/Testcontainers.Tests/Unit/Containers/Unix/CopyResourceMappingContainerTest.cs b/tests/Testcontainers.Tests/Unit/Containers/Unix/CopyResourceMappingContainerTest.cs index 7e7b3f4ad..b08979b54 100644 --- a/tests/Testcontainers.Tests/Unit/Containers/Unix/CopyResourceMappingContainerTest.cs +++ b/tests/Testcontainers.Tests/Unit/Containers/Unix/CopyResourceMappingContainerTest.cs @@ -6,6 +6,7 @@ namespace DotNet.Testcontainers.Tests.Unit using System.Text; using System.Threading.Tasks; using DotNet.Testcontainers.Builders; + using DotNet.Testcontainers.Commons; using DotNet.Testcontainers.Containers; using Xunit; @@ -15,16 +16,16 @@ public sealed class CopyResourceMappingContainerTest : IAsyncLifetime, IDisposab private readonly string _resourceMappingSourceFilePath = Path.Combine(Path.GetTempPath(), Path.GetTempFileName()); - private readonly string _resourceMappingFileDestinationFilePath = Path.Combine("/tmp", Path.GetTempFileName()); + private readonly string _resourceMappingFileDestinationFilePath = string.Join("/", string.Empty, "tmp", Path.GetRandomFileName()); - private readonly string _resourceMappingBytesDestinationFilePath = Path.Combine("/tmp", Path.GetTempFileName()); + private readonly string _resourceMappingBytesDestinationFilePath = string.Join("/", string.Empty, "tmp", Path.GetRandomFileName()); private readonly IContainer _container; public CopyResourceMappingContainerTest() { _container = new ContainerBuilder() - .WithImage("alpine") + .WithImage(CommonImages.Alpine) .WithResourceMapping(_resourceMappingSourceFilePath, _resourceMappingFileDestinationFilePath) .WithResourceMapping(Encoding.Default.GetBytes(ResourceMappingContent), _resourceMappingBytesDestinationFilePath) .Build(); diff --git a/tests/Testcontainers.Tests/Unit/Containers/Unix/TestcontainersContainerTest.cs b/tests/Testcontainers.Tests/Unit/Containers/Unix/TestcontainersContainerTest.cs index 6e1616f83..1353148e8 100644 --- a/tests/Testcontainers.Tests/Unit/Containers/Unix/TestcontainersContainerTest.cs +++ b/tests/Testcontainers.Tests/Unit/Containers/Unix/TestcontainersContainerTest.cs @@ -374,7 +374,7 @@ public async Task CopyFileToRunningContainer() await container.StartAsync() .ConfigureAwait(false); - await container.CopyFileAsync(dayOfWeekFilePath, Encoding.Default.GetBytes(dayOfWeek)) + await container.CopyAsync(Encoding.Default.GetBytes(dayOfWeek), dayOfWeekFilePath) .ConfigureAwait(false); var execResult = await container.ExecAsync(new[] { "test", "-f", dayOfWeekFilePath })