diff --git a/Testcontainers.dic b/Testcontainers.dic index 16a6cc3e4..fd74cff87 100644 --- a/Testcontainers.dic +++ b/Testcontainers.dic @@ -1,6 +1,7 @@ awslocal azurecr azurite +buildx capi creds dind @@ -32,6 +33,7 @@ rebalance redpanda ryuk servercore +shm sqlplus testcontainer testcontainers diff --git a/docs/api/create_docker_image.md b/docs/api/create_docker_image.md index b45836daf..6aedc4d2e 100644 --- a/docs/api/create_docker_image.md +++ b/docs/api/create_docker_image.md @@ -4,7 +4,7 @@ Testcontainers for .NET uses the builder design pattern to configure, create and !!! warning - BuildKit features are not supported through the Docker Engine API. As a result, Dockerfile instructions and options that depend on BuildKit cannot be used with Testcontainers' image builder API. For more details, see this [discussion](https://github.com/testcontainers/testcontainers-dotnet/discussions/1193#discussioncomment-10315903). + `ImageFromDockerfileBuilder` builds the image through the Docker Engine API, which uses the legacy builder. BuildKit features are not supported through the Docker Engine API. As a result, Dockerfile instructions and options that depend on BuildKit cannot be used with it. For more details, see this [discussion](https://github.com/testcontainers/testcontainers-dotnet/discussions/1193#discussioncomment-10315903). Use [`BuildKitImageFromDockerfileBuilder`](#building-with-buildkit) to build such a Dockerfile. ## Examples @@ -107,6 +107,68 @@ _ = new ImageFromDockerfileBuilder() .WithBuildArgument("RESOURCE_REAPER_SESSION_ID", ResourceReaper.DefaultSessionId.ToString("D")); ``` +## Building with BuildKit + +`BuildKitImageFromDockerfileBuilder` builds the image with BuildKit (`docker buildx build`) instead of the Docker Engine API. Its configuration is the same as the one of `ImageFromDockerfileBuilder`, plus the members that only BuildKit supports. Use it for a Dockerfile that depends on BuildKit, such as one that contains a here-document, mounts a build secret, or selects a frontend with `# syntax=`. + +```csharp +var futureImage = new BuildKitImageFromDockerfileBuilder() + .WithDockerfileDirectory(CommonDirectoryPath.GetSolutionDirectory(), string.Empty) + .WithDockerfile("Dockerfile") + .Build(); + +await futureImage.CreateAsync() + .ConfigureAwait(false); +``` + +The Docker CLI runs inside a container. Testcontainers copies the build context into that container, and mounts the Docker socket so the Docker CLI can reach the Docker daemon. You do not need a Docker CLI installation on the test host. The build itself runs in the BuildKit instance of the Docker daemon (the default `docker` Buildx driver), which is also where the build cache lives. The cache therefore outlives the container that starts the build, and is shared across builds the same way it is when you run `docker build` yourself. + +The image is written to the image store of the Docker daemon (`--load`), so everything that follows the build behaves as it does with `ImageFromDockerfileBuilder`, including `WithImage(IImage)` and the Resource Reaper labels. + +The Docker CLI image is configurable and pinned to a default. Pass a different one to run a specific Docker CLI and Buildx version. The image requires the Buildx plugin. + +```csharp +_ = new BuildKitImageFromDockerfileBuilder("docker:29-cli"); +``` + +!!! warning + + The Docker socket is bind-mounted into the Docker CLI container. The Docker daemon resolves the mount source, which is why a Docker daemon that is reached over TCP works too, as long as it listens on a Unix socket as well. A Docker daemon that does not provide a Unix socket at all, such as a Docker daemon that is reached over a Windows named pipe and runs Windows containers, cannot be used. Set `TestcontainersSettings.DockerSocketOverride` (or `TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE`) if the Docker socket is not at `/var/run/docker.sock`, or keep using `ImageFromDockerfileBuilder`. + +### Build secrets + +`WithSecret(string, string)` and `WithSecret(string, FileInfo)` pass a build secret to the build. The Dockerfile mounts it with `RUN --mount=type=secret,id=`, which makes it available at `/run/secrets/` for the duration of that instruction only. BuildKit does not add it to a layer of the built image. + +```csharp +_ = new BuildKitImageFromDockerfileBuilder() + .WithDockerfileDirectory(CommonDirectoryPath.GetSolutionDirectory(), string.Empty) + .WithSecret("nuget", new FileInfo("/path/to/nuget.config")); +``` + +```dockerfile +FROM mcr.microsoft.com/dotnet/sdk:8.0 +COPY . . +RUN --mount=type=secret,id=nuget dotnet restore --configfile /run/secrets/nuget +``` + +Testcontainers copies the build secret into the Docker CLI container that runs the build. It is not part of the build context, and is not passed as a build argument or an environment variable. The container that runs the build is removed after the build, no matter whether the cleanup of the image is enabled or not. + +### SSH agents + +`WithSshAgent(string, params string[])` passes an SSH agent socket or private key to the build. The Dockerfile mounts it with `RUN --mount=type=ssh,id=`. Use the id `default` for a mount that does not name an id. Each path is bind-mounted read-only into the Docker CLI container, keeping the path it has on the test host, so the paths must exist on the host that runs the Docker daemon. A path cannot contain a comma, which the Docker CLI uses to separate the paths of an SSH agent. + +```csharp +_ = new BuildKitImageFromDockerfileBuilder() + .WithDockerfileDirectory(CommonDirectoryPath.GetSolutionDirectory(), string.Empty) + .WithSshAgent("default", Environment.GetEnvironmentVariable("SSH_AUTH_SOCK")); +``` + +### Platform + +`WithPlatform(string)` builds the image for a platform other than the platform of the Docker host, for example `linux/arm64`. Building for a foreign platform requires emulation, such as QEMU. + +A comma-separated value builds a manifest list, for example `linux/amd64,linux/arm64`. Loading one into the image store of the Docker daemon requires the containerd image store. The classic image store takes a single platform only and the build fails. + ## Supported commands | Builder method | Description | @@ -123,10 +185,22 @@ _ = new ImageFromDockerfileBuilder() | `WithBuildArgument` | Sets build-time variables e.g `--build-arg "MAGIC_NUMBER=42"`. | | `WithCreateParameterModifier` | Allows low level modifications of the Docker image build parameter. | +`BuildKitImageFromDockerfileBuilder` supports the same members, and additionally: + +| Builder method | Description | +|-----------------|--------------------------------------------------------------------------| +| `WithSecret` | Sets a build secret e.g. `--secret "id=aws,src=$HOME/.aws/credentials"`. | +| `WithSshAgent` | Sets an SSH agent socket or private key e.g. `--ssh "default"`. | +| `WithPlatform` | Sets the platform to build the image for e.g. `--platform "linux/arm64"`.| + !!! tip Testcontainers for .NET detects your Docker host configuration. You do **not** have to set the Docker daemon socket. +!!! note + + `BuildKitImageFromDockerfileBuilder` translates the image build parameter (`WithCreateParameterModifier`) into Docker CLI arguments. The Dockerfile, the tags, the build arguments, the labels, the target and the platform are passed on, and so are `NoCache` (`--no-cache`), `Pull` (`--pull`), `NetworkMode` (`--network`), `ShmSize` (`--shm-size`), `ExtraHosts` (`--add-host`) and `CacheFrom` (`--cache-from`). A parameter that the Docker CLI does not provide an equivalent argument for, such as the resource limits of the legacy builder (`Memory`, `CPUShares`) or `Squash`, is logged as a warning instead of being applied. + ## Known issues - When building an image using Testcontainers for .NET and switching the user's context (`USER` statement) in a Dockerfile, the user won't automatically become the [owner](https://github.com/testcontainers/testcontainers-dotnet/issues/1171#issuecomment-2099197840) of the working directory, which seems to be the case when building the image from the CLI. If the running process requires write access to the working directory, it is necessary to set the permissions explicitly (the base image in this example already contains the user `app`): diff --git a/src/Testcontainers/Builders/BuildKitImageFromDockerfileBuilder.cs b/src/Testcontainers/Builders/BuildKitImageFromDockerfileBuilder.cs new file mode 100644 index 000000000..5b75e7f3a --- /dev/null +++ b/src/Testcontainers/Builders/BuildKitImageFromDockerfileBuilder.cs @@ -0,0 +1,372 @@ +namespace DotNet.Testcontainers.Builders +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Text.RegularExpressions; + using Docker.DotNet.Models; + using DotNet.Testcontainers.Configurations; + using DotNet.Testcontainers.Images; + using JetBrains.Annotations; + + /// + /// + /// Builds the Docker image with BuildKit (docker buildx build) instead of + /// the Docker Engine API, which uses the legacy builder. Dockerfile instructions + /// and options that require BuildKit, such as here-documents, + /// RUN --mount=type=secret and # syntax= frontends, are only + /// available with this builder. + /// + /// The Docker CLI runs inside a container. The build context is copied into the + /// container, and the Docker socket is mounted to interact with the Docker host. + /// This does not require a Docker CLI installation on the test host. The build + /// itself runs in the BuildKit instance of the Docker daemon (the default + /// docker buildx driver), which is also where the build cache is kept. + /// The built image is written to the image store of the Docker daemon + /// (--load), so it can be used like any other image. + /// + /// + /// The default configuration is equivalent to: + /// + /// _ = new BuildKitImageFromDockerfileBuilder() + /// .WithDockerEndpoint(TestcontainersSettings.OS.DockerEndpointAuthConfig) + /// .WithLabel(DefaultLabels.Instance) + /// .WithCleanUp(true) + /// .WithImageBuildPolicy(PullPolicy.Always) + /// .WithDockerfile("Dockerfile") + /// .WithDockerfileDirectory(Directory.GetCurrentDirectory()) + /// .WithName(new DockerImage("localhost/testcontainers", Guid.NewGuid().ToString("D"), string.Empty)) + /// .Build(); + /// + /// + [PublicAPI] + public sealed class BuildKitImageFromDockerfileBuilder : AbstractBuilder, IImageFromDockerfileBuilder + { + /// + /// The Docker CLI image that is used if no image is set. + /// + private const string DefaultCliImage = "docker:29.7.2-cli"; + + /// + /// The pattern that a build secret id and an SSH agent id must match. + /// + /// + /// The id is part of the path of the file that carries the build secret inside + /// the Docker CLI container, and part of the Docker CLI argument that + /// references it. + /// + private static readonly Regex IdRegex = new Regex("^[A-Za-z0-9][A-Za-z0-9_.-]*$", RegexOptions.None, TimeSpan.FromSeconds(1)); + + /// + /// Initializes a new instance of the class. + /// + public BuildKitImageFromDockerfileBuilder() + : this(new DockerImage(DefaultCliImage)) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// + /// The full Docker image name, including the image repository and tag + /// (e.g., docker:29-cli). + /// + /// + /// The image requires the Docker Buildx plugin. Docker image tags available at + /// . + /// + public BuildKitImageFromDockerfileBuilder(string cliImage) + : this(new DockerImage(cliImage)) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// + /// An instance that specifies the Docker image that runs + /// the image build. + /// + /// + /// The image requires the Docker Buildx plugin. Docker image tags available at + /// . + /// + public BuildKitImageFromDockerfileBuilder(IImage cliImage) + : this(new BuildKitImageFromDockerfileConfiguration()) + { + DockerResourceConfiguration = Init().WithCliImage(cliImage).DockerResourceConfiguration; + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + private BuildKitImageFromDockerfileBuilder(IBuildKitImageFromDockerfileConfiguration dockerResourceConfiguration) + : base(dockerResourceConfiguration) + { + DockerResourceConfiguration = dockerResourceConfiguration; + } + + /// + protected override IBuildKitImageFromDockerfileConfiguration DockerResourceConfiguration { get; } + + /// + public BuildKitImageFromDockerfileBuilder WithName(string name) + { + return WithName(new DockerImage(name)); + } + + /// + public BuildKitImageFromDockerfileBuilder WithName(IImage image) + { + return Merge(DockerResourceConfiguration, new BuildKitImageFromDockerfileConfiguration(image: image.ApplyImageNameSubstitution())); + } + + /// + public BuildKitImageFromDockerfileBuilder WithContextDirectory(string contextDirectory) + { + return Merge(DockerResourceConfiguration, new BuildKitImageFromDockerfileConfiguration(contextDirectory: contextDirectory)); + } + + /// + public BuildKitImageFromDockerfileBuilder WithDockerfile(string dockerfile) + { + var dockerfileFilePath = Regex.Replace(dockerfile, "^\\.(\\/|\\\\)", string.Empty, RegexOptions.None, TimeSpan.FromSeconds(1)); + return Merge(DockerResourceConfiguration, new BuildKitImageFromDockerfileConfiguration(dockerfile: dockerfileFilePath)); + } + + /// + public BuildKitImageFromDockerfileBuilder WithDockerfileDirectory(string dockerfileDirectory) + { + return Merge(DockerResourceConfiguration, new BuildKitImageFromDockerfileConfiguration(dockerfileDirectory: dockerfileDirectory)); + } + + /// + public BuildKitImageFromDockerfileBuilder WithDockerfileDirectory(CommonDirectoryPath commonDirectoryPath, string dockerfileDirectory) + { + var dockerfileDirectoryPath = Path.Combine(commonDirectoryPath.DirectoryPath, dockerfileDirectory); + return Merge(DockerResourceConfiguration, new BuildKitImageFromDockerfileConfiguration(dockerfileDirectory: dockerfileDirectoryPath)); + } + + /// + public BuildKitImageFromDockerfileBuilder WithTarget(string target) + { + return Merge(DockerResourceConfiguration, new BuildKitImageFromDockerfileConfiguration(target: target)); + } + + /// + public BuildKitImageFromDockerfileBuilder WithImageBuildPolicy(Func imageBuildPolicy) + { + return Merge(DockerResourceConfiguration, new BuildKitImageFromDockerfileConfiguration(imageBuildPolicy: imageBuildPolicy)); + } + + /// + public BuildKitImageFromDockerfileBuilder WithDeleteIfExists(bool deleteIfExists) + { + return Merge(DockerResourceConfiguration, new BuildKitImageFromDockerfileConfiguration(deleteIfExists: deleteIfExists)); + } + + /// + public BuildKitImageFromDockerfileBuilder WithBuildArgument(string name, string value) + { + var buildArguments = new Dictionary { { name, value } }; + return Merge(DockerResourceConfiguration, new BuildKitImageFromDockerfileConfiguration(buildArguments: buildArguments)); + } + + /// + /// Sets the platform to build the image for. + /// + /// + /// The build result is written to the image store of the Docker daemon, which + /// takes a single platform only. Building an image for a platform other than + /// the platform of the Docker host requires emulation, such as QEMU. + /// + /// The platform to build the image for e.g. --platform "linux/arm64". + /// A configured instance of . + public BuildKitImageFromDockerfileBuilder WithPlatform(string platform) + { + return Merge(DockerResourceConfiguration, new BuildKitImageFromDockerfileConfiguration(platform: platform)); + } + + /// + /// Sets a build secret. + /// + /// + /// The Dockerfile mounts the build secret with + /// RUN --mount=type=secret,id=<id>, which makes it available at + /// /run/secrets/<id> for the duration of that instruction only. + /// BuildKit does not add the build secret to a layer of the built image. + /// + /// The build secret value is copied into the Docker CLI container that runs the + /// image build. It is not part of the build context, and is not passed as a + /// build argument or an environment variable. + /// + /// The build secret id e.g. --secret "id=aws,src=$HOME/.aws/credentials". + /// The build secret value. + /// A configured instance of . + public BuildKitImageFromDockerfileBuilder WithSecret(string id, string value) + { + var secrets = new[] { new BuildSecret(id, value) }; + return Merge(DockerResourceConfiguration, new BuildKitImageFromDockerfileConfiguration(secrets: secrets)); + } + + /// + /// Sets a build secret. + /// + /// + /// The Dockerfile mounts the build secret with + /// RUN --mount=type=secret,id=<id>, which makes it available at + /// /run/secrets/<id> for the duration of that instruction only. + /// BuildKit does not add the build secret to a layer of the built image. + /// + /// The build secret value is copied into the Docker CLI container that runs the + /// image build. It is not part of the build context, and is not passed as a + /// build argument or an environment variable. + /// + /// The build secret id e.g. --secret "id=aws,src=$HOME/.aws/credentials". + /// The file on the test host that contains the build secret value. + /// A configured instance of . + public BuildKitImageFromDockerfileBuilder WithSecret(string id, FileInfo source) + { + var secrets = new[] { new BuildSecret(id, source) }; + return Merge(DockerResourceConfiguration, new BuildKitImageFromDockerfileConfiguration(secrets: secrets)); + } + + /// + /// Sets an SSH agent socket or private key. + /// + /// + /// The Dockerfile mounts the SSH agent with + /// RUN --mount=type=ssh,id=<id>. Use the id default for a + /// mount that does not name an id. + /// + /// Each path is bind-mounted read-only into the Docker CLI container, keeping + /// the path it has on the test host. The Docker daemon resolves the mount + /// source, so the paths must exist on the host that runs the Docker daemon. + /// + /// The SSH agent id e.g. --ssh "default=$SSH_AUTH_SOCK". + /// A list of SSH agent socket or private key paths on the test host. + /// A configured instance of . + public BuildKitImageFromDockerfileBuilder WithSshAgent(string id, params string[] paths) + { + var sshAgents = new Dictionary> { { id, paths.Select(Path.GetFullPath).ToArray() } }; + return Merge(DockerResourceConfiguration, new BuildKitImageFromDockerfileConfiguration(sshAgents: sshAgents)); + } + + /// + public override IFutureDockerImage Build() + { + Validate(); + return new BuildKitDockerImage(DockerResourceConfiguration); + } + + /// + protected override BuildKitImageFromDockerfileBuilder Init() + { + return base.Init().WithImageBuildPolicy(PullPolicy.Always).WithDockerfile("Dockerfile").WithDockerfileDirectory(Directory.GetCurrentDirectory()).WithName(new DockerImage(string.Join("/", "localhost", "testcontainers", Guid.NewGuid().ToString("D")))); + } + + /// + protected override void Validate() + { + base.Validate(); + + const string reuseNotSupported = "Building an image does not support the reuse feature. To keep the built image, disable the cleanup."; + _ = Guard.Argument(DockerResourceConfiguration, nameof(DockerResourceConfiguration.Reuse)) + .ThrowIf(argument => argument.Value.Reuse.HasValue && argument.Value.Reuse.Value, argument => new ArgumentException(reuseNotSupported, argument.Name)); + + _ = Guard.Argument(DockerResourceConfiguration.CliImage, nameof(DockerResourceConfiguration.CliImage)) + .NotNull(); + + const string secretIdInvalid = "The build secret id '{0}' must start with a letter or digit and can only contain letters, digits, dots, dashes, and underscores."; + _ = Guard.Argument(DockerResourceConfiguration.Secrets, nameof(DockerResourceConfiguration.Secrets)) + .ThrowIf(argument => argument.Value.Any(secret => !IsIdValid(secret.Id)), argument => new ArgumentException(string.Format(secretIdInvalid, argument.Value.First(secret => !IsIdValid(secret.Id)).Id), argument.Name)); + + const string secretIdNotUnique = "The build secret id '{0}' is set more than once."; + _ = Guard.Argument(DockerResourceConfiguration.Secrets, nameof(DockerResourceConfiguration.Secrets)) + .ThrowIf(argument => argument.Value.GroupBy(secret => secret.Id).Any(group => group.Count() > 1), argument => new ArgumentException(string.Format(secretIdNotUnique, argument.Value.GroupBy(secret => secret.Id).First(group => group.Count() > 1).Key), argument.Name)); + + const string secretSourceDoesNotExist = "The build secret file '{0}' does not exist."; + _ = Guard.Argument(DockerResourceConfiguration.Secrets, nameof(DockerResourceConfiguration.Secrets)) + .ThrowIf(argument => argument.Value.Any(IsSecretSourceMissing), argument => new FileNotFoundException(string.Format(secretSourceDoesNotExist, argument.Value.First(IsSecretSourceMissing).SourceFilePath))); + + const string sshAgentIdInvalid = "The SSH agent id '{0}' must start with a letter or digit and can only contain letters, digits, dots, dashes, and underscores."; + _ = Guard.Argument(DockerResourceConfiguration.SshAgents, nameof(DockerResourceConfiguration.SshAgents)) + .ThrowIf(argument => argument.Value.Keys.Any(id => !IsIdValid(id)), argument => new ArgumentException(string.Format(sshAgentIdInvalid, argument.Value.Keys.First(id => !IsIdValid(id))), argument.Name)); + + const string sshAgentPathInvalid = "The SSH agent path '{0}' cannot contain a comma, which separates the paths of an SSH agent."; + _ = Guard.Argument(DockerResourceConfiguration.SshAgents, nameof(DockerResourceConfiguration.SshAgents)) + .ThrowIf(argument => GetSshAgentPaths(argument.Value).Any(IsPathInvalid), argument => new ArgumentException(string.Format(sshAgentPathInvalid, GetSshAgentPaths(argument.Value).First(IsPathInvalid)), argument.Name)); + } + + /// + protected override BuildKitImageFromDockerfileBuilder Clone(IResourceConfiguration resourceConfiguration) + { + return Merge(DockerResourceConfiguration, new BuildKitImageFromDockerfileConfiguration(resourceConfiguration)); + } + + /// + protected override BuildKitImageFromDockerfileBuilder Merge(IBuildKitImageFromDockerfileConfiguration oldValue, IBuildKitImageFromDockerfileConfiguration newValue) + { + return new BuildKitImageFromDockerfileBuilder(new BuildKitImageFromDockerfileConfiguration(oldValue, newValue)); + } + + /// + /// Sets the Docker CLI image that runs the image build. + /// + /// The Docker CLI image. + /// A configured instance of . + private BuildKitImageFromDockerfileBuilder WithCliImage(IImage cliImage) + { + return Merge(DockerResourceConfiguration, new BuildKitImageFromDockerfileConfiguration(cliImage: cliImage)); + } + + /// + /// Checks whether a build secret or SSH agent id is valid or not. + /// + /// The build secret or SSH agent id. + /// True if the id is valid; otherwise, false. + private static bool IsIdValid(string id) + { + return !string.IsNullOrEmpty(id) && IdRegex.IsMatch(id); + } + + /// + /// Checks whether the file that contains the build secret value is missing or + /// not. + /// + /// The build secret. + /// True if the build secret reads its value from a file that does not exist; otherwise, false. + private static bool IsSecretSourceMissing(BuildSecret secret) + { + return !string.IsNullOrEmpty(secret.SourceFilePath) && !File.Exists(secret.SourceFilePath); + } + + /// + /// Gets the SSH agent socket and private key paths of all SSH agents. + /// + /// A dictionary of SSH agent sockets or private keys. + /// The SSH agent socket and private key paths. + private static IEnumerable GetSshAgentPaths(IReadOnlyDictionary> sshAgents) + { + return sshAgents.Values.SelectMany(paths => paths); + } + + /// + /// Checks whether an SSH agent socket or private key path can be passed to the + /// Docker CLI or not. + /// + /// + /// The Docker CLI takes the paths of an SSH agent as a comma-separated list. A + /// path that contains a comma cannot be encoded. + /// + /// The SSH agent socket or private key path. + /// True if the path cannot be passed to the Docker CLI; otherwise, false. + private static bool IsPathInvalid(string path) + { + return path != null && path.IndexOf(',') > -1; + } + } +} diff --git a/src/Testcontainers/Configurations/Images/BuildKitImageFromDockerfileConfiguration.cs b/src/Testcontainers/Configurations/Images/BuildKitImageFromDockerfileConfiguration.cs new file mode 100644 index 000000000..069d7d15c --- /dev/null +++ b/src/Testcontainers/Configurations/Images/BuildKitImageFromDockerfileConfiguration.cs @@ -0,0 +1,109 @@ +namespace DotNet.Testcontainers.Configurations +{ + using System; + using System.Collections.Generic; + using System.Text.Json.Serialization; + using Docker.DotNet.Models; + using DotNet.Testcontainers.Builders; + using DotNet.Testcontainers.Images; + using JetBrains.Annotations; + + /// + [PublicAPI] + internal sealed class BuildKitImageFromDockerfileConfiguration : ImageFromDockerfileConfiguration, IBuildKitImageFromDockerfileConfiguration + { + /// + /// Initializes a new instance of the class. + /// + /// The Docker CLI image. + /// The platform. + /// A list of build secrets. + /// A dictionary of SSH agent sockets or private keys. + /// The context directory. + /// The Dockerfile. + /// The Dockerfile directory. + /// The target. + /// The image. + /// The image build policy. + /// A list of build arguments. + /// A value indicating whether Testcontainers removes an existing image or not. + public BuildKitImageFromDockerfileConfiguration( + IImage cliImage = null, + string platform = null, + IEnumerable secrets = null, + IReadOnlyDictionary> sshAgents = null, + string contextDirectory = null, + string dockerfile = null, + string dockerfileDirectory = null, + string target = null, + IImage image = null, + Func imageBuildPolicy = null, + IReadOnlyDictionary buildArguments = null, + bool? deleteIfExists = null) + : base( + contextDirectory, + dockerfile, + dockerfileDirectory, + target, + image, + imageBuildPolicy, + buildArguments, + deleteIfExists) + { + CliImage = cliImage; + Platform = platform; + Secrets = secrets; + SshAgents = sshAgents; + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + public BuildKitImageFromDockerfileConfiguration(IResourceConfiguration resourceConfiguration) + : base(resourceConfiguration) + { + // Passes the configuration upwards to the base implementations to create an updated immutable copy. + } + + /// + /// Initializes a new instance of the class. + /// + /// The Docker resource configuration. + public BuildKitImageFromDockerfileConfiguration(IBuildKitImageFromDockerfileConfiguration resourceConfiguration) + : this(new BuildKitImageFromDockerfileConfiguration(), resourceConfiguration) + { + // Passes the configuration upwards to the base implementations to create an updated immutable copy. + } + + /// + /// Initializes a new instance of the class. + /// + /// The old Docker resource configuration. + /// The new Docker resource configuration. + public BuildKitImageFromDockerfileConfiguration(IBuildKitImageFromDockerfileConfiguration oldValue, IBuildKitImageFromDockerfileConfiguration newValue) + : base(oldValue, newValue) + { + CliImage = BuildConfiguration.Combine(oldValue.CliImage, newValue.CliImage); + Platform = BuildConfiguration.Combine(oldValue.Platform, newValue.Platform); + Secrets = BuildConfiguration.Combine(oldValue.Secrets, newValue.Secrets); + SshAgents = BuildConfiguration.Combine(oldValue.SshAgents, newValue.SshAgents); + } + + /// + [JsonIgnore] + public IImage CliImage { get; } + + /// + [JsonIgnore] + public string Platform { get; } + + /// + [JsonIgnore] + public IEnumerable Secrets { get; } + + /// + [JsonIgnore] + public IReadOnlyDictionary> SshAgents { get; } + } +} diff --git a/src/Testcontainers/Configurations/Images/BuildSecret.cs b/src/Testcontainers/Configurations/Images/BuildSecret.cs new file mode 100644 index 000000000..9dd3c5af8 --- /dev/null +++ b/src/Testcontainers/Configurations/Images/BuildSecret.cs @@ -0,0 +1,124 @@ +namespace DotNet.Testcontainers.Configurations +{ + using System.IO; + using System.Text; + using System.Threading; + using System.Threading.Tasks; + using JetBrains.Annotations; + + /// + /// A build secret that BuildKit exposes to the Docker image build. + /// + /// + /// The secret is mounted into the build with + /// RUN --mount=type=secret,id=<id>. BuildKit does not add it to a + /// layer of the built image. + /// + [PublicAPI] + public sealed class BuildSecret + { + private readonly IResourceMapping _resourceMapping; + + /// + /// Initializes a new instance of the class. + /// + /// The build secret id. + /// The build secret value. + public BuildSecret(string id, string value) + : this(id, new BinaryResourceMapping(Encoding.UTF8.GetBytes(value), GetFilePath(id), 0, 0, Unix.FileMode600)) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The build secret id. + /// The file on the test host that contains the build secret value. + public BuildSecret(string id, FileInfo source) + : this(id, new FileResourceMapping(source.FullName, GetFilePath(id), 0, 0, Unix.FileMode600)) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The build secret id. + /// The resource mapping that provides the build secret value. + private BuildSecret(string id, IResourceMapping resourceMapping) + { + Id = id; + _resourceMapping = resourceMapping; + } + + /// + /// Gets the build secret id. + /// + public string Id { get; } + + /// + /// Gets the path of the file inside the Docker CLI container that contains the + /// build secret value. + /// + /// + /// The build secret value is copied into the Docker CLI container that runs the + /// image build, not into the build context. It never becomes part of the build + /// context tar archive or of a layer of the built image. + /// + internal string FilePath + { + get + { + return _resourceMapping.Target; + } + } + + /// + /// Gets the path of the file on the test host that contains the build secret + /// value. + /// + /// + /// The path is empty if the build secret value is set directly instead of read + /// from a file. + /// + internal string SourceFilePath + { + get + { + return _resourceMapping.Source; + } + } + + /// + /// Gets the Unix file mode of the file inside the Docker CLI container that + /// contains the build secret value. + /// + internal UnixFileModes FileMode + { + get + { + return _resourceMapping.FileMode; + } + } + + /// + /// Gets the build secret value. + /// + /// Cancellation token. + /// Task that completes when the build secret value has been read. + internal Task GetAllBytesAsync(CancellationToken ct = default) + { + return _resourceMapping.GetAllBytesAsync(ct); + } + + /// + /// Gets the path of the file inside the Docker CLI container that contains the + /// value of the build secret. + /// + /// The build secret id. + /// The path of the file inside the Docker CLI container. + private static string GetFilePath(string id) + { + return string.Join("/", string.Empty, "tmp", "testcontainers", "secrets", id); + } + } +} diff --git a/src/Testcontainers/Configurations/Images/IBuildKitImageFromDockerfileConfiguration.cs b/src/Testcontainers/Configurations/Images/IBuildKitImageFromDockerfileConfiguration.cs new file mode 100644 index 000000000..4e10249c0 --- /dev/null +++ b/src/Testcontainers/Configurations/Images/IBuildKitImageFromDockerfileConfiguration.cs @@ -0,0 +1,34 @@ +namespace DotNet.Testcontainers.Configurations +{ + using System.Collections.Generic; + using DotNet.Testcontainers.Images; + using JetBrains.Annotations; + + /// + /// An image configuration that builds the image with BuildKit. + /// + [PublicAPI] + public interface IBuildKitImageFromDockerfileConfiguration : IImageFromDockerfileConfiguration + { + /// + /// Gets the Docker CLI image that runs the image build. + /// + IImage CliImage { get; } + + /// + /// Gets the platform to build the image for. + /// + string Platform { get; } + + /// + /// Gets a list of build secrets. + /// + IEnumerable Secrets { get; } + + /// + /// Gets a dictionary of SSH agent sockets or private keys on the test host, + /// indexed by their SSH agent id. + /// + IReadOnlyDictionary> SshAgents { get; } + } +} diff --git a/src/Testcontainers/Configurations/Images/ImageFromDockerfileConfiguration.cs b/src/Testcontainers/Configurations/Images/ImageFromDockerfileConfiguration.cs index ee059ee9b..b31413fe3 100644 --- a/src/Testcontainers/Configurations/Images/ImageFromDockerfileConfiguration.cs +++ b/src/Testcontainers/Configurations/Images/ImageFromDockerfileConfiguration.cs @@ -10,7 +10,7 @@ namespace DotNet.Testcontainers.Configurations /// [PublicAPI] - internal sealed class ImageFromDockerfileConfiguration : ResourceConfiguration, IImageFromDockerfileConfiguration + internal class ImageFromDockerfileConfiguration : ResourceConfiguration, IImageFromDockerfileConfiguration { /// /// Initializes a new instance of the class. diff --git a/src/Testcontainers/Images/BuildKitDockerImage.cs b/src/Testcontainers/Images/BuildKitDockerImage.cs new file mode 100644 index 000000000..bc94cc407 --- /dev/null +++ b/src/Testcontainers/Images/BuildKitDockerImage.cs @@ -0,0 +1,694 @@ +namespace DotNet.Testcontainers.Images +{ + using System; + using System.Collections.Generic; + using System.Globalization; + using System.IO; + using System.Linq; + using System.Threading; + using System.Threading.Tasks; + using Docker.DotNet; + using Docker.DotNet.Models; + using DotNet.Testcontainers.Builders; + using DotNet.Testcontainers.Clients; + using DotNet.Testcontainers.Configurations; + using DotNet.Testcontainers.Containers; + using JetBrains.Annotations; + using Microsoft.Extensions.Logging; + using Microsoft.Extensions.Logging.Abstractions; + + /// + /// + /// Runs the Docker CLI inside a container and builds the Docker image with + /// BuildKit (docker buildx build). The build context is copied into the + /// container, and the Docker socket is mounted to interact with the Docker host. + /// This does not require a Docker CLI installation on the test host. + /// + [PublicAPI] + internal sealed class BuildKitDockerImage : Resource, IFutureDockerImage + { + /// + /// The directory inside the Docker CLI container that contains the build context. + /// + private const string ContextDirectoryPath = "/tmp/testcontainers/context"; + + /// + /// The tar archive inside the Docker CLI container that contains the build context. + /// + private const string ContextArchiveFilePath = "/tmp/testcontainers/context.tar"; + + private static readonly string[] BuildCommand = { "docker", "buildx", "build", "--load", "--progress", "plain" }; + + private readonly ITestcontainersClient _client; + + private readonly IBuildKitImageFromDockerfileConfiguration _configuration; + + private ImageInspectResponse _image = new ImageInspectResponse(); + + /// + /// Initializes a new instance of the class. + /// + /// The image configuration. + public BuildKitDockerImage(IBuildKitImageFromDockerfileConfiguration configuration) + { + _client = new TestcontainersClient(configuration.SessionId, configuration.DockerEndpointAuthConfig, configuration.Logger); + _configuration = configuration; + } + + /// + public string Repository + { + get + { + ThrowIfResourceNotFound(); + return _configuration.Image.Repository; + } + } + + /// + public string Registry + { + get + { + ThrowIfResourceNotFound(); + return _configuration.Image.Registry; + } + } + + /// + public string Tag + { + get + { + ThrowIfResourceNotFound(); + return _configuration.Image.Tag; + } + } + + /// + public string Digest + { + get + { + ThrowIfResourceNotFound(); + return _configuration.Image.Digest; + } + } + + /// + public string Platform + { + get + { + ThrowIfResourceNotFound(); + return _configuration.Image.Platform; + } + } + + /// + public string FullName + { + get + { + ThrowIfResourceNotFound(); + return _configuration.Image.FullName; + } + } + + /// + /// Gets the logger. + /// + private ILogger Logger + { + get + { + return _configuration.Logger; + } + } + + /// + public string GetHostname() + { + ThrowIfResourceNotFound(); + return _configuration.Image.GetHostname(); + } + + /// + public bool MatchLatestOrNightly() + { + return _configuration.Image.MatchLatestOrNightly(); + } + + /// + public bool MatchVersion(Predicate predicate) + { + return _configuration.Image.MatchVersion(predicate); + } + + /// + public bool MatchVersion(Predicate predicate) + { + return _configuration.Image.MatchVersion(predicate); + } + + /// + public async Task CreateAsync(CancellationToken ct = default) + { + using var disposable = await AcquireLockAsync(ct) + .ConfigureAwait(false); + + await UnsafeCreateAsync(ct) + .ConfigureAwait(false); + } + + /// + public async Task DeleteAsync(CancellationToken ct = default) + { + using var disposable = await AcquireLockAsync(ct) + .ConfigureAwait(false); + + await UnsafeDeleteAsync(ct) + .ConfigureAwait(false); + } + + /// + protected override bool Exists() + { + return !string.IsNullOrEmpty(_image.ID); + } + + /// + protected override async Task UnsafeCreateAsync(CancellationToken ct = default) + { + ThrowIfLockNotAcquired(); + + if (Exists()) + { + return; + } + + await _client.System.LogContainerRuntimeInfoAsync(ct) + .ConfigureAwait(false); + + ImageInspectResponse cachedImage; + + try + { + cachedImage = await _client.Image.ByIdAsync(_configuration.Image.FullName, ct) + .ConfigureAwait(false); + } + catch (DockerImageNotFoundException) + { + cachedImage = null; + } + + if (_configuration.ImageBuildPolicy(cachedImage)) + { + await BuildAsync(ct) + .ConfigureAwait(false); + } + + _image = await _client.Image.ByIdAsync(_configuration.Image.FullName, ct) + .ConfigureAwait(false); + } + + /// + protected override async Task UnsafeDeleteAsync(CancellationToken ct = default) + { + ThrowIfLockNotAcquired(); + + if (!Exists()) + { + return; + } + + await _client.Image.DeleteAsync(_configuration.Image, ct) + .ConfigureAwait(false); + + _image = new ImageInspectResponse(); + } + + /// + /// Builds the Docker image with BuildKit. + /// + /// Cancellation token. + /// Task that completes when the Docker image has been built. + /// The Docker image build failed. + private async Task BuildAsync(CancellationToken ct = default) + { + var dockerfileArchive = new DockerfileArchive( + _configuration.ContextDirectory, + _configuration.DockerfileDirectory, + _configuration.Dockerfile, + _configuration.Image, + _configuration.BuildArguments, + Logger); + + // BuildKit resolves the base images itself, but it does not have access to the + // Docker configuration of the test host. Pull them from the test host instead, + // so that its Docker credentials and credential helpers apply. + await PullBaseImagesAsync(dockerfileArchive, ct) + .ConfigureAwait(false); + + var imageExists = await _client.Image.ExistsWithIdAsync(_configuration.Image.FullName, ct) + .ConfigureAwait(false); + + if (imageExists && _configuration.DeleteIfExists.HasValue && _configuration.DeleteIfExists.Value) + { + await _client.Image.DeleteAsync(_configuration.Image, ct) + .ConfigureAwait(false); + } + + var contextArchiveFilePath = await dockerfileArchive.Tar(ct) + .ConfigureAwait(false); + + try + { + var cliContainer = CreateCliContainer(contextArchiveFilePath); + + try + { + await StartCliContainerAsync(cliContainer, ct) + .ConfigureAwait(false); + + // Copy the build secrets after the Docker CLI container has been started. They + // are not part of the container configuration, which keeps them out of the + // Docker resource that the Docker daemon reports. + foreach (var secret in _configuration.Secrets) + { + var secretValue = await secret.GetAllBytesAsync(ct) + .ConfigureAwait(false); + + await cliContainer.CopyAsync(secretValue, secret.FilePath, 0, 0, secret.FileMode, ct) + .ConfigureAwait(false); + } + + _ = await cliContainer.ExecAsync(GetExtractContextCommand(), ct) + .ThrowOnFailure() + .ConfigureAwait(false); + + var buildCommand = GetBuildCommand(); + + // The build arguments are not secrets by contract, but they are not + // necessarily harmless either. Log them redacted, the same way the Docker + // Engine API image builder does not log the image build parameters at all. + Logger.BuildDockerImage(_configuration.Image, RedactBuildArguments(buildCommand)); + + var execResult = await cliContainer.ExecAsync(buildCommand, ct) + .ConfigureAwait(false); + + // The Docker CLI writes the build output to stderr. Log it either way, it is + // the only trace of the image build that the test host gets. + Logger.DockerImageBuildOutput(_configuration.Image, string.Concat(execResult.Stdout, execResult.Stderr)); + + if (!0L.Equals(execResult.ExitCode)) + { + throw new ImageBuildFailedException(_configuration.Image, new[] { new JSONError { Message = execResult.Stderr } }); + } + + var imageHasBeenCreated = await _client.Image.ExistsWithIdAsync(_configuration.Image.FullName, ct) + .ConfigureAwait(false); + + if (!imageHasBeenCreated) + { + throw new ImageBuildFailedException(_configuration.Image, Array.Empty()); + } + + Logger.DockerImageBuilt(_configuration.Image); + } + finally + { + await cliContainer.DisposeAsync() + .ConfigureAwait(false); + } + } + finally + { + File.Delete(contextArchiveFilePath); + } + } + + /// + /// Pulls the base images of the Dockerfile that are not present on the Docker + /// host. + /// + /// The Dockerfile archive that resolves the base images. + /// Cancellation token. + /// Task that completes when the base images have been pulled. + private async Task PullBaseImagesAsync(DockerfileArchive dockerfileArchive, CancellationToken ct = default) + { + var baseImages = dockerfileArchive.GetBaseImages().ToArray(); + + var filters = baseImages.Aggregate(new FilterByProperty(), (dictionary, baseImage) => dictionary.Add("reference", baseImage.FullName)); + + var cachedImages = await _client.Image.GetAllAsync(filters, ct) + .ConfigureAwait(false); + + var repositoryTags = new HashSet(cachedImages.SelectMany(image => image.RepoTags ?? Array.Empty())); + + var uncachedImages = baseImages.Where(baseImage => !repositoryTags.Contains(baseImage.FullName)); + + await Task.WhenAll(uncachedImages.Select(image => _client.PullImageAsync(image, ct))) + .ConfigureAwait(false); + } + + /// + /// Creates the container that runs the Docker CLI. + /// + /// The tar archive on the test host that contains the build context. + /// The container that runs the Docker CLI. + private IContainer CreateCliContainer(string contextArchiveFilePath) + { + // The Docker CLI container is an implementation detail of the image build. It + // keeps the default Resource Reaper session and the default logger, no matter + // how the image is configured. Disabling the cleanup of the image keeps the + // built image, it does not keep the container that built it, which carries the + // build secrets. Not logging to the configured logger keeps the Docker CLI + // command, which carries the build arguments, out of the log output. + var cliBuilder = new ContainerBuilder() + .WithImage(_configuration.CliImage) + .WithDockerEndpoint(_configuration.DockerEndpointAuthConfig) + .WithLogger(NullLogger.Instance) + .WithEntrypoint("/bin/sh", "-c") + .WithCommand("trap 'exit 0' TERM; sleep infinity & wait $!") + .WithMount(new UnixSocketMount(_configuration.DockerEndpointAuthConfig.Endpoint)) + .WithResourceMapping(new FileInfo(contextArchiveFilePath), new FileInfo(ContextArchiveFilePath)); + + // Bind-mount the SSH agent sockets and private keys, keeping the path they have + // on the test host. The Docker daemon resolves the mount source, which is the + // same reason the Docker socket can be mounted. + cliBuilder = _configuration.SshAgents.Values + .SelectMany(paths => paths) + .Distinct() + .Aggregate(cliBuilder, (builder, path) => builder.WithBindMount(path, path, AccessMode.ReadOnly)); + + return cliBuilder.Build(); + } + + /// + /// Starts the container that runs the Docker CLI. + /// + /// The container that runs the Docker CLI. + /// Cancellation token. + /// Task that completes when the container has been started. + /// The Docker socket cannot be mounted into the container. + private async Task StartCliContainerAsync(IContainer cliContainer, CancellationToken ct = default) + { + try + { + await cliContainer.StartAsync(ct) + .ConfigureAwait(false); + } + catch (DockerApiException e) when (IsDockerSocketMountFailure(e)) + { + // Building an image with BuildKit runs the Docker CLI against the Docker socket + // of the Docker daemon. A Docker daemon that does not listen on a Unix socket, + // such as a Docker daemon that is reached over a Windows named pipe, cannot + // provide one. Every other failure, such as a Docker CLI image that cannot be + // pulled, propagates unchanged. + throw new InvalidOperationException($"The Docker socket '{GetDockerSocketFilePath()}' cannot be mounted into the Docker CLI container. Building an image with BuildKit requires a Docker socket that the Docker daemon can resolve. Set TestcontainersSettings.DockerSocketOverride to the Docker socket path of the Docker daemon, or use ImageFromDockerfileBuilder, which builds the image through the Docker Engine API.", e); + } + } + + /// + /// Checks whether the Docker daemon rejected the bind mount of the Docker socket + /// or not. + /// + /// + /// A Docker daemon that cannot resolve the Docker socket responds with a mount + /// error that names the Docker socket, e.g. + /// invalid mount config for type "bind": bind source path does not exist: + /// /var/run/docker.sock. + /// + /// The exception that the Docker daemon responded with. + /// True if the Docker daemon rejected the bind mount of the Docker socket; otherwise, false. + private bool IsDockerSocketMountFailure(DockerApiException e) + { + return e.Message != null + && e.Message.IndexOf("mount", StringComparison.OrdinalIgnoreCase) > -1 + && e.Message.IndexOf(GetDockerSocketFilePath(), StringComparison.Ordinal) > -1; + } + + /// + /// Gets the path of the Docker socket on the host that runs the Docker daemon. + /// + /// The path of the Docker socket. + private string GetDockerSocketFilePath() + { + return new UnixSocketMount(_configuration.DockerEndpointAuthConfig.Endpoint).Source; + } + + /// + /// Gets the command that extracts the build context inside the Docker CLI + /// container. + /// + /// The command that extracts the build context. + private static IList GetExtractContextCommand() + { + return new[] { "/bin/sh", "-c", $"mkdir -p '{ContextDirectoryPath}' && tar -xf '{ContextArchiveFilePath}' -C '{ContextDirectoryPath}' && rm '{ContextArchiveFilePath}'" }; + } + + /// + /// Gets the Docker CLI command that builds the Docker image. + /// + /// + /// The image build parameters carry the configuration that the Docker Engine API + /// image builder gets too, including the parameter modifiers that + /// WithCreateParameterModifier sets. Only the parameters that the Docker + /// CLI provides an argument for are passed on, the remaining ones are logged. + /// + /// The Docker CLI command that builds the Docker image. + private IList GetBuildCommand() + { + var dockerfileFilePath = string.Join("/", ContextDirectoryPath, Unix.Instance.NormalizePath(_configuration.Dockerfile)); + + var buildParameters = new ImageBuildParameters + { + Dockerfile = dockerfileFilePath, + Target = _configuration.Target, + Platform = _configuration.Platform, + Tags = new List { _configuration.Image.FullName }, + BuildArgs = _configuration.BuildArguments.ToDictionary(item => item.Key, item => item.Value), + Labels = _configuration.Labels.ToDictionary(item => item.Key, item => item.Value), + }; + + if (_configuration.ParameterModifiers != null) + { + foreach (var parameterModifier in _configuration.ParameterModifiers) + { + parameterModifier(buildParameters); + } + } + + foreach (var parameterName in GetUnsupportedParameterNames(buildParameters)) + { + Logger.ImageBuildParameterNotSupported(parameterName); + } + + var buildCommand = new List(BuildCommand); + + buildCommand.Add("--file"); + buildCommand.Add(buildParameters.Dockerfile); + + if (!string.IsNullOrEmpty(buildParameters.Target)) + { + buildCommand.Add("--target"); + buildCommand.Add(buildParameters.Target); + } + + if (!string.IsNullOrEmpty(buildParameters.Platform)) + { + buildCommand.Add("--platform"); + buildCommand.Add(buildParameters.Platform); + } + + if (buildParameters.NoCache.HasValue && buildParameters.NoCache.Value) + { + buildCommand.Add("--no-cache"); + } + + if (IsPullEnabled(buildParameters.Pull)) + { + buildCommand.Add("--pull"); + } + + if (!string.IsNullOrEmpty(buildParameters.NetworkMode)) + { + buildCommand.Add("--network"); + buildCommand.Add(buildParameters.NetworkMode); + } + + if (buildParameters.ShmSize.HasValue) + { + buildCommand.Add("--shm-size"); + buildCommand.Add(buildParameters.ShmSize.Value.ToString(CultureInfo.InvariantCulture)); + } + + foreach (var extraHost in buildParameters.ExtraHosts ?? Array.Empty()) + { + buildCommand.Add("--add-host"); + buildCommand.Add(extraHost); + } + + foreach (var cacheFrom in buildParameters.CacheFrom ?? Array.Empty()) + { + buildCommand.Add("--cache-from"); + buildCommand.Add(cacheFrom); + } + + foreach (var tag in buildParameters.Tags ?? Array.Empty()) + { + buildCommand.Add("--tag"); + buildCommand.Add(tag); + } + + foreach (var buildArgument in buildParameters.BuildArgs ?? Enumerable.Empty>()) + { + buildCommand.Add("--build-arg"); + buildCommand.Add($"{buildArgument.Key}={buildArgument.Value}"); + } + + foreach (var label in buildParameters.Labels ?? Enumerable.Empty>()) + { + buildCommand.Add("--label"); + buildCommand.Add($"{label.Key}={label.Value}"); + } + + foreach (var secret in _configuration.Secrets) + { + buildCommand.Add("--secret"); + buildCommand.Add($"id={secret.Id},src={secret.FilePath}"); + } + + foreach (var sshAgent in _configuration.SshAgents) + { + buildCommand.Add("--ssh"); + buildCommand.Add(sshAgent.Value.Any() ? $"{sshAgent.Key}={string.Join(",", sshAgent.Value)}" : sshAgent.Key); + } + + buildCommand.Add(ContextDirectoryPath); + + return buildCommand; + } + + /// + /// Checks whether the image build parameters pull the base images or not. + /// + /// + /// The Docker Engine API takes the option as a string, the Docker CLI as a flag. + /// + /// The value of the pull image build parameter. + /// True if the base images are pulled; otherwise, false. + private static bool IsPullEnabled(string pull) + { + return !string.IsNullOrEmpty(pull) + && !"0".Equals(pull, StringComparison.Ordinal) + && !bool.FalseString.Equals(pull, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Gets the names of the image build parameters that are set, but that the + /// Docker CLI does not provide an equivalent argument for. + /// + /// + /// The resource limits of the legacy builder (CPU, memory) do not apply to a + /// BuildKit build. The remaining parameters either configure the Docker Engine + /// API image builder itself, or conflict with the way the Docker CLI container + /// runs the image build. + /// + /// The image build parameters. + /// The names of the image build parameters that are not applied. + private static IEnumerable GetUnsupportedParameterNames(ImageBuildParameters buildParameters) + { + if (buildParameters.SuppressOutput.HasValue) + { + yield return nameof(ImageBuildParameters.SuppressOutput); + } + + if (!string.IsNullOrEmpty(buildParameters.RemoteContext)) + { + yield return nameof(ImageBuildParameters.RemoteContext); + } + + if (buildParameters.Remove.HasValue) + { + yield return nameof(ImageBuildParameters.Remove); + } + + if (buildParameters.ForceRemove.HasValue) + { + yield return nameof(ImageBuildParameters.ForceRemove); + } + + if (!string.IsNullOrEmpty(buildParameters.CPUSetCPUs)) + { + yield return nameof(ImageBuildParameters.CPUSetCPUs); + } + + if (buildParameters.CPUShares.HasValue) + { + yield return nameof(ImageBuildParameters.CPUShares); + } + + if (buildParameters.CPUQuota.HasValue) + { + yield return nameof(ImageBuildParameters.CPUQuota); + } + + if (buildParameters.CPUPeriod.HasValue) + { + yield return nameof(ImageBuildParameters.CPUPeriod); + } + + if (buildParameters.Memory.HasValue) + { + yield return nameof(ImageBuildParameters.Memory); + } + + if (buildParameters.MemorySwap.HasValue) + { + yield return nameof(ImageBuildParameters.MemorySwap); + } + + if (buildParameters.Squash.HasValue) + { + yield return nameof(ImageBuildParameters.Squash); + } + + if (!string.IsNullOrEmpty(buildParameters.Outputs)) + { + yield return nameof(ImageBuildParameters.Outputs); + } + + if (!string.IsNullOrEmpty(buildParameters.Version)) + { + yield return nameof(ImageBuildParameters.Version); + } + + if (buildParameters.AuthConfigs != null && buildParameters.AuthConfigs.Count > 0) + { + yield return nameof(ImageBuildParameters.AuthConfigs); + } + } + + /// + /// Redacts the build argument values of the Docker CLI command that builds the + /// Docker image. + /// + /// The Docker CLI command that builds the Docker image. + /// The Docker CLI command with the build argument values redacted. + private static IEnumerable RedactBuildArguments(IEnumerable buildCommand) + { + const string redacted = "***"; + + var isBuildArgument = false; + + foreach (var argument in buildCommand) + { + var separatorIndex = isBuildArgument ? argument.IndexOf('=') : -1; + yield return separatorIndex > -1 ? string.Concat(argument.Substring(0, separatorIndex + 1), redacted) : argument; + isBuildArgument = "--build-arg".Equals(argument, StringComparison.Ordinal); + } + } + } +} diff --git a/src/Testcontainers/Logging.cs b/src/Testcontainers/Logging.cs index 78aaa55bf..d36cf0cc1 100644 --- a/src/Testcontainers/Logging.cs +++ b/src/Testcontainers/Logging.cs @@ -78,6 +78,15 @@ internal static partial class Logging [LoggerMessage(Level = LogLevel.Information, Message = "Docker image {FullName} built")] private static partial void DockerImageBuiltCore(ILogger logger, string fullName); + [LoggerMessage(Level = LogLevel.Debug, Message = "Build Docker image {FullName} with \"{Command}\"")] + private static partial void BuildDockerImageCore(ILogger logger, string fullName, string command); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Docker image {FullName} build output:{NewLine}{BuildOutput}")] + private static partial void DockerImageBuildOutputCore(ILogger logger, string fullName, string newLine, string buildOutput); + + [LoggerMessage(Level = LogLevel.Warning, Message = "The image build parameter {ParameterName} is set, but the Docker CLI does not provide an equivalent argument. It is not applied to the Docker image build")] + private static partial void ImageBuildParameterNotSupportedCore(ILogger logger, string parameterName); + [LoggerMessage(Level = LogLevel.Information, Message = "Delete Docker image {FullName}")] private static partial void DeleteDockerImageCore(ILogger logger, string fullName); @@ -244,6 +253,22 @@ public static void DockerImageBuilt(this ILogger logger, IImage image) DockerImageBuiltCore(logger, image.FullName); } + public static void BuildDockerImage(this ILogger logger, IImage image, IEnumerable command) + { + var commandLine = string.Join(" ", command); + BuildDockerImageCore(logger, image.FullName, commandLine); + } + + public static void DockerImageBuildOutput(this ILogger logger, IImage image, string buildOutput) + { + DockerImageBuildOutputCore(logger, image.FullName, Environment.NewLine, buildOutput); + } + + public static void ImageBuildParameterNotSupported(this ILogger logger, string parameterName) + { + ImageBuildParameterNotSupportedCore(logger, parameterName); + } + public static void DeleteDockerImage(this ILogger logger, IImage image) { DeleteDockerImageCore(logger, image.FullName); diff --git a/tests/Testcontainers.Platform.Linux.Tests/BuildKitImageFromDockerfileTest.cs b/tests/Testcontainers.Platform.Linux.Tests/BuildKitImageFromDockerfileTest.cs new file mode 100644 index 000000000..0c40d62f1 --- /dev/null +++ b/tests/Testcontainers.Platform.Linux.Tests/BuildKitImageFromDockerfileTest.cs @@ -0,0 +1,571 @@ +namespace Testcontainers.Tests; + +using System.Security.Cryptography; +using System.Text.Json; +using DotNet.Testcontainers.Images; +using Microsoft.Extensions.Logging; + +public sealed class BuildKitImageFromDockerfileTest +{ + [Fact] + public async Task BuildsHeredocDockerfile() + { + // Given + + // The legacy builder does not interpret a here-document. It creates an empty + // file, and the container that runs it fails with an exec format error: + // https://github.com/testcontainers/testcontainers-dotnet/issues/1247. + var dockerfileDirectoryPath = CreateDockerfileDirectory($""" + FROM {CommonImages.Alpine.FullName} + RUN cat < /entrypoint.sh + #!/bin/sh + echo "Hello, BuildKit!" + EOF + RUN chmod +x /entrypoint.sh + ENTRYPOINT ["/entrypoint.sh"] + """); + + await using var image = new BuildKitImageFromDockerfileBuilder() + .WithDockerfileDirectory(dockerfileDirectoryPath) + .Build(); + + await image.CreateAsync(TestContext.Current.CancellationToken) + .ConfigureAwait(true); + + await using var container = CreateKeepAliveContainer(image); + + await container.StartAsync(TestContext.Current.CancellationToken) + .ConfigureAwait(true); + + // When + var readEntrypointResult = await container.ExecAsync(new[] { "cat", "/entrypoint.sh" }, TestContext.Current.CancellationToken) + .ConfigureAwait(true); + + var runEntrypointResult = await container.ExecAsync(new[] { "/entrypoint.sh" }, TestContext.Current.CancellationToken) + .ConfigureAwait(true); + + // Then + Assert.Equal(0L, readEntrypointResult.ExitCode); + Assert.StartsWith("#!/bin/sh", readEntrypointResult.Stdout); + Assert.Contains("echo \"Hello, BuildKit!\"", readEntrypointResult.Stdout); + Assert.Equal(0L, runEntrypointResult.ExitCode); + Assert.Contains("Hello, BuildKit!", runEntrypointResult.Stdout); + } + + [Fact] + public async Task MountsBuildSecret() + { + // Given + const string secretId = "mysecret"; + + var secretValue = Guid.NewGuid().ToString("D"); + + // The build secret value does not appear in the Dockerfile. The Dockerfile + // instructions become the history of the built image, which would report the + // build secret value that this test asserts is not reported. + var secretValueHash = BitConverter.ToString(SHA256.HashData(Encoding.UTF8.GetBytes(secretValue))).Replace("-", string.Empty).ToLowerInvariant(); + + // The first instruction fails the build if the build secret is not readable, + // the second one fails it if the build secret outlives the instruction that + // mounts it: https://github.com/testcontainers/testcontainers-dotnet/issues/1406. + var dockerfileDirectoryPath = CreateDockerfileDirectory($""" + FROM {CommonImages.Alpine.FullName} + RUN --mount=type=secret,id={secretId} [ "$(sha256sum < /run/secrets/{secretId} | cut -d ' ' -f 1)" = "{secretValueHash}" ] + RUN [ ! -e /run/secrets/{secretId} ] + """); + + await using var image = new BuildKitImageFromDockerfileBuilder() + .WithDockerfileDirectory(dockerfileDirectoryPath) + .WithSecret(secretId, secretValue) + .Build(); + + // When + await image.CreateAsync(TestContext.Current.CancellationToken) + .ConfigureAwait(true); + + using var dockerClient = TestcontainersSettings.OS.DockerEndpointAuthConfig.GetDockerClientBuilder().Build(); + + var imageHistoryResponse = await dockerClient.Images.GetImageHistoryAsync(image.FullName, TestContext.Current.CancellationToken) + .ConfigureAwait(true); + + var imageInspectResponse = await dockerClient.Images.InspectImageAsync(image.FullName, TestContext.Current.CancellationToken) + .ConfigureAwait(true); + + // Then + + // The image history reports the instruction that mounts the build secret. Its + // hash confirms that the assertion below inspects the instructions, and does + // not pass because the image history is empty. + Assert.Contains(secretValueHash, JsonSerializer.Serialize(imageHistoryResponse)); + Assert.DoesNotContain(secretValue, JsonSerializer.Serialize(imageHistoryResponse)); + Assert.DoesNotContain(secretValue, JsonSerializer.Serialize(imageInspectResponse)); + } + + [Fact] + public async Task RemovesDockerCliContainerWhenCleanUpIsDisabled() + { + // Given + + // The Docker CLI container is an implementation detail of the image build. + // Disabling the cleanup keeps the built image, it does not keep the container + // that built it, which carries the build secrets. + using var dockerClient = TestcontainersSettings.OS.DockerEndpointAuthConfig.GetDockerClientBuilder().Build(); + + // Derive a Docker CLI image for this test only. The ancestor filter resolves + // the image id, so an image of its own makes the Docker CLI container of this + // test the only container that the filter matches, independent of the image + // builds that run at the same time. + await using var cliImage = new ImageFromDockerfileBuilder() + .WithDockerfileDirectory(CreateDockerfileDirectory($""" + FROM {CommonImages.DockerCli.FullName} + LABEL "org.testcontainers.docker-cli"="{Guid.NewGuid():D}" + """)) + .Build(); + + await cliImage.CreateAsync(TestContext.Current.CancellationToken) + .ConfigureAwait(true); + + var dockerfileDirectoryPath = CreateDockerfileDirectory($""" + FROM {CommonImages.Alpine.FullName} + RUN --mount=type=secret,id=mysecret [ -s /run/secrets/mysecret ] + """); + + var image = new BuildKitImageFromDockerfileBuilder(cliImage) + .WithDockerfileDirectory(dockerfileDirectoryPath) + .WithSecret("mysecret", Guid.NewGuid().ToString("D")) + .WithCleanUp(false) + .Build(); + + var imageName = string.Empty; + + try + { + await image.CreateAsync(TestContext.Current.CancellationToken) + .ConfigureAwait(true); + + imageName = image.FullName; + + // When + await image.DisposeAsync() + .ConfigureAwait(true); + + var containerListParameters = new ContainersListParameters { All = true, Filters = new FilterByProperty().Add("ancestor", cliImage.FullName) }; + + var containers = await dockerClient.Containers.ListContainersAsync(containerListParameters, TestContext.Current.CancellationToken) + .ConfigureAwait(true); + + // Then + Assert.Empty(containers); + } + finally + { + // Disabling the cleanup takes the built image out of the Resource Reaper + // session, which makes this test responsible for it. + if (!string.IsNullOrEmpty(imageName)) + { + _ = await dockerClient.Images.DeleteImageAsync(imageName, new ImageDeleteParameters { Force = true }, TestContext.Current.CancellationToken) + .ConfigureAwait(true); + } + } + } + + [Fact] + public async Task ThrowsWhenDockerCliImageCannotBePulled() + { + // Given + + // A Docker CLI container that does not start is not necessarily a Docker + // socket that cannot be mounted. The Docker daemon error propagates unchanged. + var dockerfileDirectoryPath = CreateDockerfileDirectory($"FROM {CommonImages.Alpine.FullName}"); + + await using var image = new BuildKitImageFromDockerfileBuilder("docker:0.0.0-does-not-exist-cli") + .WithDockerfileDirectory(dockerfileDirectoryPath) + .Build(); + + // When + var exception = await Assert.ThrowsAsync(() => image.CreateAsync(TestContext.Current.CancellationToken)) + .ConfigureAwait(true); + + // Then + Assert.Contains("docker:0.0.0-does-not-exist-cli", exception.Message); + Assert.DoesNotContain(nameof(TestcontainersSettings.DockerSocketOverride), exception.Message); + } + + [Fact] + public async Task BuildsForExpectedPlatform() + { + // Given + using var dockerClient = TestcontainersSettings.OS.DockerEndpointAuthConfig.GetDockerClientBuilder().Build(); + + var versionResponse = await dockerClient.System.GetVersionAsync(TestContext.Current.CancellationToken) + .ConfigureAwait(true); + + // Build the image for a platform other than the platform of the Docker host. + // The Dockerfile does not run an instruction, which keeps the build + // independent of an emulator such as QEMU. + var platform = "arm64".Equals(versionResponse.Arch, StringComparison.OrdinalIgnoreCase) ? "linux/amd64" : "linux/arm64"; + + var dockerfileDirectoryPath = CreateDockerfileDirectory($""" + FROM {CommonImages.Alpine.FullName} + ENV TESTCONTAINERS_PLATFORM="{platform}" + """); + + await using var image = new BuildKitImageFromDockerfileBuilder() + .WithDockerfileDirectory(dockerfileDirectoryPath) + .WithPlatform(platform) + .Build(); + + await image.CreateAsync(TestContext.Current.CancellationToken) + .ConfigureAwait(true); + + // When + var imageInspectResponse = await dockerClient.Images.InspectImageAsync(image.FullName, TestContext.Current.CancellationToken) + .ConfigureAwait(true); + + // Then + Assert.Equal(platform, string.Join("/", imageInspectResponse.Os, imageInspectResponse.Architecture)); + } + + [Fact] + public async Task BuildsFromContextDirectory() + { + // Given + var contextDirectoryPath = Directory.CreateDirectory(Path.Combine(TestSession.TempDirectoryPath, Guid.NewGuid().ToString("D"))).FullName; + + await File.WriteAllTextAsync(Path.Combine(contextDirectoryPath, "hello.txt"), "Hello, BuildKit!", TestContext.Current.CancellationToken) + .ConfigureAwait(true); + + // The Dockerfile directory does not contain the file that the Dockerfile + // copies. It is only part of the build context directory. + var dockerfileDirectoryPath = CreateDockerfileDirectory($""" + FROM {CommonImages.Alpine.FullName} + COPY hello.txt /hello.txt + """); + + await using var image = new BuildKitImageFromDockerfileBuilder() + .WithContextDirectory(contextDirectoryPath) + .WithDockerfileDirectory(dockerfileDirectoryPath) + .Build(); + + await image.CreateAsync(TestContext.Current.CancellationToken) + .ConfigureAwait(true); + + await using var container = CreateKeepAliveContainer(image); + + await container.StartAsync(TestContext.Current.CancellationToken) + .ConfigureAwait(true); + + // When + var execResult = await container.ExecAsync(new[] { "cat", "/hello.txt" }, TestContext.Current.CancellationToken) + .ConfigureAwait(true); + + // Then + Assert.Equal(0L, execResult.ExitCode); + Assert.Equal("Hello, BuildKit!", execResult.Stdout); + } + + [Fact] + public async Task AppliesImageBuildParametersToBuildCommand() + { + // Given + var fakeLogger = new FakeLogger(); + + var dockerfileDirectoryPath = CreateDockerfileDirectory($""" + FROM {CommonImages.Alpine.FullName} + RUN touch /build + """); + + await using var image = new BuildKitImageFromDockerfileBuilder() + .WithDockerfileDirectory(dockerfileDirectoryPath) + .WithLogger(fakeLogger) + .WithCreateParameterModifier(parameters => + { + parameters.NoCache = true; + parameters.Pull = bool.TrueString; + parameters.NetworkMode = "none"; + parameters.ShmSize = 67108864; + parameters.ExtraHosts = new List { "testcontainers.local:127.0.0.1" }; + parameters.CacheFrom = new List { "type=local,src=/tmp/testcontainers/cache" }; + + // The legacy builder squashes the layers of the built image. BuildKit + // does not provide an equivalent. + parameters.Squash = true; + }) + .Build(); + + // When + await image.CreateAsync(TestContext.Current.CancellationToken) + .ConfigureAwait(true); + + var logRecords = fakeLogger.Collector.GetSnapshot(); + + var buildCommand = logRecords.Single(logRecord => logRecord.Message.StartsWith("Build Docker image ", StringComparison.Ordinal)).Message; + + // Then + Assert.Contains("--no-cache", buildCommand); + Assert.Contains("--pull", buildCommand); + Assert.Contains("--network none", buildCommand); + Assert.Contains("--shm-size 67108864", buildCommand); + Assert.Contains("--add-host testcontainers.local:127.0.0.1", buildCommand); + Assert.Contains("--cache-from type=local,src=/tmp/testcontainers/cache", buildCommand); + Assert.Contains(logRecords, logRecord => logRecord.Level == LogLevel.Warning && logRecord.Message.Contains(nameof(ImageBuildParameters.Squash), StringComparison.Ordinal)); + } + + [Fact] + public async Task BuildsImageWhenImageBuildParameterCollectionsAreReset() + { + // Given + using var dockerClient = TestcontainersSettings.OS.DockerEndpointAuthConfig.GetDockerClientBuilder().Build(); + + var dockerfileDirectoryPath = CreateDockerfileDirectory($""" + FROM {CommonImages.Alpine.FullName} + RUN touch /build + """); + + await using var image = new BuildKitImageFromDockerfileBuilder() + .WithDockerfileDirectory(dockerfileDirectoryPath) + .WithBuildArgument("MAGIC_NUMBER", "42") + .WithCreateParameterModifier(parameters => + { + // A parameter modifier can reset a collection of the image build + // parameters. The Docker CLI command does not enumerate it then. + parameters.BuildArgs = null; + parameters.Labels = null; + }) + .Build(); + + // When + await image.CreateAsync(TestContext.Current.CancellationToken) + .ConfigureAwait(true); + + var imageInspectResponse = await dockerClient.Images.InspectImageAsync(image.FullName, TestContext.Current.CancellationToken) + .ConfigureAwait(true); + + // Then + Assert.NotNull(imageInspectResponse); + } + + [Fact] + public async Task LogsRedactedBuildCommandAndBuildOutputAtDebugLevel() + { + // Given + var fakeLogger = new FakeLogger(); + + var buildArgumentValue = Guid.NewGuid().ToString("D"); + + var dockerfileDirectoryPath = CreateDockerfileDirectory($""" + FROM {CommonImages.Alpine.FullName} + ARG TOKEN + """); + + await using var image = new BuildKitImageFromDockerfileBuilder() + .WithDockerfileDirectory(dockerfileDirectoryPath) + .WithBuildArgument("TOKEN", buildArgumentValue) + .WithLogger(fakeLogger) + .Build(); + + // When + await image.CreateAsync(TestContext.Current.CancellationToken) + .ConfigureAwait(true); + + var logRecords = fakeLogger.Collector.GetSnapshot(); + + // Then + Assert.Contains(logRecords, logRecord => logRecord.Level == LogLevel.Debug && logRecord.Message.Contains("--build-arg TOKEN=***", StringComparison.Ordinal)); + Assert.Contains(logRecords, logRecord => logRecord.Level == LogLevel.Debug && logRecord.Message.Contains("build output:", StringComparison.Ordinal)); + Assert.DoesNotContain(logRecords, logRecord => logRecord.Message.Contains(buildArgumentValue, StringComparison.Ordinal)); + Assert.DoesNotContain(logRecords, logRecord => logRecord.Level > LogLevel.Debug && logRecord.Message.Contains("buildx build", StringComparison.Ordinal)); + Assert.DoesNotContain(logRecords, logRecord => logRecord.Level > LogLevel.Debug && logRecord.Message.Contains("build output:", StringComparison.Ordinal)); + } + + [Fact] + public async Task MountsBuildSecretFromFile() + { + // Given + const string secretId = "mysecret"; + + var secretValue = Guid.NewGuid().ToString("D"); + + var secretFilePath = Path.Combine(Directory.CreateDirectory(Path.Combine(TestSession.TempDirectoryPath, Guid.NewGuid().ToString("D"))).FullName, secretId); + + await File.WriteAllTextAsync(secretFilePath, secretValue, TestContext.Current.CancellationToken) + .ConfigureAwait(true); + + var dockerfileDirectoryPath = CreateDockerfileDirectory($""" + FROM {CommonImages.Alpine.FullName} + RUN --mount=type=secret,id={secretId} [ "$(cat /run/secrets/{secretId})" = "{secretValue}" ] + """); + + await using var image = new BuildKitImageFromDockerfileBuilder() + .WithDockerfileDirectory(dockerfileDirectoryPath) + .WithSecret(secretId, new FileInfo(secretFilePath)) + .Build(); + + // When + var exception = await Record.ExceptionAsync(() => image.CreateAsync(TestContext.Current.CancellationToken)) + .ConfigureAwait(true); + + // Then + Assert.Null(exception); + } + + [Fact] + public async Task AppliesLabelsAndBuildArgumentsToImage() + { + // Given + var buildArgumentValue = Guid.NewGuid().ToString("D"); + + var dockerfileDirectoryPath = CreateDockerfileDirectory($""" + FROM {CommonImages.Alpine.FullName} + ARG MAGIC_NUMBER="0" + LABEL "org.testcontainers.magic-number"=$MAGIC_NUMBER + """); + + await using var image = new BuildKitImageFromDockerfileBuilder() + .WithDockerfileDirectory(dockerfileDirectoryPath) + .WithBuildArgument("MAGIC_NUMBER", buildArgumentValue) + .WithLabel("org.testcontainers.buildkit", bool.TrueString.ToLowerInvariant()) + .Build(); + + await image.CreateAsync(TestContext.Current.CancellationToken) + .ConfigureAwait(true); + + using var dockerClient = TestcontainersSettings.OS.DockerEndpointAuthConfig.GetDockerClientBuilder().Build(); + + // When + var imageInspectResponse = await dockerClient.Images.InspectImageAsync(image.FullName, TestContext.Current.CancellationToken) + .ConfigureAwait(true); + + // Then + Assert.Equal(buildArgumentValue, imageInspectResponse.Config.Labels["org.testcontainers.magic-number"]); + Assert.Equal(bool.TrueString.ToLowerInvariant(), imageInspectResponse.Config.Labels["org.testcontainers.buildkit"]); + Assert.Contains(ResourceReaper.ResourceReaperSessionLabel, imageInspectResponse.Config.Labels.Keys); + } + + [Fact] + public async Task BuildsUpToExpectedTarget() + { + // Given + var dockerfileDirectoryPath = CreateDockerfileDirectory($""" + FROM {CommonImages.Alpine.FullName} AS build + RUN touch /build + + FROM build AS final + RUN touch /final + """); + + await using var image = new BuildKitImageFromDockerfileBuilder() + .WithDockerfileDirectory(dockerfileDirectoryPath) + .WithTarget("build") + .Build(); + + await image.CreateAsync(TestContext.Current.CancellationToken) + .ConfigureAwait(true); + + await using var container = CreateKeepAliveContainer(image); + + await container.StartAsync(TestContext.Current.CancellationToken) + .ConfigureAwait(true); + + // When + var execResult = await container.ExecAsync(new[] { "ls", "/final" }, TestContext.Current.CancellationToken) + .ConfigureAwait(true); + + // Then + Assert.NotEqual(0L, execResult.ExitCode); + } + + [Fact] + public async Task BuildFailureIncludesDetailedErrorMessage() + { + // Given + var dockerfileDirectoryPath = CreateDockerfileDirectory($""" + FROM {CommonImages.Alpine.FullName} + RUN command-that-does-not-exist + """); + + await using var image = new BuildKitImageFromDockerfileBuilder() + .WithDockerfileDirectory(dockerfileDirectoryPath) + .Build(); + + // When + var exception = await Assert.ThrowsAsync(() => image.CreateAsync(TestContext.Current.CancellationToken)) + .ConfigureAwait(true); + + // Then + Assert.StartsWith("Docker image ", exception.Message); + Assert.Contains(" has not been created.", exception.Message); + Assert.Contains("command-that-does-not-exist", exception.Message); + } + + private static string CreateDockerfileDirectory(string dockerfile) + { + var dockerfileDirectoryPath = Directory.CreateDirectory(Path.Combine(TestSession.TempDirectoryPath, Guid.NewGuid().ToString("D"))).FullName; + File.WriteAllText(Path.Combine(dockerfileDirectoryPath, "Dockerfile"), dockerfile); + return dockerfileDirectoryPath; + } + + private static IContainer CreateKeepAliveContainer(IImage image) + { + // The images that the tests build either exit immediately or do not have an + // entrypoint at all. Keep the container running, so that the tests can inspect + // the image content. + return new ContainerBuilder() + .WithImage(image) + .WithEntrypoint("/bin/sh", "-c") + .WithCommand("trap 'exit 0' TERM; sleep infinity & wait $!") + .Build(); + } +} + +[CollectionDefinition(nameof(DockerSocketOverrideCollection), DisableParallelization = true)] +public static class DockerSocketOverrideCollection +{ +} + +[Collection(nameof(DockerSocketOverrideCollection))] +public sealed class BuildKitImageFromDockerfileDockerSocketTest : IDisposable +{ + private readonly string _dockerSocketOverride = TestcontainersSettings.DockerSocketOverride; + + private bool _disposed; + + public void Dispose() + { + if (_disposed) + { + return; + } + + TestcontainersSettings.DockerSocketOverride = _dockerSocketOverride; + _disposed = true; + } + + [Fact] + public async Task ThrowsWhenDockerSocketIsNotAvailable() + { + // Given + + // A Docker daemon that does not listen on a Unix socket, such as a Docker + // daemon that is reached over a Windows named pipe, cannot provide a Docker + // socket to bind-mount into the Docker CLI container. + TestcontainersSettings.DockerSocketOverride = "/var/run/docker-socket-does-not-exist.sock"; + + var dockerfileDirectoryPath = Directory.CreateDirectory(Path.Combine(TestSession.TempDirectoryPath, Guid.NewGuid().ToString("D"))).FullName; + + await File.WriteAllTextAsync(Path.Combine(dockerfileDirectoryPath, "Dockerfile"), $"FROM {CommonImages.Alpine.FullName}", TestContext.Current.CancellationToken) + .ConfigureAwait(true); + + await using var image = new BuildKitImageFromDockerfileBuilder() + .WithDockerfileDirectory(dockerfileDirectoryPath) + .Build(); + + // When + var exception = await Assert.ThrowsAsync(() => image.CreateAsync(TestContext.Current.CancellationToken)) + .ConfigureAwait(true); + + // Then + Assert.Contains("/var/run/docker-socket-does-not-exist.sock", exception.Message); + Assert.Contains(nameof(TestcontainersSettings.DockerSocketOverride), exception.Message); + } +} diff --git a/tests/Testcontainers.Tests/Unit/Builders/BuildKitImageFromDockerfileBuilderTest.cs b/tests/Testcontainers.Tests/Unit/Builders/BuildKitImageFromDockerfileBuilderTest.cs new file mode 100644 index 000000000..0e076f143 --- /dev/null +++ b/tests/Testcontainers.Tests/Unit/Builders/BuildKitImageFromDockerfileBuilderTest.cs @@ -0,0 +1,126 @@ +namespace DotNet.Testcontainers.Tests.Unit +{ + using System; + using System.IO; + using DotNet.Testcontainers.Builders; + using DotNet.Testcontainers.Commons; + using Xunit; + + public sealed class BuildKitImageFromDockerfileBuilderTest + { + [Fact] + public void BuildsWithDefaultConfiguration() + { + // Given + var imageFromDockerfileBuilder = new BuildKitImageFromDockerfileBuilder(); + + // When + var exception = Record.Exception(() => imageFromDockerfileBuilder.Build()); + + // Then + Assert.Null(exception); + } + + [Fact] + public void BuildsWithCustomDockerCliImage() + { + // Given + var imageFromDockerfileBuilder = new BuildKitImageFromDockerfileBuilder(CommonImages.DockerCli); + + // When + var exception = Record.Exception(() => imageFromDockerfileBuilder.Build()); + + // Then + Assert.Null(exception); + } + + [Fact] + public void ThrowsArgumentExceptionWhenReuseIsEnabled() + { + // Given + var imageFromDockerfileBuilder = new BuildKitImageFromDockerfileBuilder().WithReuse(true); + + // When + var exception = Assert.Throws(() => imageFromDockerfileBuilder.Build()); + + // Then + Assert.StartsWith("Building an image does not support the reuse feature.", exception.Message); + } + + [Theory] + [InlineData("")] + [InlineData("-invalid")] + [InlineData("invalid id")] + [InlineData("../invalid")] + [InlineData("invalid/id")] + public void ThrowsArgumentExceptionWhenSecretIdIsInvalid(string secretId) + { + // Given + var imageFromDockerfileBuilder = new BuildKitImageFromDockerfileBuilder().WithSecret(secretId, "value"); + + // When + var exception = Assert.Throws(() => imageFromDockerfileBuilder.Build()); + + // Then + Assert.StartsWith($"The build secret id '{secretId}' must start with", exception.Message); + } + + [Fact] + public void ThrowsArgumentExceptionWhenSecretIdIsNotUnique() + { + // Given + var imageFromDockerfileBuilder = new BuildKitImageFromDockerfileBuilder() + .WithSecret("mysecret", "value") + .WithSecret("mysecret", new FileInfo("value")); + + // When + var exception = Assert.Throws(() => imageFromDockerfileBuilder.Build()); + + // Then + Assert.StartsWith("The build secret id 'mysecret' is set more than once.", exception.Message); + } + + [Fact] + public void ThrowsFileNotFoundExceptionWhenSecretFileDoesNotExist() + { + // Given + var secretFilePath = Path.Combine(TestSession.TempDirectoryPath, Guid.NewGuid().ToString("D")); + + var imageFromDockerfileBuilder = new BuildKitImageFromDockerfileBuilder().WithSecret("mysecret", new FileInfo(secretFilePath)); + + // When + var exception = Assert.Throws(() => imageFromDockerfileBuilder.Build()); + + // Then + Assert.Equal($"The build secret file '{secretFilePath}' does not exist.", exception.Message); + } + + [Fact] + public void ThrowsArgumentExceptionWhenSshAgentIdIsInvalid() + { + // Given + var imageFromDockerfileBuilder = new BuildKitImageFromDockerfileBuilder().WithSshAgent("invalid id"); + + // When + var exception = Assert.Throws(() => imageFromDockerfileBuilder.Build()); + + // Then + Assert.StartsWith("The SSH agent id 'invalid id' must start with", exception.Message); + } + + [Fact] + public void ThrowsArgumentExceptionWhenSshAgentPathContainsComma() + { + // Given + var sshAgentPath = Path.Combine(TestSession.TempDirectoryPath, "ssh,agent.sock"); + + var imageFromDockerfileBuilder = new BuildKitImageFromDockerfileBuilder().WithSshAgent("default", sshAgentPath); + + // When + var exception = Assert.Throws(() => imageFromDockerfileBuilder.Build()); + + // Then + Assert.StartsWith($"The SSH agent path '{sshAgentPath}' cannot contain a comma", exception.Message); + } + } +}