Working with a container

edit

A 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();

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 0

The 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. wslc does 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 container does 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();
MethodEffect
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 Length is known: copy it to a MemoryStream when 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}");
}
OptionEffect
Since, UntilReturn the entries written in this time range, bounds included.
TailReturn the last entries only. The runtime takes them first, then applies the time range and the stream selection.
FollowKeep returning the new entries (the default), or end with the entries written so far.
IncludeStandardOutput, IncludeStandardErrorSelect 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 only

The 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
MethodEffect
StopAsyncStops the container. It keeps its file system; StartAsync starts it again.
StartAsyncCreates the container if needed, starts it, and runs the wait strategies.
RestartAsyncRestarts the container. It does not run the wait strategies: call WaitUntilReadyAsync after it.
PauseAsync, UnpauseAsyncFreezes and resumes the processes of the container, to simulate a service that hangs.
KillAsyncSends a kill signal to the container, to simulate a crash.
InspectAsyncReturns a ContainerInfo: id, name, image, State, Health, exit code, start and finish times, IP address, ports and labels.
ExistsAsyncTells whether the container still exists.
DeleteAsyncRemoves 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);