Skip to content

Commit 934e972

Browse files
feat: Add typed WithResourceMapping(...) overloads (#1497)
Co-authored-by: Andre Hofmeister <9199345+HofmeisterAn@users.noreply.github.com>
1 parent ab0dcd6 commit 934e972

File tree

11 files changed

+568
-32
lines changed

11 files changed

+568
-32
lines changed

docs/api/create_docker_container.md

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -60,35 +60,65 @@ _ = new ContainerBuilder("mcr.microsoft.com/dotnet/aspnet:10.0")
6060

6161
## Copying directories or files to the container
6262

63-
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.
63+
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`.
6464

6565
```csharp title="Copying a directory"
6666
_ = new ContainerBuilder("alpine:3.20.0")
67-
.WithResourceMapping(new DirectoryInfo("."), "/app/");
67+
.WithResourceMapping(
68+
DirectoryPath.Of("."),
69+
DirectoryPath.Of("/app/"));
6870
```
6971

7072
```csharp title="Copying a file"
7173
_ = new ContainerBuilder("alpine:3.20.0")
7274
// Copy 'appsettings.json' into the '/app' directory.
73-
.WithResourceMapping(new FileInfo("appsettings.json"), "/app/")
74-
// Copy 'appsettings.Container.json' to '/app/appsettings.Developer.json'.
75-
.WithResourceMapping(new FileInfo("appsettings.Container.json"), new FileInfo("/app/appsettings.Developer.json"));
75+
.WithResourceMapping(
76+
FilePath.Of("appsettings.json"),
77+
DirectoryPath.Of("/app/"))
78+
// Copy 'appsettings.Container.json' to '/app/appsettings.json'.
79+
.WithResourceMapping(
80+
FilePath.Of("appsettings.Container.json"),
81+
FilePath.Of("/app/appsettings.json"));
7682
```
7783

78-
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.
84+
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.
7985

8086
```csharp title="Copying a byte array"
8187
_ = new ContainerBuilder("alpine:3.20.0")
82-
.WithResourceMapping(Encoding.Default.GetBytes("{}"), "/app/appsettings.json");
88+
.WithResourceMapping(
89+
Encoding.UTF8.GetBytes("{}"),
90+
FilePath.Of("/app/appsettings.json"));
8391
```
8492

93+
For remote sources, you can copy a file from a URL to a target directory or file before the container starts.
94+
95+
=== "Copying to a directory"
96+
```csharp
97+
_ = new ContainerBuilder("alpine:3.20.0")
98+
.WithResourceMapping(
99+
new Uri("https://localhost:8080/appsettings.json"),
100+
DirectoryPath.Of("/app/"));
101+
```
102+
103+
=== "Copying to a file"
104+
```csharp
105+
_ = new ContainerBuilder("alpine:3.20.0")
106+
.WithResourceMapping(
107+
new Uri("https://localhost:8080/appsettings.json"),
108+
FilePath.Of("/app/appsettings.json"));
109+
```
110+
85111
### Specifying file ownership
86112

87113
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.
88114

89115
```csharp title="Copying a file with specific UID and GID"
90116
_ = new ContainerBuilder("alpine:3.20.0")
91-
.WithResourceMapping(new DirectoryInfo("."), "/app/", uid: 1000, gid: 1000);
117+
.WithResourceMapping(
118+
FilePath.Of("appsettings.json"),
119+
DirectoryPath.Of("/app/"),
120+
uid: 1000,
121+
gid: 1000);
92122
```
93123

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

98128
```csharp title="Copying a script with executable permissions"
99129
_ = new ContainerBuilder("alpine:3.20.0")
100-
.WithResourceMapping(new DirectoryInfo("."), "/app/", fileMode: Unix.FileMode755);
130+
.WithResourceMapping(
131+
FilePath.Of("docker-entrypoint.sh"),
132+
DirectoryPath.Of("/app/"),
133+
fileMode: Unix.FileMode755);
101134
```
102135

103136
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.

src/Testcontainers.EventHubs/EventHubsBuilder.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ protected override void Validate()
159159

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

165165
/// <inheritdoc />

src/Testcontainers.Neo4j/Neo4jBuilder.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ protected override void Validate()
149149
&& (!value.Environments.TryGetValue(AcceptLicenseAgreementEnvVar, out var licenseAgreementValue) || !AcceptLicenseAgreement.Equals(licenseAgreementValue, StringComparison.Ordinal));
150150

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

155155
/// <inheritdoc />

src/Testcontainers.Oracle/OracleBuilder.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ protected override void Validate()
147147
value.Database != null && value.Image.MatchVersion(v => v.Major < 18);
148148

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

152152
_ = Guard.Argument(DockerResourceConfiguration.Username, nameof(DockerResourceConfiguration.Username))
153153
.NotNull()

src/Testcontainers/Builders/ContainerBuilder`3.cs

Lines changed: 92 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,18 @@ public TBuilderEntity WithResourceMapping(byte[] resourceContent, string filePat
213213
return WithResourceMapping(new BinaryResourceMapping(resourceContent, filePath, uid, gid, fileMode));
214214
}
215215

216+
/// <inheritdoc />
217+
public TBuilderEntity WithResourceMapping(byte[] resourceContent, FileInfo target, uint uid = 0, uint gid = 0, UnixFileModes fileMode = Unix.FileMode644)
218+
{
219+
return WithResourceMapping(resourceContent, FilePath.Of(target.ToString()), uid, gid, fileMode);
220+
}
221+
222+
/// <inheritdoc />
223+
public TBuilderEntity WithResourceMapping(byte[] resourceContent, FilePath target, uint uid = 0, uint gid = 0, UnixFileModes fileMode = Unix.FileMode644)
224+
{
225+
return WithResourceMapping(new BinaryResourceMapping(resourceContent, target.Value, uid, gid, fileMode));
226+
}
227+
216228
/// <inheritdoc />
217229
public TBuilderEntity WithResourceMapping(string source, string target, uint uid = 0, uint gid = 0, UnixFileModes fileMode = Unix.FileMode644)
218230
{
@@ -225,35 +237,67 @@ public TBuilderEntity WithResourceMapping(string source, string target, uint uid
225237

226238
if ((fileAttributes & FileAttributes.Directory) == FileAttributes.Directory)
227239
{
228-
return WithResourceMapping(new DirectoryInfo(source), target, uid, gid, fileMode);
240+
return WithResourceMapping(DirectoryPath.Of(source), DirectoryPath.Of(target), uid, gid, fileMode);
229241
}
230242
else
231243
{
232-
return WithResourceMapping(new FileInfo(source), target, uid, gid, fileMode);
244+
return WithResourceMapping(FilePath.Of(source), DirectoryPath.Of(target), uid, gid, fileMode);
233245
}
234246
}
235247

236248
/// <inheritdoc />
237249
public TBuilderEntity WithResourceMapping(DirectoryInfo source, string target, uint uid = 0, uint gid = 0, UnixFileModes fileMode = Unix.FileMode644)
238250
{
239-
return WithResourceMapping(new FileResourceMapping(source.FullName, target, uid, gid, fileMode));
251+
return WithResourceMapping(DirectoryPath.Of(source.FullName), DirectoryPath.Of(target), uid, gid, fileMode);
252+
}
253+
254+
/// <inheritdoc />
255+
public TBuilderEntity WithResourceMapping(DirectoryInfo source, DirectoryInfo target, uint uid = 0, uint gid = 0, UnixFileModes fileMode = Unix.FileMode644)
256+
{
257+
return WithResourceMapping(DirectoryPath.Of(source.FullName), DirectoryPath.Of(target.ToString()), uid, gid, fileMode);
258+
}
259+
260+
/// <inheritdoc />
261+
public TBuilderEntity WithResourceMapping(DirectoryPath source, DirectoryPath target, uint uid = 0, uint gid = 0, UnixFileModes fileMode = Unix.FileMode644)
262+
{
263+
return WithResourceMapping(new FileResourceMapping(source.Value, target.Value, uid, gid, fileMode));
240264
}
241265

242266
/// <inheritdoc />
243267
public TBuilderEntity WithResourceMapping(FileInfo source, string target, uint uid = 0, uint gid = 0, UnixFileModes fileMode = Unix.FileMode644)
244268
{
245-
return WithResourceMapping(new FileResourceMapping(source.FullName, target, uid, gid, fileMode));
269+
return WithResourceMapping(FilePath.Of(source.FullName), DirectoryPath.Of(target), uid, gid, fileMode);
270+
}
271+
272+
/// <inheritdoc />
273+
public TBuilderEntity WithResourceMapping(FileInfo source, DirectoryInfo target, uint uid = 0, uint gid = 0, UnixFileModes fileMode = Unix.FileMode644)
274+
{
275+
return WithResourceMapping(FilePath.Of(source.FullName), DirectoryPath.Of(target.ToString()), uid, gid, fileMode);
276+
}
277+
278+
/// <inheritdoc />
279+
public TBuilderEntity WithResourceMapping(FilePath source, DirectoryPath target, uint uid = 0, uint gid = 0, UnixFileModes fileMode = Unix.FileMode644)
280+
{
281+
var fileName = Path.GetFileName(source.Value);
282+
var filePath = FilePath.Of(Path.Combine(target.Value, fileName));
283+
return WithResourceMapping(source, filePath, uid, gid, fileMode);
246284
}
247285

248286
/// <inheritdoc />
249287
public TBuilderEntity WithResourceMapping(FileInfo source, FileInfo target, uint uid = 0, uint gid = 0, UnixFileModes fileMode = Unix.FileMode644)
250288
{
251-
using (var fileStream = source.Open(FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
289+
return WithResourceMapping(FilePath.Of(source.FullName), FilePath.Of(target.ToString()), uid, gid, fileMode);
290+
}
291+
292+
/// <inheritdoc />
293+
public TBuilderEntity WithResourceMapping(FilePath source, FilePath target, uint uid = 0, uint gid = 0, UnixFileModes fileMode = Unix.FileMode644)
294+
{
295+
using (var fileStream = new FileInfo(source.Value).Open(FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
252296
{
253297
using (var streamReader = new BinaryReader(fileStream))
254298
{
255299
var resourceContent = streamReader.ReadBytes((int)streamReader.BaseStream.Length);
256-
return WithResourceMapping(resourceContent, target.ToString(), uid, gid, fileMode);
300+
return WithResourceMapping(resourceContent, target, uid, gid, fileMode);
257301
}
258302
}
259303
}
@@ -263,11 +307,50 @@ public TBuilderEntity WithResourceMapping(Uri source, string target, uint uid =
263307
{
264308
if (source.IsFile)
265309
{
266-
return WithResourceMapping(new FileResourceMapping(source.AbsolutePath, target, uid, gid, fileMode));
310+
return WithResourceMapping(source, DirectoryPath.Of(target), uid, gid, fileMode);
311+
}
312+
else
313+
{
314+
return WithResourceMapping(source, FilePath.Of(target), uid, gid, fileMode);
315+
}
316+
}
317+
318+
/// <inheritdoc />
319+
public TBuilderEntity WithResourceMapping(Uri source, DirectoryInfo target, uint uid = 0, uint gid = 0, UnixFileModes fileMode = Unix.FileMode644)
320+
{
321+
return WithResourceMapping(source, DirectoryPath.Of(target.ToString()), uid, gid, fileMode);
322+
}
323+
324+
/// <inheritdoc />
325+
public TBuilderEntity WithResourceMapping(Uri source, DirectoryPath target, uint uid = 0, uint gid = 0, UnixFileModes fileMode = Unix.FileMode644)
326+
{
327+
const string message = "The URI '{0}' does not contain a file name segment.";
328+
329+
var fileName = Path.GetFileName(source.LocalPath);
330+
331+
_ = Guard.Argument(source, nameof(source))
332+
.ThrowIf(_ => string.IsNullOrEmpty(fileName), _ => new ArgumentException(string.Format(message, source), nameof(source)));
333+
334+
var filePath = FilePath.Of(Path.Combine(target.Value, fileName));
335+
return WithResourceMapping(source, filePath, uid, gid, fileMode);
336+
}
337+
338+
/// <inheritdoc />
339+
public TBuilderEntity WithResourceMapping(Uri source, FileInfo target, uint uid = 0, uint gid = 0, UnixFileModes fileMode = Unix.FileMode644)
340+
{
341+
return WithResourceMapping(source, FilePath.Of(target.ToString()), uid, gid, fileMode);
342+
}
343+
344+
/// <inheritdoc />
345+
public TBuilderEntity WithResourceMapping(Uri source, FilePath target, uint uid = 0, uint gid = 0, UnixFileModes fileMode = Unix.FileMode644)
346+
{
347+
if (source.IsFile)
348+
{
349+
return WithResourceMapping(FilePath.Of(source.LocalPath), target, uid, gid, fileMode);
267350
}
268351
else
269352
{
270-
return WithResourceMapping(new UriResourceMapping(source, target, uid, gid, fileMode));
353+
return WithResourceMapping(new UriResourceMapping(source, target.Value, uid, gid, fileMode));
271354
}
272355
}
273356

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

435518
_ = Guard.Argument(DockerResourceConfiguration, nameof(DockerResourceConfiguration.Image))
436-
.ThrowIf(argument => licenseAgreementNotAccepted(argument.Value), argument => throw new ArgumentException(string.Format(message, DockerResourceConfiguration.Image.FullName), argument.Name));
519+
.ThrowIf(argument => licenseAgreementNotAccepted(argument.Value), argument => new ArgumentException(string.Format(message, DockerResourceConfiguration.Image.FullName), argument.Name));
437520
}
438521

439522
/// <summary>
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
namespace DotNet.Testcontainers.Builders
2+
{
3+
using System;
4+
using DotNet.Testcontainers.Configurations;
5+
using JetBrains.Annotations;
6+
7+
/// <summary>
8+
/// Represents a container or host directory path.
9+
/// </summary>
10+
[PublicAPI]
11+
public readonly record struct DirectoryPath
12+
{
13+
/// <summary>
14+
/// Initializes a new instance of the <see cref="DirectoryPath" /> struct.
15+
/// </summary>
16+
/// <param name="value">The directory path value.</param>
17+
private DirectoryPath(string value)
18+
{
19+
Value = value;
20+
}
21+
22+
/// <summary>
23+
/// Gets the normalized directory path value.
24+
/// </summary>
25+
[PublicAPI]
26+
public string Value { get; }
27+
28+
/// <summary>
29+
/// Creates a new <see cref="DirectoryPath" /> from the specified path.
30+
/// </summary>
31+
/// <param name="path">The directory path.</param>
32+
/// <returns>The normalized <see cref="DirectoryPath" />.</returns>
33+
[PublicAPI]
34+
public static DirectoryPath Of(string path)
35+
{
36+
if (path == null)
37+
{
38+
throw new ArgumentNullException(nameof(path));
39+
}
40+
41+
if (string.IsNullOrWhiteSpace(path))
42+
{
43+
throw new ArgumentException("The directory path cannot be empty.", nameof(path));
44+
}
45+
46+
return new DirectoryPath(Unix.Instance.NormalizePath(path));
47+
}
48+
49+
/// <inheritdoc />
50+
public override string ToString()
51+
{
52+
return Value;
53+
}
54+
}
55+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
namespace DotNet.Testcontainers.Builders
2+
{
3+
using System;
4+
using DotNet.Testcontainers.Configurations;
5+
using JetBrains.Annotations;
6+
7+
/// <summary>
8+
/// Represents a container or host file path.
9+
/// </summary>
10+
[PublicAPI]
11+
public readonly record struct FilePath
12+
{
13+
/// <summary>
14+
/// Initializes a new instance of the <see cref="FilePath" /> struct.
15+
/// </summary>
16+
/// <param name="value">The file path value.</param>
17+
private FilePath(string value)
18+
{
19+
Value = value;
20+
}
21+
22+
/// <summary>
23+
/// Gets the normalized file path value.
24+
/// </summary>
25+
[PublicAPI]
26+
public string Value { get; }
27+
28+
/// <summary>
29+
/// Creates a new <see cref="FilePath" /> from the specified path.
30+
/// </summary>
31+
/// <param name="path">The file path.</param>
32+
/// <returns>The normalized <see cref="FilePath" />.</returns>
33+
[PublicAPI]
34+
public static FilePath Of(string path)
35+
{
36+
if (path == null)
37+
{
38+
throw new ArgumentNullException(nameof(path));
39+
}
40+
41+
if (string.IsNullOrWhiteSpace(path))
42+
{
43+
throw new ArgumentException("The file path cannot be empty.", nameof(path));
44+
}
45+
46+
return new FilePath(Unix.Instance.NormalizePath(path));
47+
}
48+
49+
/// <inheritdoc />
50+
public override string ToString()
51+
{
52+
return Value;
53+
}
54+
}
55+
}

0 commit comments

Comments
 (0)