Packages
Build custom Docker images from Dockerfile and use them as typed formulas in testcontainer.
Current section
Files
Jump to
Current section
Files
src/testcontainer_dockerfile.gleam
//// Build custom Docker images from a Dockerfile and use them as typed
//// formulas with [`testcontainer`](https://hex.pm/packages/testcontainer).
////
//// `testcontainer` is a container runner: it does not build images.
//// This package fills the gap by shelling out to `docker build` and
//// wrapping the resulting image in a `Formula`.
import cowl
import fio
import gleam/list
import gleam/result
import gleam/string
import testcontainer/container
import testcontainer/formula
import testcontainer/port
import testcontainer/wait
import testcontainer_dockerfile/error
/// Default build timeout (10 minutes). Can be overridden via
/// [`with_timeout`](#with_timeout).
pub const default_timeout_ms: Int = 600_000
/// Maximum allowed build timeout (1 hour). `with_timeout` clamps to this.
pub const max_timeout_ms: Int = 3_600_000
/// Opaque builder for a Dockerfile build configuration.
/// Construct via [`new`](#new) and refine with `with_*` builders.
pub opaque type DockerfileConfig {
DockerfileConfig(
dockerfile_path: String,
context_path: String,
build_args: List(#(String, String)),
spec_transforms: List(
fn(container.ContainerSpec) -> container.ContainerSpec,
),
timeout_ms: Int,
)
}
/// The typed output yielded after a successful build + container start.
/// Carries the running `Container` (so callers can use
/// `container.host_port`, `container.host`, etc.) plus the built image ID.
pub opaque type DockerImage {
DockerImage(container: container.Container, image_id: String)
}
/// Start a new Dockerfile build configuration.
///
/// `dockerfile_path` should point to a readable Dockerfile, e.g.
/// `"./Dockerfile"` or `"docker/api.Dockerfile"`. The path is trimmed
/// before being passed to `docker build -f`.
pub fn new(dockerfile_path: String) -> DockerfileConfig {
DockerfileConfig(
dockerfile_path: dockerfile_path,
context_path: ".",
build_args: [],
spec_transforms: [],
timeout_ms: default_timeout_ms,
)
}
/// Set the build context (sent to the Docker daemon as the build root).
/// Defaults to the current directory (`.`).
pub fn with_context(
cfg: DockerfileConfig,
context_path: String,
) -> DockerfileConfig {
DockerfileConfig(..cfg, context_path: context_path)
}
/// Add a `--build-arg` flag passed to `docker build`. Use for `ARG`
/// directives inside the Dockerfile.
///
/// Note: build args are visible in the host's process list (`ps aux`)
/// during the build window. Do **not** pass real secrets via build args;
/// use [`with_secret_env`](#with_secret_env) for runtime secrets, or
/// Docker BuildKit secret mounts for build-time secrets.
pub fn with_build_arg(
cfg: DockerfileConfig,
key: String,
value: String,
) -> DockerfileConfig {
let args = list.append(cfg.build_args, [#(key, value)])
DockerfileConfig(..cfg, build_args: args)
}
/// Override the default 10-minute build timeout. Clamped to the inclusive
/// range `[1000, max_timeout_ms]`.
pub fn with_timeout(cfg: DockerfileConfig, ms: Int) -> DockerfileConfig {
let clamped = case ms < 1000, ms > max_timeout_ms {
True, _ -> 1000
False, True -> max_timeout_ms
False, False -> ms
}
DockerfileConfig(..cfg, timeout_ms: clamped)
}
// ---------------------------------------------------------------------------
// Container spec builders (applied AFTER build)
// ---------------------------------------------------------------------------
/// Map a port that should be exposed when the container runs.
pub fn with_expose_port(
cfg: DockerfileConfig,
p: port.Port,
) -> DockerfileConfig {
add_transform(cfg, fn(spec) { container.expose_port(spec, p) })
}
/// Map several exposed ports at once.
pub fn with_expose_ports(
cfg: DockerfileConfig,
ports: List(port.Port),
) -> DockerfileConfig {
add_transform(cfg, fn(spec) { container.expose_ports(spec, ports) })
}
/// Add an environment variable injected at container start. Overrides
/// any `ENV` baked into the image.
pub fn with_env(
cfg: DockerfileConfig,
key: String,
value: String,
) -> DockerfileConfig {
add_transform(cfg, fn(spec) { container.with_env(spec, key, value) })
}
/// Add many env vars at once.
pub fn with_envs(
cfg: DockerfileConfig,
pairs: List(#(String, String)),
) -> DockerfileConfig {
add_transform(cfg, fn(spec) { container.with_envs(spec, pairs) })
}
/// Add an env var whose value is wrapped in `cowl.Secret` and never
/// appears in `string.inspect` output.
pub fn with_secret_env(
cfg: DockerfileConfig,
key: String,
secret: cowl.Secret(String),
) -> DockerfileConfig {
add_transform(cfg, fn(spec) { container.with_secret_env(spec, key, secret) })
}
/// Add a Docker label to the running container.
pub fn with_label(
cfg: DockerfileConfig,
key: String,
value: String,
) -> DockerfileConfig {
add_transform(cfg, fn(spec) { container.with_label(spec, key, value) })
}
/// Set the wait strategy used after the container starts. Replaces any
/// previous wait strategy.
pub fn with_wait(
cfg: DockerfileConfig,
strategy: wait.WaitStrategy,
) -> DockerfileConfig {
add_transform(cfg, fn(spec) { container.wait_for(spec, strategy) })
}
/// Combine an additional wait strategy with whatever is already configured
/// using `wait.all_of`. Useful to layer e.g. `wait.health_check()` on top
/// of a base port wait.
pub fn with_extra_wait(
cfg: DockerfileConfig,
extra: wait.WaitStrategy,
) -> DockerfileConfig {
add_transform(cfg, fn(spec) {
let current = container.wait_strategy(spec)
container.wait_for(spec, wait.all_of([current, extra]))
})
}
/// Override the image's CMD when running the container.
pub fn with_command(
cfg: DockerfileConfig,
cmd: List(String),
) -> DockerfileConfig {
add_transform(cfg, fn(spec) { container.with_command(spec, cmd) })
}
/// Override the image's ENTRYPOINT.
pub fn with_entrypoint(
cfg: DockerfileConfig,
ep: List(String),
) -> DockerfileConfig {
add_transform(cfg, fn(spec) { container.with_entrypoint(spec, ep) })
}
/// Set a fixed container name.
pub fn with_name(cfg: DockerfileConfig, name: String) -> DockerfileConfig {
add_transform(cfg, fn(spec) { container.with_name(spec, name) })
}
/// Attach the container to a Docker network by name.
pub fn with_network(
cfg: DockerfileConfig,
network: String,
) -> DockerfileConfig {
add_transform(cfg, fn(spec) { container.on_network(spec, network) })
}
/// Bind-mount a host path read-write into the container.
pub fn with_bind_mount(
cfg: DockerfileConfig,
host_path: String,
container_path: String,
) -> DockerfileConfig {
add_transform(cfg, fn(spec) {
container.with_bind_mount(spec, host_path, container_path)
})
}
/// Bind-mount a host path read-only into the container.
pub fn with_readonly_bind(
cfg: DockerfileConfig,
host_path: String,
container_path: String,
) -> DockerfileConfig {
add_transform(cfg, fn(spec) {
container.with_readonly_bind(spec, host_path, container_path)
})
}
/// Mount an in-memory tmpfs at the given container path.
pub fn with_tmpfs(
cfg: DockerfileConfig,
container_path: String,
) -> DockerfileConfig {
add_transform(cfg, fn(spec) { container.with_tmpfs(spec, container_path) })
}
/// Run the container as privileged. **Use with extreme caution**: a
/// privileged container has full access to host devices and can escape
/// most isolation. Naming alone (`with_privileged`) is the security
/// signal — there is no runtime warning.
pub fn with_privileged(cfg: DockerfileConfig) -> DockerfileConfig {
add_transform(cfg, container.with_privileged)
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
/// Run `docker build` and return a `Formula` that wraps the resulting
/// image. The build happens eagerly when this function is called.
///
/// On success, the returned `Formula` can be passed straight to
/// `testcontainer.with_formula` (see the README for an end-to-end example).
///
/// Failure modes (`error.Error` variants):
/// - `DockerNotFound` — the `docker` CLI is not on PATH
/// - `DockerfileNotFound(path)` — the configured Dockerfile path is
/// empty, contains invalid characters, points to a directory, or does
/// not exist on disk
/// - `BuildFailed(path, reason)` — `docker build` returned non-zero
/// or invalid build-arg keys were rejected. `reason` carries full
/// stdout+stderr for diagnostics
pub fn formula(
cfg: DockerfileConfig,
) -> Result(formula.Formula(DockerImage), error.Error) {
use trimmed_cfg <- result.try(validate_and_normalize(cfg))
use image_id <- result.try(build_dockerfile(trimmed_cfg))
let spec =
apply_transforms(container.new(image_id), trimmed_cfg.spec_transforms)
Ok(
formula.new(spec, fn(running) {
Ok(DockerImage(container: running, image_id: image_id))
}),
)
}
// ---------------------------------------------------------------------------
// Accessors
// ---------------------------------------------------------------------------
/// Get the Docker image ID (e.g. `sha256:abcd...`) of a built image.
pub fn image_id(img: DockerImage) -> String {
img.image_id
}
/// Get the running `Container` so you can use `container.host_port`,
/// `container.host`, `container.mapped_url`, etc.
pub fn container(img: DockerImage) -> container.Container {
img.container
}
// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------
fn validate_and_normalize(
cfg: DockerfileConfig,
) -> Result(DockerfileConfig, error.Error) {
let trimmed_dockerfile = string.trim(cfg.dockerfile_path)
let trimmed_context = string.trim(cfg.context_path)
use _ <- result.try(validate_path_string(trimmed_dockerfile, "dockerfile"))
use _ <- result.try(validate_path_string(trimmed_context, "context"))
use _ <- result.try(validate_dockerfile_is_file(trimmed_dockerfile))
use _ <- result.try(validate_build_args(cfg.build_args))
Ok(
DockerfileConfig(
..cfg,
dockerfile_path: trimmed_dockerfile,
context_path: trimmed_context,
),
)
}
fn validate_path_string(
path: String,
kind: String,
) -> Result(Nil, error.Error) {
case path {
"" ->
case kind {
"dockerfile" -> Error(error.DockerfileNotFound(path: ""))
_ ->
Error(error.BuildFailed(path: path, reason: "context path is empty"))
}
_ ->
case has_unsafe_char(path) {
True ->
Error(error.BuildFailed(
path: path,
reason: kind <> " path contains NUL or CR/LF characters",
))
False -> Ok(Nil)
}
}
}
fn validate_dockerfile_is_file(path: String) -> Result(Nil, error.Error) {
case fio.is_file(path) {
Ok(True) -> Ok(Nil)
Ok(False) -> Error(error.DockerfileNotFound(path: path))
Error(_) -> Error(error.DockerfileNotFound(path: path))
}
}
fn validate_build_args(
args: List(#(String, String)),
) -> Result(Nil, error.Error) {
case args {
[] -> Ok(Nil)
[#(key, value), ..rest] ->
case is_valid_build_arg_key(key) {
False ->
Error(error.BuildFailed(
path: "",
reason: "invalid build-arg key: "
<> key
<> " (must match [A-Za-z_][A-Za-z0-9_]*)",
))
True ->
case has_unsafe_char(value) {
True ->
Error(error.BuildFailed(
path: "",
reason: "build-arg value for "
<> key
<> " contains NUL or CR/LF characters",
))
False -> validate_build_args(rest)
}
}
}
}
fn has_unsafe_char(s: String) -> Bool {
string.contains(s, "\u{0000}")
|| string.contains(s, "\n")
|| string.contains(s, "\r")
}
fn is_valid_build_arg_key(key: String) -> Bool {
case string.to_graphemes(key) {
[] -> False
[first, ..rest] ->
is_valid_first_char(first) && list.all(rest, is_valid_arg_char)
}
}
fn is_valid_first_char(c: String) -> Bool {
is_alpha(c) || c == "_"
}
fn is_valid_arg_char(c: String) -> Bool {
is_alpha(c) || is_digit(c) || c == "_"
}
fn is_alpha(c: String) -> Bool {
case c {
"a"
| "b"
| "c"
| "d"
| "e"
| "f"
| "g"
| "h"
| "i"
| "j"
| "k"
| "l"
| "m"
| "n"
| "o"
| "p"
| "q"
| "r"
| "s"
| "t"
| "u"
| "v"
| "w"
| "x"
| "y"
| "z" -> True
"A"
| "B"
| "C"
| "D"
| "E"
| "F"
| "G"
| "H"
| "I"
| "J"
| "K"
| "L"
| "M"
| "N"
| "O"
| "P"
| "Q"
| "R"
| "S"
| "T"
| "U"
| "V"
| "W"
| "X"
| "Y"
| "Z" -> True
_ -> False
}
}
fn is_digit(c: String) -> Bool {
case c {
"0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" -> True
_ -> False
}
}
// ---------------------------------------------------------------------------
// Internals
// ---------------------------------------------------------------------------
fn add_transform(
cfg: DockerfileConfig,
t: fn(container.ContainerSpec) -> container.ContainerSpec,
) -> DockerfileConfig {
DockerfileConfig(
..cfg,
spec_transforms: list.append(cfg.spec_transforms, [t]),
)
}
fn apply_transforms(
spec: container.ContainerSpec,
transforms: List(fn(container.ContainerSpec) -> container.ContainerSpec),
) -> container.ContainerSpec {
list.fold(transforms, spec, fn(s, t) { t(s) })
}
@external(erlang, "testcontainer_dockerfile_ffi", "run_docker_build")
fn ffi_build(
dockerfile: String,
context: String,
args: List(#(String, String)),
timeout_ms: Int,
) -> Result(String, String)
fn build_dockerfile(cfg: DockerfileConfig) -> Result(String, error.Error) {
case
ffi_build(
cfg.dockerfile_path,
cfg.context_path,
cfg.build_args,
cfg.timeout_ms,
)
{
Ok(image_id) -> Ok(image_id)
Error("DOCKER_NOT_FOUND") -> Error(error.DockerNotFound)
Error(reason) ->
Error(error.BuildFailed(path: cfg.dockerfile_path, reason: reason))
}
}