Skip to content
51 changes: 42 additions & 9 deletions docs/api/create_docker_container.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,35 +60,65 @@ _ = new ContainerBuilder("mcr.microsoft.com/dotnet/aspnet:10.0")

## Copying directories or files to the container

Sometimes it is necessary to copy files into the container to configure the services running inside the container in advance, like the `appsettings.json` or an SSL certificate. The container builder API provides a member `WithResourceMapping(string, string)`, including several overloads to copy directories or individual files to a container's directory.
Sometimes it is necessary to copy files into the container to configure the services running inside the container in advance, like the `appsettings.json` or an SSL certificate. The container builder API provides `WithResourceMapping(...)` overloads that support strongly typed source and target paths via `FilePath` and `DirectoryPath`.

```csharp title="Copying a directory"
_ = new ContainerBuilder("alpine:3.20.0")
.WithResourceMapping(new DirectoryInfo("."), "/app/");
.WithResourceMapping(
DirectoryPath.Of("."),
DirectoryPath.Of("/app/"));
```

```csharp title="Copying a file"
_ = new ContainerBuilder("alpine:3.20.0")
// Copy 'appsettings.json' into the '/app' directory.
.WithResourceMapping(new FileInfo("appsettings.json"), "/app/")
// Copy 'appsettings.Container.json' to '/app/appsettings.Developer.json'.
.WithResourceMapping(new FileInfo("appsettings.Container.json"), new FileInfo("/app/appsettings.Developer.json"));
.WithResourceMapping(
FilePath.Of("appsettings.json"),
DirectoryPath.Of("/app/"))
// Copy 'appsettings.Container.json' to '/app/appsettings.json'.
.WithResourceMapping(
FilePath.Of("appsettings.Container.json"),
FilePath.Of("/app/appsettings.json"));
```

Another overloaded member of the container builder API allows you to copy the contents of a byte array to a specific file path within the container. This can be useful when you already have the file content stored in memory or when you need to dynamically generate the file content before copying it.
You can also copy the contents of a byte array to a specific file path within the container. This can be useful when you already have the file content stored in memory or when you need to dynamically generate the file content before copying it.

```csharp title="Copying a byte array"
_ = new ContainerBuilder("alpine:3.20.0")
.WithResourceMapping(Encoding.Default.GetBytes("{}"), "/app/appsettings.json");
.WithResourceMapping(
Encoding.UTF8.GetBytes("{}"),
FilePath.Of("/app/appsettings.json"));
```

For remote sources, you can copy a file from a URL to a target directory or file before the container starts.

=== "Copying to a directory"
```csharp
_ = new ContainerBuilder("alpine:3.20.0")
.WithResourceMapping(
new Uri("https://localhost:8080/appsettings.json"),
DirectoryPath.Of("/app/"));
```

=== "Copying to a file"
```csharp
_ = new ContainerBuilder("alpine:3.20.0")
.WithResourceMapping(
new Uri("https://localhost:8080/appsettings.json"),
FilePath.Of("/app/appsettings.json"));
```

### Specifying file ownership

When copying files into a container, you can specify the user ID (UID) and group ID (GID) to set the correct ownership of the copied files. This is particularly useful when the container runs as a non-root user or when specific file permissions are required for security or application functionality.

```csharp title="Copying a file with specific UID and GID"
_ = new ContainerBuilder("alpine:3.20.0")
.WithResourceMapping(new DirectoryInfo("."), "/app/", uid: 1000, gid: 1000);
.WithResourceMapping(
DirectoryPath.Of("."),
DirectoryPath.Of("/app/"),
uid: 1000,
gid: 1000);
```

### Specifying file permission
Expand All @@ -97,7 +127,10 @@ When copying files into a container, you can specify the file mode to set the co

```csharp title="Copying a script with executable permissions"
_ = new ContainerBuilder("alpine:3.20.0")
.WithResourceMapping(new DirectoryInfo("."), "/app/", fileMode: Unix.FileMode755);
.WithResourceMapping(
DirectoryPath.Of("."),
DirectoryPath.Of("/app/"),
fileMode: Unix.FileMode755);
```

The `Unix` class provides common permission configurations like `FileMode755` (read, write, execute for owner; read, execute for group and others). For individual permission combinations, you can use the `UnixFileModes` enumeration to create custom configurations.
Expand Down
2 changes: 1 addition & 1 deletion src/Testcontainers.EventHubs/EventHubsBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ protected override void Validate()

_ = Guard.Argument(DockerResourceConfiguration.ServiceConfiguration, nameof(DockerResourceConfiguration.ServiceConfiguration))
.NotNull()
.ThrowIf(argument => !argument.Value.Validate(), _ => throw new ArgumentException("The service configuration of the Azure Event Hubs Emulator is invalid."));
.ThrowIf(argument => !argument.Value.Validate(), _ => new ArgumentException("The service configuration of the Azure Event Hubs Emulator is invalid."));
}

/// <inheritdoc />
Expand Down
2 changes: 1 addition & 1 deletion src/Testcontainers.Neo4j/Neo4jBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ protected override void Validate()
&& (!value.Environments.TryGetValue(AcceptLicenseAgreementEnvVar, out var licenseAgreementValue) || !AcceptLicenseAgreement.Equals(licenseAgreementValue, StringComparison.Ordinal));

_ = Guard.Argument(DockerResourceConfiguration, nameof(DockerResourceConfiguration.Image))
.ThrowIf(argument => licenseAgreementNotAccepted(argument.Value), argument => throw new ArgumentException(string.Format(message, DockerResourceConfiguration.Image.FullName), argument.Name));
.ThrowIf(argument => licenseAgreementNotAccepted(argument.Value), argument => new ArgumentException(string.Format(message, DockerResourceConfiguration.Image.FullName), argument.Name));
}

/// <inheritdoc />
Expand Down
2 changes: 1 addition & 1 deletion src/Testcontainers.Oracle/OracleBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ protected override void Validate()
value.Database != null && value.Image.MatchVersion(v => v.Major < 18);

_ = Guard.Argument(DockerResourceConfiguration, nameof(DockerResourceConfiguration.Database))
.ThrowIf(argument => databaseConfigurationNotSupported(argument.Value), _ => throw new NotSupportedException(string.Format(message, DockerResourceConfiguration.Image.FullName)));
.ThrowIf(argument => databaseConfigurationNotSupported(argument.Value), _ => new NotSupportedException(string.Format(message, DockerResourceConfiguration.Image.FullName)));

_ = Guard.Argument(DockerResourceConfiguration.Username, nameof(DockerResourceConfiguration.Username))
.NotNull()
Expand Down
101 changes: 92 additions & 9 deletions src/Testcontainers/Builders/ContainerBuilder`3.cs
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,18 @@ public TBuilderEntity WithResourceMapping(byte[] resourceContent, string filePat
return WithResourceMapping(new BinaryResourceMapping(resourceContent, filePath, uid, gid, fileMode));
}

/// <inheritdoc />
public TBuilderEntity WithResourceMapping(byte[] resourceContent, FileInfo target, uint uid = 0, uint gid = 0, UnixFileModes fileMode = Unix.FileMode644)
{
return WithResourceMapping(resourceContent, FilePath.Of(target.ToString()), uid, gid, fileMode);
}

/// <inheritdoc />
public TBuilderEntity WithResourceMapping(byte[] resourceContent, FilePath target, uint uid = 0, uint gid = 0, UnixFileModes fileMode = Unix.FileMode644)
{
return WithResourceMapping(new BinaryResourceMapping(resourceContent, target.Value, uid, gid, fileMode));
}

/// <inheritdoc />
public TBuilderEntity WithResourceMapping(string source, string target, uint uid = 0, uint gid = 0, UnixFileModes fileMode = Unix.FileMode644)
{
Expand All @@ -225,35 +237,67 @@ public TBuilderEntity WithResourceMapping(string source, string target, uint uid

if ((fileAttributes & FileAttributes.Directory) == FileAttributes.Directory)
{
return WithResourceMapping(new DirectoryInfo(source), target, uid, gid, fileMode);
return WithResourceMapping(DirectoryPath.Of(source), DirectoryPath.Of(target), uid, gid, fileMode);
}
else
{
return WithResourceMapping(new FileInfo(source), target, uid, gid, fileMode);
return WithResourceMapping(FilePath.Of(source), DirectoryPath.Of(target), uid, gid, fileMode);
}
}

/// <inheritdoc />
public TBuilderEntity WithResourceMapping(DirectoryInfo source, string target, uint uid = 0, uint gid = 0, UnixFileModes fileMode = Unix.FileMode644)
{
return WithResourceMapping(new FileResourceMapping(source.FullName, target, uid, gid, fileMode));
return WithResourceMapping(DirectoryPath.Of(source.FullName), DirectoryPath.Of(target), uid, gid, fileMode);
}

/// <inheritdoc />
public TBuilderEntity WithResourceMapping(DirectoryInfo source, DirectoryInfo target, uint uid = 0, uint gid = 0, UnixFileModes fileMode = Unix.FileMode644)
{
return WithResourceMapping(DirectoryPath.Of(source.FullName), DirectoryPath.Of(target.ToString()), uid, gid, fileMode);
}

/// <inheritdoc />
public TBuilderEntity WithResourceMapping(DirectoryPath source, DirectoryPath target, uint uid = 0, uint gid = 0, UnixFileModes fileMode = Unix.FileMode644)
{
return WithResourceMapping(new FileResourceMapping(source.Value, target.Value, uid, gid, fileMode));
}

/// <inheritdoc />
public TBuilderEntity WithResourceMapping(FileInfo source, string target, uint uid = 0, uint gid = 0, UnixFileModes fileMode = Unix.FileMode644)
{
return WithResourceMapping(new FileResourceMapping(source.FullName, target, uid, gid, fileMode));
return WithResourceMapping(FilePath.Of(source.FullName), DirectoryPath.Of(target), uid, gid, fileMode);
}

/// <inheritdoc />
public TBuilderEntity WithResourceMapping(FileInfo source, DirectoryInfo target, uint uid = 0, uint gid = 0, UnixFileModes fileMode = Unix.FileMode644)
{
return WithResourceMapping(FilePath.Of(source.FullName), DirectoryPath.Of(target.ToString()), uid, gid, fileMode);
}

/// <inheritdoc />
public TBuilderEntity WithResourceMapping(FilePath source, DirectoryPath target, uint uid = 0, uint gid = 0, UnixFileModes fileMode = Unix.FileMode644)
{
var fileName = Path.GetFileName(source.Value);
var filePath = FilePath.Of(Path.Combine(target.Value, fileName));
return WithResourceMapping(source, filePath, uid, gid, fileMode);
}

/// <inheritdoc />
public TBuilderEntity WithResourceMapping(FileInfo source, FileInfo target, uint uid = 0, uint gid = 0, UnixFileModes fileMode = Unix.FileMode644)
{
using (var fileStream = source.Open(FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
return WithResourceMapping(FilePath.Of(source.FullName), FilePath.Of(target.ToString()), uid, gid, fileMode);
}

/// <inheritdoc />
public TBuilderEntity WithResourceMapping(FilePath source, FilePath target, uint uid = 0, uint gid = 0, UnixFileModes fileMode = Unix.FileMode644)
{
using (var fileStream = new FileInfo(source.Value).Open(FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
{
using (var streamReader = new BinaryReader(fileStream))
{
var resourceContent = streamReader.ReadBytes((int)streamReader.BaseStream.Length);
return WithResourceMapping(resourceContent, target.ToString(), uid, gid, fileMode);
return WithResourceMapping(resourceContent, target, uid, gid, fileMode);
}
}
}
Expand All @@ -263,11 +307,50 @@ public TBuilderEntity WithResourceMapping(Uri source, string target, uint uid =
{
if (source.IsFile)
{
return WithResourceMapping(new FileResourceMapping(source.AbsolutePath, target, uid, gid, fileMode));
return WithResourceMapping(source, DirectoryPath.Of(target), uid, gid, fileMode);
}
else
{
return WithResourceMapping(source, FilePath.Of(target), uid, gid, fileMode);
}
}

/// <inheritdoc />
public TBuilderEntity WithResourceMapping(Uri source, DirectoryInfo target, uint uid = 0, uint gid = 0, UnixFileModes fileMode = Unix.FileMode644)
{
return WithResourceMapping(source, DirectoryPath.Of(target.ToString()), uid, gid, fileMode);
}

/// <inheritdoc />
public TBuilderEntity WithResourceMapping(Uri source, DirectoryPath target, uint uid = 0, uint gid = 0, UnixFileModes fileMode = Unix.FileMode644)
{
const string message = "The URI '{0}' does not contain a file name segment.";

var fileName = Path.GetFileName(source.LocalPath);

_ = Guard.Argument(source, nameof(source))
.ThrowIf(_ => string.IsNullOrEmpty(fileName), _ => new ArgumentException(string.Format(message, source), nameof(source)));

var filePath = FilePath.Of(Path.Combine(target.Value, fileName));
return WithResourceMapping(source, filePath, uid, gid, fileMode);
}

/// <inheritdoc />
public TBuilderEntity WithResourceMapping(Uri source, FileInfo target, uint uid = 0, uint gid = 0, UnixFileModes fileMode = Unix.FileMode644)
{
return WithResourceMapping(source, FilePath.Of(target.ToString()), uid, gid, fileMode);
}

/// <inheritdoc />
public TBuilderEntity WithResourceMapping(Uri source, FilePath target, uint uid = 0, uint gid = 0, UnixFileModes fileMode = Unix.FileMode644)
{
if (source.IsFile)
{
return WithResourceMapping(FilePath.Of(source.LocalPath), target, uid, gid, fileMode);
}
else
{
return WithResourceMapping(new UriResourceMapping(source, target, uid, gid, fileMode));
return WithResourceMapping(new UriResourceMapping(source, target.Value, uid, gid, fileMode));
}
}

Expand Down Expand Up @@ -433,7 +516,7 @@ protected virtual void ValidateLicenseAgreement()
!value.Environments.TryGetValue(AcceptLicenseAgreementEnvVar, out var licenseAgreementValue) || !AcceptLicenseAgreement.Equals(licenseAgreementValue, StringComparison.Ordinal);

_ = Guard.Argument(DockerResourceConfiguration, nameof(DockerResourceConfiguration.Image))
.ThrowIf(argument => licenseAgreementNotAccepted(argument.Value), argument => throw new ArgumentException(string.Format(message, DockerResourceConfiguration.Image.FullName), argument.Name));
.ThrowIf(argument => licenseAgreementNotAccepted(argument.Value), argument => new ArgumentException(string.Format(message, DockerResourceConfiguration.Image.FullName), argument.Name));
}

/// <summary>
Expand Down
55 changes: 55 additions & 0 deletions src/Testcontainers/Builders/DirectoryPath.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
namespace DotNet.Testcontainers.Builders
{
using System;
using DotNet.Testcontainers.Configurations;
using JetBrains.Annotations;

/// <summary>
/// Represents a container or host directory path.
/// </summary>
[PublicAPI]
public readonly record struct DirectoryPath
{
/// <summary>
/// Initializes a new instance of the <see cref="DirectoryPath" /> struct.
/// </summary>
/// <param name="value">The directory path value.</param>
private DirectoryPath(string value)
{
Value = value;
}

/// <summary>
/// Gets the normalized directory path value.
/// </summary>
[PublicAPI]
public string Value { get; }

/// <summary>
/// Creates a new <see cref="DirectoryPath" /> from the specified path.
/// </summary>
/// <param name="path">The directory path.</param>
/// <returns>The normalized <see cref="DirectoryPath" />.</returns>
[PublicAPI]
public static DirectoryPath Of(string path)
{
if (path == null)
{
throw new ArgumentNullException(nameof(path));
}

if (string.IsNullOrWhiteSpace(path))
{
throw new ArgumentException("The directory path cannot be empty.", nameof(path));
}

return new DirectoryPath(Unix.Instance.NormalizePath(path));
}

/// <inheritdoc />
public override string ToString()
{
return Value;
}
}
}
55 changes: 55 additions & 0 deletions src/Testcontainers/Builders/FilePath.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
namespace DotNet.Testcontainers.Builders
{
using System;
using DotNet.Testcontainers.Configurations;
using JetBrains.Annotations;

/// <summary>
/// Represents a container or host file path.
/// </summary>
[PublicAPI]
public readonly record struct FilePath
{
/// <summary>
/// Initializes a new instance of the <see cref="FilePath" /> struct.
/// </summary>
/// <param name="value">The file path value.</param>
private FilePath(string value)
{
Value = value;
}

/// <summary>
/// Gets the normalized file path value.
/// </summary>
[PublicAPI]
public string Value { get; }

/// <summary>
/// Creates a new <see cref="FilePath" /> from the specified path.
/// </summary>
/// <param name="path">The file path.</param>
/// <returns>The normalized <see cref="FilePath" />.</returns>
[PublicAPI]
public static FilePath Of(string path)
{
if (path == null)
{
throw new ArgumentNullException(nameof(path));
}

if (string.IsNullOrWhiteSpace(path))
{
throw new ArgumentException("The file path cannot be empty.", nameof(path));
}

return new FilePath(Unix.Instance.NormalizePath(path));
}

/// <inheritdoc />
public override string ToString()
{
return Value;
}
}
}
Loading
Loading