Working with a container
editA started TemporaryContainer lets a test act on the container: prepare data, check what the service wrote, or simulate an outage.
Run a command
var result = await container.ExecAsync(options =>
{
options.Command = ["psql", "--username", "postgres", "--tuples-only", "--command", "SELECT version()"];
options.Environment.Add("PGCONNECT_TIMEOUT", "5");
});
// A command that fails does not throw: check its exit code
if (result.ExitCode != 0)
throw new InvalidOperationException("psql failed: " + result.StandardError);
var version = result.StandardOutput.Trim();- ExecOptions.Command is the executable and its arguments, passed as is, without a shell.
- ExecOptions also sets the Environment, the User and the WorkingDirectory of the command.
- ExecAsync returns once the command exits, with its ExitCode, StandardOutput and StandardError. A command that fails does not throw; a runtime that cannot run it throws a ContainerRuntimeException.
To send data to the standard input of the command, set StandardInput:
var result = await container.ExecAsync(options =>
{
options.Command = ["psql", "--username", "postgres"];
options.StandardInput = InputSource.FromText("CREATE TABLE orders (id int PRIMARY KEY);");
});InputSource also reads from a stream, bytes or a file. The Docker Engine API runtime cannot send the standard input to a daemon reached over a named pipe, the default on Windows: use the docker runtime there.
Fail when the command fails
EnsureSuccess throws a ContainerExecException when the exit code is not 0. Its message has the command and the standard error, so a failed setup step explains itself in the test output. Timeout bounds the time ExecAsync waits:
var result = await container.ExecAsync(options =>
{
options.Command = ["psql", "--username", "postgres", "--command", "CREATE DATABASE app"];
options.Timeout = TimeSpan.FromSeconds(30); // throws a TimeoutException after 30 seconds
});
result.EnsureSuccess(); // throws a ContainerExecException, with the standard error, when the exit code is not 0The runtimes cannot stop a command they started without a terminal: after the timeout, the command may keep running in the container.
Binary or large output
StandardOutput and StandardError copy the outputs to a stream, byte for byte, instead of decoding them as text. Use them for a database dump or an archive, which decoding would corrupt, or for an output too large to keep in memory:
await using var dump = File.Create("database.dump");
var result = await container.ExecAsync(options =>
{
options.Command = ["pg_dump", "--username", "postgres", "--format", "custom", "postgres"];
options.StandardOutput = dump; // copied byte for byte, never decoded as text
});
result.EnsureSuccess();The matching property of the ExecResult is then empty. The streams are not disposed.
Background commands, terminals and privileges
await container.ExecAsync(options =>
{
options.Command = ["sh", "-c", "seed-data.sh > /tmp/seed.log 2>&1"];
options.Detach = true; // returns as soon as the command is started
});- Detach starts the command and returns at once, with an exit code of 0 and no output. A detached command cannot have a standard input, an output stream or a timeout.
wslcdoes not support it. - Tty runs the command in a pseudo-terminal, for the commands that only print colors or progress, or only behave interactively, in one. A terminal merges the standard error into the standard output and ends lines with
\r\n. - Privileged runs the command with every capability. Apple's
containerdoes not support it.
Copy files
using var settings = new MemoryStream("""{ "mode": "test" }"""u8.ToArray());
await container.WriteFileAsync("/tmp/settings.json", settings); // creates or overwrites the file
await container.CopyToContainerAsync("testdata", "/tmp/testdata"); // a file or a directory
await container.CopyFromContainerAsync("/var/log", "artifacts/container-logs");
await using var stream = await container.OpenReadAsync("/etc/os-release");
using var reader = new StreamReader(stream);
var osRelease = await reader.ReadToEndAsync();| Method | Effect |
|---|---|
WriteFileAsync(path, content) | Creates or overwrites a file of the container with the content of a stream. |
CopyToContainerAsync(source, destination) | Copies a file or a directory of the host into the container. |
CopyFromContainerAsync(source, destination) | Copies a file or a directory of the container to the host. |
OpenReadAsync(path) | Reads a file of the container, as a stream to dispose once read. |
Files are streamed, never loaded in memory, so copying a database dump or a large artifact does not cost its size in memory:
- With the Docker Engine API, the stream OpenReadAsync returns reads the file from the daemon as you read it. It cannot seek, but its
Lengthis known: copy it to aMemoryStreamwhen you need to seek. The CLI runtimes copy the file to a temporary file first, and return a seekable stream that deletes it when disposed. - WriteFileAsync reads the content from its current position. With the Docker Engine API, a seekable stream is sent as it is read; a stream whose length is unknown (a network stream, a pipe) is copied to a temporary file first, because the archive sent to the daemon declares the length of the file before its content.
- The build context of a Dockerfile image is sent to the daemon as it is archived.
The temporary files are only readable by the current user, since a file can hold secrets.
To give files to the container before it starts, a bind mount is often simpler.
Read the logs
// Follows the logs from the creation of the container until it stops: break or cancel to stop earlier
await foreach (var entry in container.GetLogsAsync())
{
Console.WriteLine($"[{entry.Stream}] {entry.Message}");
if (entry.Message.Contains("checkpoint complete", StringComparison.Ordinal))
break;
}GetLogsAsync returns the logs from the creation of the container, then follows the new lines until the container stops. On a running container, the loop does not end on its own: break out of it, or pass a CancellationToken. Each LogEntry has the Message, the Stream it was written to (LogStream.Stdout or LogStream.Stderr) and, when the runtime reports it, a Timestamp.
To read only part of the logs, pass a ContainerLogsOptions:
// The last 50 lines of the standard error written in the last 5 minutes, without following the new ones
var options = new ContainerLogsOptions
{
Since = DateTimeOffset.UtcNow.AddMinutes(-5),
Tail = 50,
Follow = false,
IncludeStandardOutput = false,
};
await foreach (var entry in container.GetLogsAsync(options))
{
Console.WriteLine($"{entry.Timestamp:O} {entry.Message}");
}| Option | Effect |
|---|---|
| Since, Until | Return the entries written in this time range, bounds included. |
| Tail | Return the last entries only. The runtime takes them first, then applies the time range and the stream selection. |
| Follow | Keep returning the new entries (the default), or end with the entries written so far. |
| IncludeStandardOutput, IncludeStandardError | Select the streams. At least one must be included. |
The times are compared with the timestamps the runtime records, which come from the clock of the runtime: wslc reports the time of its virtual machine, which can differ from the time of the host. Apple's container records no timestamps, so it does not support Since and Until and throws a NotSupportedException.
Forward the logs to a logger
To see what the container prints, for instance in the output of a failed test, give the definition an ILogger:
var definition = ContainerDefinition.CreateRedis();
definition.Logging.Logger = logger; // standard output is logged as Information, standard error as Error
definition.Logging.CaptureStandardOutput = false; // forward standard error onlyThe lines are forwarded while the container runs, from StartAsync until it stops or is disposed: standard output at the Information level, standard error at the Error level. A logger that throws stops the forwarding, but never fails the container. A container started again, or restarted, keeps the logs of its earlier runs: only the entries of the current run are forwarded, and the log wait strategy only looks for its message there. When the runtime ends the log stream while the container runs, the forwarding attaches again from the last entry it forwarded. To write them to the output of an xUnit test, see Using containers in tests and CI.
Stop, restart and inspect
await container.StopAsync();
await container.StartAsync(); // starts it again and runs the wait strategies
await container.RestartAsync();
await container.WaitUntilReadyAsync(); // a restart does not run the wait strategies
await container.PauseAsync();
await container.UnpauseAsync();
var info = await container.InspectAsync();
Console.WriteLine($"{info.Name}: {info.State}");
await container.KillAsync();
await container.DeleteAsync(); // removes it now; a later StartAsync creates a new container| Method | Effect |
|---|---|
| StopAsync | Stops the container. It keeps its file system; StartAsync starts it again. |
| StartAsync | Creates the container if needed, starts it, and runs the wait strategies. |
| RestartAsync | Restarts the container. It does not run the wait strategies: call WaitUntilReadyAsync after it. |
| PauseAsync, UnpauseAsync | Freezes and resumes the processes of the container, to simulate a service that hangs. |
| KillAsync | Sends a kill signal to the container, to simulate a crash. |
| InspectAsync | Returns a ContainerInfo: id, name, image, State, Health, exit code, start and finish times, IP address, ports and labels. |
| ExistsAsync | Tells whether the container still exists. |
| DeleteAsync | Removes the container, and the image built for it, right away. A later StartAsync creates a new container. |
Apple's container has no pause: PauseAsync and UnpauseAsync throw a NotSupportedException there. It has no restart either, nor has wslc: RestartAsync stops then starts the container.
Create a container without starting it
EnsureCreatedAsync creates the container, or adopts the one a ReuseId designates, without starting it. Id and Name are available from then on:
await using var container = ContainerDefinition.CreateRedis().CreateContainer();
await container.EnsureCreatedAsync(); // creates the container without starting it
Console.WriteLine(container.Name);