NAME

App::Actions::Docker - run Docker commands for the workflow runner

SYNOPSIS

Check that Docker works, then pull an image:

use App::Actions::Docker;

my $docker = App::Actions::Docker->new(verbose => 1);
$docker->check;                  # dies if Docker is missing or not running
$docker->pull('perl:5.40');      # dies if the pull fails

Start a container, run a command in it, and remove it:

$docker->container_start('--name', 'job1', 'perl:5.40', 'sleep', 'infinity');
my $status = $docker->container_exec('job1', 'perl', '-v');
$docker->container_remove('job1');

Pass a secret without showing it to other users:

use App::Actions::Docker::Variable;

my $docker = App::Actions::Docker->new(secrets => { TOKEN => $token });
$docker->run('run', '--rm',
    '-e', App::Actions::Docker::Variable->new(TOKEN => $token),
    'alpine', 'sh', '-c', 'test -n "$TOKEN"');

See what would run, without running it:

my $docker = App::Actions::Docker->new(dry_run => 1);
$docker->run('run', '--rm', 'alpine', 'true');
# prints: [dry-run] docker run --rm alpine true

Read the output of a command:

my ($output, $status) = $docker->capture('ps', '--format', '{{.Names}}');

DESCRIPTION

This module is the only place where the distribution starts the docker program. It gives the rest of the code one method for each Docker task, and it adds four things on top of the plain command line:

  • Dry run. With dry_run, nothing is started. Each command is printed with [dry-run] in front, and each method returns the answer that a successful command would give.

  • Verbose output. With verbose, each command is printed with + in front before it runs.

  • Secret redaction. Every printed command has the values of the known secrets replaced by ***. This changes only what is printed, never what runs.

  • Private job variables. See "Job variables".

The Docker program does not have to be the real docker binary. It is chosen in this order: the bin argument, the DOCKER environment variable, then docker on the PATH. If it is a Perl script (its first line is a #! line naming perl, or its name ends in .pl), it is run with the same Perl as this program. So a Perl wrapper works on Windows too, where the system cannot start a #! script by itself.

No method uses a shell. Arguments are passed to Docker exactly as given, so spaces, quotes and $ in them are safe.

Job variables

Every user of the host can read a process's arguments (with ps, or from /proc/PID/cmdline), so a -e TOKEN=value on the docker client's command line would publish the value. A job variable is therefore passed as an App::Actions::Docker::Variable object after -e. When the command runs, the object becomes a bare -e NAME, and the value is put in the client's environment, which only its own user can read, for that one command. The client reads -e NAME from its environment.

A variable stays on the command line as NAME=VALUE when hiding it would change what the command does:

  • the name is not one an environment can carry (letters, digits and _, not starting with a digit);

  • the name is one the docker client reads for itself, such as DOCKER_HOST, PATH, HOME, TMPDIR, HTTPS_PROXY or SSL_CERT_FILE: set in the client's environment it would change where the client connects, what it trusts or where it finds programs;

  • another variable in the same command has the same name in a different case, which Windows would treat as one variable;

  • the value contains a NUL character, which no environment can hold.

Arguments that are plain strings, including a -e NAME=VALUE meant for the program inside the container, are passed exactly as given. What is printed in verbose and dry-run modes is the NAME=VALUE form, with secrets redacted.

METHODS

new

my $docker = App::Actions::Docker->new(%options);

Makes a Docker object. Nothing is run yet.

ARGUMENTS

All are optional.

  • bin - the Docker program. Default: $ENV{DOCKER}, then docker.

  • dry_run - true to print commands instead of running them.

  • explain - true in a dry run that prints a report of its own: then the [dry-run] lines are not printed.

  • verbose - true to print each command before it runs.

  • quiet - true to print nothing extra. It wins over verbose.

  • platform - a platform such as linux/amd64, given to docker pull as --platform.

  • secrets - a hash reference of secret names to values. Their values are replaced by *** in printed commands, and a NAME=VALUE argument whose name is a secret prints as NAME=***.

RETURNS

The new object.

SIDE EFFECTS

Reads the first 256 bytes of the Docker program, to see if it is a Perl script.

EXAMPLE

my $docker = App::Actions::Docker->new(
    bin      => '/usr/local/bin/docker',
    verbose  => 1,
    platform => 'linux/arm64',
    secrets  => { API_KEY => $key },
);

API SPECIFICATION

INPUT

{
    bin      => { type => 'string', min => 1, optional => 1 },
    dry_run  => { type => 'boolean', optional => 1 },
    explain  => { type => 'boolean', optional => 1 },
    verbose  => { type => 'boolean', optional => 1 },
    quiet    => { type => 'boolean', optional => 1 },
    platform => { type => 'string', optional => 1 },
    secrets  => { type => 'hashref', optional => 1 },
}

OUTPUT

{ type => 'object', isa => 'App::Actions::Docker' }

MESSAGES

None.

bin

my $program = $docker->bin;

Returns the Docker program this object runs, as chosen by "new".

API SPECIFICATION

INPUT

{}

OUTPUT

{ type => 'string', min => 1 }

MESSAGES

None.

dry_run

if ($docker->dry_run) { ... }

Returns 1 in a dry run, else 0.

API SPECIFICATION

INPUT

{}

OUTPUT

{ type => 'integer', memberof => [ 0, 1 ] }

MESSAGES

None.

explain

if ($docker->explain) { ... }

Returns 1 if the object was made with explain, else 0.

API SPECIFICATION

INPUT

{}

OUTPUT

{ type => 'integer', memberof => [ 0, 1 ] }

MESSAGES

None.

verbose

if ($docker->verbose) { ... }

Returns 1 if the object was made with verbose, else 0.

API SPECIFICATION

INPUT

{}

OUTPUT

{ type => 'integer', memberof => [ 0, 1 ] }

MESSAGES

None.

quiet

if ($docker->quiet) { ... }

Returns 1 if the object was made with quiet, else 0.

API SPECIFICATION

INPUT

{}

OUTPUT

{ type => 'integer', memberof => [ 0, 1 ] }

MESSAGES

None.

platform_args

my @args = $docker->platform_args;

Returns ('--platform', $platform) if a platform was given to "new", or an empty list. Add it to your own docker commands that accept --platform.

EXAMPLE

$docker->run('run', $docker->platform_args, '--rm', 'alpine', 'uname', '-m');

API SPECIFICATION

INPUT

{}

OUTPUT

{ type => 'array', max => 2 }

MESSAGES

None.

check

$docker->check;

Makes sure that the Docker program exists and that the Docker daemon (the background service) answers.

ARGUMENTS

None.

RETURNS

1. In a dry run, 1 at once, without checking.

SIDE EFFECTS

Runs docker --version and docker info. When verbose, prints the version. Unless quiet, the error output of docker info is shown.

EXAMPLE

eval { $docker->check; 1 } or die "Please start Docker first: $@";

API SPECIFICATION

INPUT

{}

OUTPUT

{ type => 'integer', memberof => [ 1 ] }

MESSAGES

+-------------------------------------------+-------------------------+-------------------------------+
| Exception (croak)                         | Meaning                 | What to do                    |
+-------------------------------------------+-------------------------+-------------------------------+
| Docker executable 'BIN' was not found or  | "BIN --version" failed  | install Docker, or set DOCKER |
| is not usable                             |                         | or --docker to the program    |
| Cannot connect to the Docker daemon. Is   | "BIN info" failed       | start Docker; add your user   |
| Docker running, and can this user access  |                         | to the docker group           |
| it?                                       |                         |                               |
+-------------------------------------------+-------------------------+-------------------------------+

image_exists

if ($docker->image_exists('perl:5.40')) { ... }

Says whether the image is already on this computer.

ARGUMENTS

  • $image - an image name, such as perl:5.40.

RETURNS

1 if it is, 0 if not. Always 1 in a dry run.

SIDE EFFECTS

Runs docker image inspect.

API SPECIFICATION

INPUT

{ image => { type => 'string', min => 1 } }

OUTPUT

{ type => 'integer', memberof => [ 0, 1 ] }

MESSAGES

None.

image_size

my $bytes = $docker->image_size('perl:5.40');

Returns the size of a local image in bytes.

ARGUMENTS

  • $image - an image name.

RETURNS

The size, or undef if the image is not on this computer, Docker cannot say, or this is a dry run. A missing image is not an error.

SIDE EFFECTS

Runs docker image inspect.

API SPECIFICATION

INPUT

{ image => { type => 'string', min => 1 } }

OUTPUT

{ type => 'integer', min => 0, optional => 1 }

MESSAGES

None.

pull

$docker->pull('perl:5.40');

Downloads an image. Docker's progress is shown on the terminal.

ARGUMENTS

  • $image - an image name.

RETURNS

1 on success.

SIDE EFFECTS

Runs docker pull, with --platform if one was given to "new".

API SPECIFICATION

INPUT

{ image => { type => 'string', min => 1 } }

OUTPUT

{ type => 'integer', memberof => [ 1 ] }

MESSAGES

+------------------------------------------+---------------------+-------------------------------+
| Exception (die)                          | Meaning             | What to do                    |
+------------------------------------------+---------------------+-------------------------------+
| Failed to pull Docker image 'IMAGE'      | docker pull failed  | check the name, your network  |
| (exit N)                                 | with status N       | and "docker login"            |
+------------------------------------------+---------------------+-------------------------------+

pull_many

my $results = $docker->pull_many(\@images, concurrency => 4);

Downloads several images at the same time.

ARGUMENTS

  • \@images - the image names. At least one.

  • concurrency - optional. How many pulls run at once. Default 4.

  • quiet - optional. True to hide Docker's output, for example when you are building a report.

RETURNS

A hash reference from each image name to the exit status of its pull. 0 means success. In a dry run, every status is 0.

SIDE EFFECTS

Starts one docker pull per image, at most concurrency at a time. When verbose, all the commands are printed first, because the pulls then write to the terminal in no fixed order.

EXAMPLE

my $results = $docker->pull_many(['alpine:3', 'perl:5.40']);
my @failed = grep { $results->{$_} != 0 } sort keys %{$results};

API SPECIFICATION

INPUT

{
    images      => { type => 'arrayref', min => 1, element_type => 'string' },
    concurrency => { type => 'integer', min => 1, optional => 1, default => 4 },
    quiet       => { type => 'boolean', optional => 1 },
}

OUTPUT

{ type => 'hashref' }

MESSAGES

+------------------+-----------------------------+------------------------+
| Exception (croak)| Meaning                     | What to do             |
+------------------+-----------------------------+------------------------+
| No images given  | the list was empty or not   | pass an array          |
|                  | an array reference          | reference with names   |
+------------------+-----------------------------+------------------------+

image_remove

my $status = $docker->image_remove('perl:5.40');

Removes a local image, even if a stopped container uses it (docker image rm -f).

RETURNS

The exit status of the command: 0 on success. 0 in a dry run.

SIDE EFFECTS

Runs the command with its output hidden.

API SPECIFICATION

INPUT

{ image => { type => 'string', min => 1 } }

OUTPUT

{ type => 'integer', min => 0 }

MESSAGES

None. Check the status.

network_create

my $status = $docker->network_create('job-net');

Makes a Docker network, so that a job container and its service containers can reach each other by name.

RETURNS

The exit status: 0 on success. 0 in a dry run.

SIDE EFFECTS

Runs docker network create with its output hidden.

API SPECIFICATION

INPUT

{ name => { type => 'string', min => 1 } }

OUTPUT

{ type => 'integer', min => 0 }

MESSAGES

None. Check the status.

network_remove

my $status = $docker->network_remove('job-net');

Removes a Docker network.

RETURNS

The exit status: 0 on success. 0 in a dry run.

SIDE EFFECTS

Runs docker network rm with its output hidden.

API SPECIFICATION

INPUT

{ name => { type => 'string', min => 1 } }

OUTPUT

{ type => 'integer', min => 0 }

MESSAGES

None. Check the status.

container_start

my $status = $docker->container_start(@run_arguments);

Starts a container in the background (docker run -d ...).

ARGUMENTS

  • @run_arguments - everything after docker run -d: options, the image and the command. App::Actions::Docker::Variable objects after -e are kept off the command line.

RETURNS

The exit status: 0 if the container started. 0 in a dry run.

SIDE EFFECTS

Runs the command with its output hidden.

EXAMPLE

$docker->container_start('--name', 'job1', '--network', 'job-net', 'perl:5.40', 'sleep', 'infinity');

API SPECIFICATION

INPUT

{ args => { type => 'array', min => 1 } }

OUTPUT

{ type => 'integer', min => 0 }

MESSAGES

None. Use "container_start_captured" to learn why a start failed.

container_start_captured

my ($output, $status) = $docker->container_start_captured(@run_arguments);

Like "container_start", but also returns what Docker printed, with its error output included. When a start fails, that text says why (for example "port is already allocated").

RETURNS

A list of two values: the text (never undef), and the exit status.

SIDE EFFECTS

Runs docker run -d. Nothing is printed.

EXAMPLE

my ($output, $status) = $docker->container_start_captured('--name', 'db', 'postgres:16');
die "The database did not start: $output" if $status != 0;

API SPECIFICATION

INPUT

{ args => { type => 'array', min => 1 } }

OUTPUT

{ type => 'array', min => 2, max => 2 }    # (string, integer)

MESSAGES

None.

container_exec

my $status = $docker->container_exec('job1', 'sh', '-c', 'make test');

Runs a command in a running container (docker exec ...). Its output is shown on the terminal as it happens.

RETURNS

The exit status of the command in the container. 0 in a dry run.

API SPECIFICATION

INPUT

{ args => { type => 'array', min => 2 } }

OUTPUT

{ type => 'integer', min => 0 }

MESSAGES

None.

container_running

if ($docker->container_running('job1')) { ... }

Says whether a container is running now.

RETURNS

1 if it is, 0 if it is not or does not exist. Always 1 in a dry run.

SIDE EFFECTS

Runs docker inspect.

API SPECIFICATION

INPUT

{ name => { type => 'string', min => 1 } }

OUTPUT

{ type => 'integer', memberof => [ 0, 1 ] }

MESSAGES

None.

container_logs

$docker->container_logs('job1', 50);

Shows the last lines a container printed, on the terminal.

ARGUMENTS

  • $name - the container.

  • $lines - optional. How many lines. Default 200.

RETURNS

The exit status of docker logs.

API SPECIFICATION

INPUT

{
    name  => { type => 'string', min => 1 },
    lines => { type => 'integer', min => 0, optional => 1, default => 200 },
}

OUTPUT

{ type => 'integer', min => 0 }

MESSAGES

None.

container_log_text

my $text = $docker->container_log_text('db', 10);

Returns the last lines a container printed, with its error output included, as text. Use it to put the reason a service failed into an error message. Nothing is printed.

ARGUMENTS

  • $name - the container.

  • $lines - optional. How many lines. Default 10.

RETURNS

The text. The empty string in a dry run or if Docker says nothing.

API SPECIFICATION

INPUT

{
    name  => { type => 'string', min => 1 },
    lines => { type => 'integer', min => 0, optional => 1, default => 10 },
}

OUTPUT

{ type => 'string' }

MESSAGES

None.

container_exit_code

my $status = $docker->container_exit_code('job1');

Returns the status a container exited with.

RETURNS

The status, or undef when Docker cannot say, for example because the container was already removed. 0 in a dry run.

API SPECIFICATION

INPUT

{ name => { type => 'string', min => 1 } }

OUTPUT

{ type => 'integer', optional => 1 }

MESSAGES

None.

container_status

my $state = $docker->container_status('db');

Returns the container's state as Docker says it, for example running, restarting, exited or dead.

RETURNS

The state, or undef when Docker cannot say. running in a dry run.

API SPECIFICATION

INPUT

{ name => { type => 'string', min => 1 } }

OUTPUT

{ type => 'string', min => 1, optional => 1 }

MESSAGES

None.

container_restart_count

my $count = $docker->container_restart_count('db');

Returns how many times Docker has restarted the container because of its restart policy. A container that is crashing again and again can look "running" between restarts; a count above 0 shows the problem.

RETURNS

The count, or undef when Docker cannot say. 0 in a dry run.

API SPECIFICATION

INPUT

{ name => { type => 'string', min => 1 } }

OUTPUT

{ type => 'integer', min => 0, optional => 1 }

MESSAGES

None.

container_remove

my $status = $docker->container_remove('job1');

Stops and removes a container (docker rm -f).

RETURNS

The exit status: 0 on success. 0 in a dry run.

SIDE EFFECTS

Runs the command with its output hidden.

API SPECIFICATION

INPUT

{ name => { type => 'string', min => 1 } }

OUTPUT

{ type => 'integer', min => 0 }

MESSAGES

None.

run

my $status = $docker->run(@arguments);

Runs any Docker command. Its output is shown on the terminal as it happens.

ARGUMENTS

RETURNS

The exit status. A command killed by a signal gives 128 plus the signal number, as a shell does. 0 in a dry run.

SIDE EFFECTS

Prints the command first if verbose or dry_run (secrets redacted). Sets the hidden job variables in %ENV while the command runs.

EXAMPLE

my $status = $docker->run('image', 'prune', '-f');

API SPECIFICATION

INPUT

{ args => { type => 'array', min => 1 } }

OUTPUT

{ type => 'integer', min => 0 }

MESSAGES

None. Check the status.

run_silent

my $status = $docker->run_silent(@arguments);

Like "run", but the command's output and error output are thrown away.

API SPECIFICATION

INPUT

{ args => { type => 'array', min => 1 } }

OUTPUT

{ type => 'integer', min => 0 }

MESSAGES

None.

capture

my ($output, $status) = $docker->capture(@arguments);

Runs a Docker command and returns what it printed. Its error output still goes to the terminal.

RETURNS

A list of two values: the output (Windows line ends \r\n become \n), and the exit status. In a dry run, ('', 0).

SIDE EFFECTS

As for "run".

EXAMPLE

my ($names) = $docker->capture('ps', '--format', '{{.Names}}');
my @running = split /\n/, $names;

API SPECIFICATION

INPUT

{ args => { type => 'array', min => 1 } }

OUTPUT

{ type => 'array', min => 2, max => 2 }    # (string, integer)

MESSAGES

None.

capture_silent

my ($output, $status) = $docker->capture_silent(@arguments);

Like "capture", but the error output is thrown away.

API SPECIFICATION

INPUT

{ args => { type => 'array', min => 1 } }

OUTPUT

{ type => 'array', min => 2, max => 2 }    # (string, integer)

MESSAGES

None.

capture_merged

my ($output, $status) = $docker->capture_merged(@arguments);

Like "capture", but the error output is included in the returned text, in the order the command wrote it (like the shell's 2>&1).

API SPECIFICATION

INPUT

{ args => { type => 'array', min => 1 } }

OUTPUT

{ type => 'array', min => 2, max => 2 }    # (string, integer)

MESSAGES

None.

redact

my $safe = $docker->redact($text);

Replaces every known secret value in a text with ***.

ARGUMENTS

  • $text - any text, or undef.

RETURNS

The text with the secrets replaced. undef stays undef. When one secret contains another, the longer one is replaced first, so no part of it is left.

SIDE EFFECTS

None.

EXAMPLE

my $docker = App::Actions::Docker->new(secrets => { PASS => 'hunter2' });
print $docker->redact('login with hunter2');    # login with ***

API SPECIFICATION

INPUT

{ text => { type => 'string', optional => 1 } }

OUTPUT

{ type => 'string', optional => 1 }

MESSAGES

None.

COMMON PITFALLS

  • Most methods return a status; they do not die. Only "check", "pull" and "pull_many" (with no images) throw exceptions. Always look at the returned status of "run", "container_exec" and the others.

  • A dry run answers "yes". In a dry run, "image_exists" and "container_running" return 1, statuses are 0, and "image_size" is undef. Do not use a dry run to learn about the real computer.

  • capture returns a list. my $output = $docker->capture(...) gives you the status (the last element), not the output. Write my ($output, $status) = ....

  • Redaction works on whole values. A secret is hidden only where its complete value appears. If a command prints the secret encoded (for example in base64) or split, it is not hidden. An empty secret is never redacted.

  • Plain NAME=VALUE strings are not hidden. Only App::Actions::Docker::Variable objects are kept off the command line.

  • Variables named like Docker's own stay visible. A job variable called DOCKER_TOKEN or HOME is passed on the command line. See "Job variables".

  • A secret must be given to "new" to be redacted. Values the object was not told about are printed as they are.

ENCODING

Arguments are passed to Docker as bytes. Pass ASCII, or text you have already encoded (for example with Encode::encode('UTF-8', $text)): non-ASCII characters and emoji then work in image names, commands and variable values as far as Docker itself allows them. Captured output is returned as bytes, as Docker printed it; decode it yourself if you need characters. A value with a NUL character cannot be passed in an environment, so such a variable stays on the command line.

LIMITATIONS

  • Variables kept on the command line, as listed under "Job variables", can be read there by other users of the host.

  • Only job variables are hidden. A secret in a docker action's args, or in a -e NAME=VALUE written in a workflow's container options, is an ordinary argument and stays visible.

  • The daemon keeps each container's environment, and anyone who can reach it (members of the docker group, for example) can read it with docker inspect.

  • Captured output is read into memory, so a command that prints a lot uses a lot of memory.

AUTHOR

Nigel Horne, njh@nigelhorne.com

FORMAL SPECIFICATION

This section describes each routine in the Z notation, a precise mathematical language for describing programs. You do not need to read it to use the module: everything here is also said in plain words in the sections above.

How to read it: a box names a routine. The lines above the middle rule declare the values it works with. A name ending in ? is an input, and a name ending in ! is an output. ΞDocker means the routine changes nothing in the object. The lines below the rule are always true when the routine returns.

[ARG, TEXT, STATUS == ℕ]
exec : seq ARG → TEXT × STATUS          the command's output and status
printed : seq TEXT                       what this module has printed

┌─ Docker ──────────────────────────────────────────
│ bin           : seq CHAR
│ dry_run, explain, verbose, quiet : 𝔹
│ platform      : optional seq CHAR
│ secret_names  : ℙ NAME
│ secret_values : ℙ seq CHAR
├───────────────────────────────────────────────────
│ ⟨⟩ ¬in; secret_values
└───────────────────────────────────────────────────

┌─ Announce ────────────────────────────────────────
│ ΞDocker
│ args? : seq ARG
│ go!   : 𝔹
├───────────────────────────────────────────────────
│ dry_run ∧ ¬quiet ∧ ¬explain ⇒ printed' = printed ⁀ ⟨"[dry-run] " ⁀ display(args?)⟩
│ ¬dry_run ∧ verbose ∧ ¬quiet ⇒ printed' = printed ⁀ ⟨"+ " ⁀ display(args?)⟩
│ go! ⇔ ¬dry_run
│ ∀ s ∈ secret_values • s not in display(args?)
└───────────────────────────────────────────────────

hide(args) = (argv, env) where each Variable(n, v) that is hideable
becomes n in argv and n ↦ v in env, and every other value is itself.

new

┌─ New ─────────────────────────────────────────────
│ Docker'
│ options? : NAME ⇸ VALUE
├───────────────────────────────────────────────────
│ bin' = first non-empty of ⟨options?.bin, ENV.DOCKER, "docker"⟩
│ dry_run' = bool(options?.dry_run)   (and so for explain, verbose, quiet)
│ secret_names' = dom options?.secrets
│ secret_values' = { v : ran options?.secrets | v ≠ ⊥ ∧ v ≠ ⟨⟩ }
└───────────────────────────────────────────────────

bin

┌─ Bin ─────────────────────────────────────────────
│ ΞDocker
│ r! : seq CHAR
├───────────────────────────────────────────────────
│ r! = bin
└───────────────────────────────────────────────────

dry_run

┌─ DryRun ──────────────────────────────────────────
│ ΞDocker
│ r! : {0, 1}
├───────────────────────────────────────────────────
│ r! = 1 ⇔ dry_run
└───────────────────────────────────────────────────

explain

┌─ Explain ─────────────────────────────────────────
│ ΞDocker
│ r! : {0, 1}
├───────────────────────────────────────────────────
│ r! = 1 ⇔ explain
└───────────────────────────────────────────────────

verbose

┌─ Verbose ─────────────────────────────────────────
│ ΞDocker
│ r! : {0, 1}
├───────────────────────────────────────────────────
│ r! = 1 ⇔ verbose
└───────────────────────────────────────────────────

quiet

┌─ Quiet ───────────────────────────────────────────
│ ΞDocker
│ r! : {0, 1}
├───────────────────────────────────────────────────
│ r! = 1 ⇔ quiet
└───────────────────────────────────────────────────

platform_args

┌─ PlatformArgs ────────────────────────────────────
│ ΞDocker
│ r! : seq ARG
├───────────────────────────────────────────────────
│ platform ≠ ⊥ ∧ platform ≠ ⟨⟩ ⇒ r! = ⟨"--platform", platform⟩
│ otherwise r! = ⟨⟩
└───────────────────────────────────────────────────

check

┌─ Check ───────────────────────────────────────────
│ ΞDocker
│ r! : {1}
├───────────────────────────────────────────────────
│ dry_run ⇒ r! = 1
│ ¬dry_run ∧ second(exec(⟨"--version"⟩)) ≠ 0 ⇒ raises NotUsable(bin)
│ ¬dry_run ∧ second(exec(⟨"info", ...⟩)) ≠ 0 ⇒ raises NoDaemon
│ otherwise r! = 1
└───────────────────────────────────────────────────

image_exists

┌─ ImageExists ─────────────────────────────────────
│ ΞDocker
│ image? : seq CHAR
│ r!     : {0, 1}
├───────────────────────────────────────────────────
│ dry_run ⇒ r! = 1
│ ¬dry_run ⇒ (r! = 1 ⇔ second(exec(⟨"image", "inspect", ..., image?⟩)) = 0)
└───────────────────────────────────────────────────

image_size

┌─ ImageSize ───────────────────────────────────────
│ ΞDocker
│ image? : seq CHAR
│ r!     : optional ℕ
├───────────────────────────────────────────────────
│ dry_run ⇒ r! = ⊥
│ (out, s) = exec(⟨"image", "inspect", "--format", "{{.Size}}", image?⟩)
│ ¬dry_run ∧ s = 0 ∧ strip(out) ∈ DIGITS⁺ ⇒ r! = number(strip(out))
│ otherwise r! = ⊥
└───────────────────────────────────────────────────

pull

┌─ Pull ────────────────────────────────────────────
│ ΞDocker
│ image? : seq CHAR
│ r!     : {1}
├───────────────────────────────────────────────────
│ s = Run(⟨"pull"⟩ ⁀ platform_args ⁀ ⟨image?⟩)
│ s = 0 ⇒ r! = 1
│ s ≠ 0 ⇒ raises PullFailed(image?, s)
└───────────────────────────────────────────────────

pull_many

┌─ PullMany ────────────────────────────────────────
│ ΞDocker
│ images?      : seq₁ seq CHAR
│ concurrency? : ℕ₁
│ r!           : seq CHAR ⇸ STATUS
├───────────────────────────────────────────────────
│ images? = ⟨⟩ ⇒ raises NoImages
│ dom r! = ran images?
│ dry_run ⇒ ∀ i ∈ ran images? • r!(i) = 0
│ ¬dry_run ⇒ ∀ i ∈ ran images? • r!(i) = second(exec(⟨"pull"⟩ ⁀ platform_args ⁀ ⟨i⟩))
│ at most concurrency? pulls run at the same time
└───────────────────────────────────────────────────

image_remove

┌─ ImageRemove ─────────────────────────────────────
│ ΞDocker
│ image? : seq CHAR
│ r!     : STATUS
├───────────────────────────────────────────────────
│ r! = RunSilent(⟨"image", "rm", "-f", image?⟩)
└───────────────────────────────────────────────────

network_create

┌─ NetworkCreate ───────────────────────────────────
│ ΞDocker
│ name? : seq CHAR
│ r!    : STATUS
├───────────────────────────────────────────────────
│ r! = RunSilent(⟨"network", "create", name?⟩)
└───────────────────────────────────────────────────

network_remove

┌─ NetworkRemove ───────────────────────────────────
│ ΞDocker
│ name? : seq CHAR
│ r!    : STATUS
├───────────────────────────────────────────────────
│ r! = RunSilent(⟨"network", "rm", name?⟩)
└───────────────────────────────────────────────────

container_start

┌─ ContainerStart ──────────────────────────────────
│ ΞDocker
│ args? : seq ARG
│ r!    : STATUS
├───────────────────────────────────────────────────
│ r! = RunSilent(⟨"run", "-d"⟩ ⁀ args?)
└───────────────────────────────────────────────────

container_start_captured

┌─ ContainerStartCaptured ──────────────────────────
│ ΞDocker
│ args? : seq ARG
│ r!    : TEXT × STATUS
├───────────────────────────────────────────────────
│ r! = CaptureMerged(⟨"run", "-d"⟩ ⁀ args?)
│ first(r!) ≠ ⊥
└───────────────────────────────────────────────────

container_exec

┌─ ContainerExec ───────────────────────────────────
│ ΞDocker
│ args? : seq ARG
│ r!    : STATUS
├───────────────────────────────────────────────────
│ r! = Run(⟨"exec"⟩ ⁀ args?)
└───────────────────────────────────────────────────

container_running

┌─ ContainerRunning ────────────────────────────────
│ ΞDocker
│ name? : seq CHAR
│ r!    : {0, 1}
├───────────────────────────────────────────────────
│ dry_run ⇒ r! = 1
│ (out, s) = exec(⟨"inspect", "--format", "{{.State.Running}}", name?⟩)
│ ¬dry_run ⇒ (r! = 1 ⇔ s = 0 ∧ "true" in out)
└───────────────────────────────────────────────────

container_logs

┌─ ContainerLogs ───────────────────────────────────
│ ΞDocker
│ name?  : seq CHAR
│ lines? : optional ℕ
│ r!     : STATUS
├───────────────────────────────────────────────────
│ r! = Run(⟨"logs", "--tail", if lines? = ⊥ then 200 else lines?, name?⟩)
└───────────────────────────────────────────────────

container_log_text

┌─ ContainerLogText ────────────────────────────────
│ ΞDocker
│ name?  : seq CHAR
│ lines? : optional ℕ
│ r!     : TEXT
├───────────────────────────────────────────────────
│ dry_run ⇒ r! = ⟨⟩
│ ¬dry_run ⇒ r! = first(CaptureMerged(⟨"logs", "--tail", n, name?⟩))
│     where n = if lines? = ⊥ then 10 else lines?
└───────────────────────────────────────────────────

container_exit_code

┌─ ContainerExitCode ───────────────────────────────
│ ΞDocker
│ name? : seq CHAR
│ r!    : optional ℤ
├───────────────────────────────────────────────────
│ dry_run ⇒ r! = 0
│ ¬dry_run ∧ field(name?, ".State.ExitCode") ∈ INTEGER ⇒ r! = that number
│ otherwise r! = ⊥
└───────────────────────────────────────────────────

container_status

┌─ ContainerStatus ─────────────────────────────────
│ ΞDocker
│ name? : seq CHAR
│ r!    : optional seq₁ CHAR
├───────────────────────────────────────────────────
│ dry_run ⇒ r! = "running"
│ ¬dry_run ∧ field(name?, ".State.Status") = f ∧ f ≠ ⟨⟩ ⇒ r! = f
│ otherwise r! = ⊥
└───────────────────────────────────────────────────

container_restart_count

┌─ ContainerRestartCount ───────────────────────────
│ ΞDocker
│ name? : seq CHAR
│ r!    : optional ℕ
├───────────────────────────────────────────────────
│ dry_run ⇒ r! = 0
│ ¬dry_run ∧ field(name?, ".RestartCount") ∈ DIGITS⁺ ⇒ r! = that number
│ otherwise r! = ⊥
└───────────────────────────────────────────────────

container_remove

┌─ ContainerRemove ─────────────────────────────────
│ ΞDocker
│ name? : seq CHAR
│ r!    : STATUS
├───────────────────────────────────────────────────
│ r! = RunSilent(⟨"rm", "-f", name?⟩)
└───────────────────────────────────────────────────

run

┌─ Run ─────────────────────────────────────────────
│ ΞDocker
│ args? : seq ARG
│ r!    : STATUS
├───────────────────────────────────────────────────
│ Announce(args?)
│ ¬go! ⇒ r! = 0
│ go! ∧ hide(args?) = (argv, env) ⇒
│     r! = second(exec(⟨bin⟩ ⁀ argv)) with ENV ⊕ env during the call
│     ∧ ENV' = ENV
│ the output goes to the caller's terminal
└───────────────────────────────────────────────────

run_silent

┌─ RunSilent ───────────────────────────────────────
│ Run
├───────────────────────────────────────────────────
│ the output and error output are discarded
└───────────────────────────────────────────────────

capture

┌─ Capture ─────────────────────────────────────────
│ ΞDocker
│ args? : seq ARG
│ r!    : TEXT × STATUS
├───────────────────────────────────────────────────
│ Announce(args?)
│ ¬go! ⇒ r! = (⟨⟩, 0)
│ go! ⇒ r! = (crlf_to_lf(out), s) where (out, s) = exec(⟨bin⟩ ⁀ argv)
│ the error output goes to the caller's terminal
└───────────────────────────────────────────────────

capture_silent

┌─ CaptureSilent ───────────────────────────────────
│ Capture
├───────────────────────────────────────────────────
│ the error output is discarded
└───────────────────────────────────────────────────

capture_merged

┌─ CaptureMerged ───────────────────────────────────
│ Capture
├───────────────────────────────────────────────────
│ first(r!) holds the output and the error output, in the order written
└───────────────────────────────────────────────────

redact

┌─ Redact ──────────────────────────────────────────
│ ΞDocker
│ text? : optional TEXT
│ r!    : optional TEXT
├───────────────────────────────────────────────────
│ text? = ⊥ ⇒ r! = ⊥
│ text? ≠ ⊥ ⇒ r! = text? with each s ∈ secret_values replaced by "***",
│     longest s first
│ ∀ s ∈ secret_values • s not in r!  (unless "***" itself forms one)
└───────────────────────────────────────────────────

STATE DIAGRAM

The object's settings never change after "new". What changes is the state of each command it runs:

            new(%options)
                 |
                 v
         +---------------+
         |     READY     |<-------------------------------+
         +---------------+                                |
                 |                                        |
                 | run / run_silent / capture* /           |
                 | container_* / image_* / network_* /     |
                 | pull / check                            |
                 v                                        |
         +---------------+                                |
         |  ANNOUNCING   |  verbose: print "+ cmd"        |
         |               |  dry run: print "[dry-run] cmd"|
         +---------------+  (secrets shown as ***)        |
           |           |                                  |
  dry_run  |           | not dry_run                      |
           v           v                                  |
+-------------+  +------------------+                     |
| SKIPPED     |  | HIDING VARIABLES |  Variable objects   |
| returns the |  +------------------+  -> "-e NAME";      |
| success     |           |            values -> %ENV     |
| answer      |           v                               |
+-------------+  +------------------+                     |
       |         |     RUNNING      |  docker client runs,|
       |         +------------------+  no shell           |
       |                  |                               |
       |                  | client exits                  |
       |                  v                               |
       |         +------------------+  %ENV restored;     |
       |         |     FINISHED     |  status (and output)|
       |         +------------------+  returned           |
       |              |         |                          |
       +--------------+         | check / pull fail        |
              |                 v                          |
              |        +------------------+                |
              |        |  ERROR (thrown)  |                |
              |        +------------------+                |
              +--------------------------------------------+

LICENSE AND COPYRIGHT

Copyright 2026 Nigel Horne.

This program is released under the GNU General Public License, version 2. See the LICENSE file that comes with this distribution. If you use it, please let me know.