Skip to content

Execution environments ​

Docker. #local, a tag-pinned image, a digest-pinned one, resource limits and other container options, an image built from the local Dockerfile, environments held in a Map<Environment> and chosen at run time, and with_container, which runs several commands in one container.

bash
lask envs list               # every image, and whether each is on the container engine
lask sync
lask eval tool-versions
lask eval uname-in --env built
lask eval kept-in-session

The thing to take away: an environment is a value. A command that names none is a static error, never a quiet fall back to the host.

This topic has 2 files, so clone the repository to try it:

bash
git clone https://github.com/lask-task-runner/lask.git
cd lask/example/02-language/04-environments

Source ​

These are example/02-language/04-environments in the lask repository.

lask
// Execution environments
//
// An environment is a value: it is declared once, passed around, stored
// in a map and selected at runtime like anything else.
//
//   lask envs list             # every image this module can reach for,
//                              # and whether each is on the container engine
//   lask sync                  # materialize them (run/eval never pull)
//   lask eval alpine_version
//   lask eval tool_versions
//   lask eval uname_in --env built
//   lask eval kept_in_session

// The host itself. There is no default environment, so `#local` is
// something you write — a command that names no environment at all is
// a static error, never a quiet fall back to the host.
host = #local

// An image is named by the head of the expression, and only there: no
// string or variable can name one, so reading the source is enough to
// know every image it uses. A tag is optional — a bare
// `#alpine` means `latest` — because `lask sync` pins the digest a
// reference resolves to in lask.lock.json, and every run uses that.
alpine = #alpine:3.22.2

// A digest pins the exact bytes, which no tag can promise.
pinned = #alpine@sha256:4b7ce07002c69e8f3d704a9c5d6fd3053be500b7f1c69fc0d80990c2ad8dd412

// Run options go in braces after the head: resource limits, for one.
// The braces make a Runnable — the environment, and how to run it. They
// are sugar for runnable(#alpine:3.22.2, memory = "256m", cpus = 1).
small = #alpine:3.22.2{memory: "256m", cpus: 1}

// It configures the rest of the container the same way: variables, the
// network, and what the container may write to. These are ordinary
// arguments — literals here, but they may be computed like any other.
// Variable names are not `lower_id`, so they are written as strings.
sealed = #alpine:3.22.2{
  env: { "LANG": "C.UTF-8" },
  network: "none",
  read_only: true,
  tmpfs: ["/tmp"],
  cap_drop: ["ALL"]
}

// The other form of an image: a recipe, named by its Dockerfile's path.
// What decides the image goes in parentheses: the path, `context` and
// `build_args`, literals inside this module's own tree, which is what
// keeps the set of images a module uses enumerable and pinnable.
built = #./Dockerfile(context = ".")

// A function can let its caller choose the image, and say how to run
// whichever it gets. The default image is written here, and a caller's
// is written where it calls — `alpine_shell(image = #alpine:3.21)` — so
// either way it is a head someone wrote. runnable() takes an
// Environment, so the options are given once, here.
alpine_shell(--image: Environment = #alpine:3.22.2): Runnable =
  runnable(image, env = { "PS1": "lask$ " }, read_only = true)

command { "uname" } on host
command { "jq" } on built
command { "id" } on sealed

// The kernel Lask itself is running on.
//
// @return The kernel of the machine Lask itself is running on.
kernel(): String = $ uname -s
  |> trim

// The release of the tag-pinned image.
//
// @return The version line of the pinned image.
alpine_version(): String = $[alpine] cat /etc/alpine-release
  |> trim

// A container that reaches no network and writes nowhere but /tmp.
//
// @return The user the sealed container runs as.
sealed_user(): String = $ id -un
  |> trim

// The release of the digest-pinned image.
//
// The digest-pinned environment is the same image, named by content.
pinned_version(): String = $[pinned] cat /etc/alpine-release
  |> trim

// The memory limit of a constrained container.
//
// Limits apply to the container this command runs in.
memory_limit(): String = $[small] sh -c 'cat /sys/fs/cgroup/memory.max'
  |> trim

// The jq version of the image built from Dockerfile.
//
// jq is installed in no environment but the one built from Dockerfile,
// and dispatch is what sends the command there.
jq_version(): String = $ jq --version
  |> trim

// Environments in a map are chosen by name at runtime. `@complete`
// then offers those names in the shell.
envs: Map<Environment> = {
  "alpine": alpine,
  "built": built,
  "local": host
}

// Run `uname` in whichever environment is named.
//
// @param env  Which environment to report on.
// @complete env @keys envs
// @example lask eval uname-in --env built
uname_in(--env: String = "alpine"): String = $[envs[env]] uname -s
  |> trim

// The alpine release of any environment given.
//
// A task can also take an environment directly, since it is a value
// with a type like any other.
release_of(e: Environment): String = $[e] sh -c 'cat /etc/alpine-release 2>/dev/null || echo n/a'
  |> trim

// Every `$[alpine]` above starts a container of its own, so nothing a
// command leaves outside /work is there for the next one, and commands
// that need each other end up joined with && on one line.
// with_container keeps one container for the length of its body, and
// every command run with `box` runs in it: a file written by one line
// is there for the next, and so is a process left in the background.
// The container is removed when the body is done, however it ends.
// Each command is still a shell of its own, so a `cd` or an `export`
// ends with it; the working directory and variables go in the run
// options, given once where the container is opened.
//
// @return What the second and third commands found the first left.
kept_in_session(): String = with_container(#alpine:3.22.2{workdir: "/tmp", env: { "STEP": "3" }}, \(box) -> do {
  $[box] echo written-by-step-1 > note
  $[box] sleep 60 > /dev/null 2>&1 &
  found = $[box] echo "$(cat note), $(pgrep sleep | wc -l) background process, step $STEP"
  trim(found)
})

// What every environment here reports about itself.
//
// @return What each environment reports about itself.
tool_versions(): Record<host: String, alpine: String, built: String> = {
  host: kernel(),
  alpine: release_of(alpine),
  built: jq_version()
}
dockerfile
# The recipe form of an environment: an image built from this file
# rather than pulled by name. `lask sync` is what materializes it.
FROM alpine:3.22.2

RUN apk add --no-cache jq=1.8.2-r0

Released under the MIT License.