Skip to main content

Filesystems: #FS

Along with container images, filesystems are one of the building blocks of the Dagger CUE SDK. They are represented by the dagger.#FS type. An #FS is a reference to a filesystem tree: a directory storing files in a hierarchical/tree structure.

Filesystems are everywhere

Filesystems are key to any CI pipeline. Pipeline are essentially a series of transformations applied on filesystems until deployment. You may, for example:

  • load code
  • compile binaries
  • run unit/integration tests
  • deploy code/artifacts
  • do anything that can possibly be done with a container

Each of these use cases requires a change or transfer of data, in the form of files in directories, from one action/step to another. The dagger.#FS makes that transfer possible.

docker.#Image vs dagger.#FS

You need an understanding of how filesystems relate to core actions and container images to fully benefit from the power of the CUE SDK.

The core API

The Dagger CUE SDK leverages, at its core, a low level API to interact with filesystem trees (see reference). Every other Universe package is just an abstraction on top of these low-level core primitives.

Let's dissect one:

// Create one or multiple directory in a container
#Mkdir: {
$dagger: task: _name: "Mkdir"

// Container filesystem
input: dagger.#FS

// Path of the directory to create
// It can be nested (e.g : "/foo" or "/foo/bar")
path: string

// Permissions of the directory
permissions: *0o755 | int

// If set, it creates parents' directory if they do not exist
parents: *true | false

// Modified filesystem
output: dagger.#FS @dagger(generated)
}

core.#Mkdir is the dagger equivalent of the mkdir command: it takes as input a dagger.#FS and retrieves a dagger.#FS containing the newly created folders.

As Dagger CUE is statically typed, you can look at an action definition to see the types that an action requires or outputs. In most cases, an action will either take as input a dagger.#FS or a docker.#Image. Let's look inside a docker.#Image to see the #FS inside.

Dissecting docker.#Image

Inspecting the docker.#Image package is a good way to grasp the relation between filesystems and images:

// A container image
#Image: {
// Root filesystem of the image.
rootfs: dagger.#FS

// Image config
config: core.#ImageConfig
}

Many Universe packages don't accept filesystems (dagger.#FS) directly as input, but rely on images (docker.#Image) instead, to let users specify the environment in which an action's logic will be executed.

As you can see, docker.#Image and dagger.#FS are closely tied, since an image is just a filesystem (rootfs) together with some configuration (config).

Since every docker.#Image contains a rootfs field, it is possible to access the dagger.#FS of any docker.#Image. This is very useful when copying filesystems between container images, passing an #FS to an action that requires one, or exporting the final filesystem/files/artifacts after a build to use with other actions (or to save on the client filesystem).

The corollary is also true: from a given filesystem, building up a docker.#Image is possible (with the help of the dagger.#Scratch type and some core actions).

note

Sometimes you need an empty filesystem. For example, to start a minimal image build. The docker package implements a docker.#Scratch image by relying on the dagger.#Scratch type. dagger.#Scratch is a core type representing a minimal rootfs.

#Scratch: #Image & {
rootfs: dagger.#Scratch
config: {}
}

Learn more about the docker package

Moving filesystems between actions

Let's explore, in-depth, how to transfer filesystems between actions.

Topology of a Universe action (from an #FS perspective)

As stated above, most of actions require a docker.#Image as input. As an image contains a rootfs, the first #FS of an action is its rootfs. As some actions create artifacts, these computed outputs are a second type of filesystem living inside the rootfs.

Universe action topology

Lastly, some filesystems need to be shared between actions, and only need to live during the lifetime of its execution logic. This is the last type of filesystem that you might encounter: for the sake of the guide, let's call them additional filesystems.

Transfer of filesystems via docker.#Image

As most of actions run within a container image, an easy way to transfer an fs is to make the output image of an action the input of the next one. In other words, to make the state of the rootfs after execution, the initial rootfs of my second action.

Universe action topology

A common use-case is to create an action whose sole purpose is to install all the required packages/dependencies, and let the next action execute the core logic:

dagger-cue do test --log-format plain
package main

import (
"dagger.io/dagger"

"universe.dagger.io/bash"
)

dagger.#Plan & {
actions: {
// Create hidden action (not present in `dagger do`)
// This action runs a bash command on a container image
// The bash command creates a file
_first: bash.#RunSimple & {
// Run bash command to create a file
script: contents: """
echo example > /tmp/test
"""
}

// Use as an input image the output the the `_first` action
test: bash.#Run & {
// Use as image the state of the `_first` image, after execution
input: _first.output

// Context: Make sure action always gets executed (idempotence)
always: true

// Show content of file, to see if `#FS` has indeed been shared between the two action
script: contents: "cat /tmp/test"
}
}
}

A simplified visual representation of above plan would be:

diagram-representing-above-bash-example

Transfer of #FS via mounts

A mount is way to add new filesystem layers to a container image. With mounts, an image will not only have a rootfs, but also as many filesystem layers as the amount of fs mounted.

diagram-representing-action-with-mounts

Difference between a Docker bind mount and Dagger CUE SDK mounts

Dagger CUE SDK mounts are very similar to the docker ones you may be familiar with. You can mount filesytems from your underlying dev/CI machine (host) or from another action. The main difference is that Dagger CUE SDK mounts are transient (more on that below) and not bi-directional like "bind" mounts. So, even if you're mounting a #FS that is read from the host system (client API), any script interacting with the mounted folder (inside the container) writes to the container's filesystem layer only, and the writes do not impact the underlying client system at all.

diagram-representing-dagger-mount

Whether you mount a directory from your dev/CI host (using the client API), or between actions, the mount will only be modified in the context of an action execution.

However, if your script creates artifacts outside of the mounted filesystem, then it will be created inside the rootfs layer. That's a great way to make generated artifacts, files, directories sharable between actions.

diagram-representing-dagger-mount-script-not-interacting-with-mounted-fs

Mounts are not shared between actions (transient)

In the Dagger CUE SDK, as an image is only composed of a rootfs + a config, when passing the image to the next action, it loses all the mounted filesystems:

diagram-representing-mount-loss

Mounted FS cannot be exported (transient)

Exports only export from a rootfs. As mounts do not reside inside the rootfs layer, but on a layer above, the information residing inside a mounted filesystem gets lost, unless you mount it again inside the next action.

Mounts can overshadow filesystems

When mounting filesystems on top of a preexisting directory, they are temporarily overwritten/shadowed.

diagram-representing-mount-loss

In above example, the script only has access to the /foo and /bar directories that were mounted, as the artifacts present in the rootfs layer have been overshadowed in the superposition of all layers.

Example

Below is a plan showing how to mount an FS prior executing a command on top of it:

dagger-cue do verify --log-format plain
package main

import (
"dagger.io/dagger"

"universe.dagger.io/alpine"
"universe.dagger.io/docker"
)

dagger.#Plan & {
// client: filesystem:
actions: {
// Create base image for the two actions relying on
// docker.#Run universe package (exec and verify)
_img: alpine.#Build

// Create a file inside alpine's image
exec: docker.#Run & {
// Retrieve the image from the `image` (alpine.#Image) action
// Make it the base image for this action
input: _img.output
// execute a command in this container
command: {
name: "sh"
// create an output.txt file
flags: "-c": #"""
echo -n hello world >> /output.txt
"""#
}
}

// Verify the content of an action
verify: docker.#Run & {
// Retrieve the image from the `image` (alpine.#Image) action
// Make it the base image for this action
input: _img.output
// mount the rootfs of the `exec` action after execution
// (after the /output.txt file has been created)
// Mount it at /target, in container
mounts: "example target": {
dest: "/target"
contents: exec.output.rootfs
}
command: {
name: "sh"
// verify that the /target/output.txt file exists
flags: "-c": """
test "$(cat /target/output.txt)" = "hello world"
"""
}
}
}
}

Visually, these are the underlying steps of above plan:

diagram-representing-mount

As mounts only live during the execution of the verify action, chaining outputs will not work.

note

Mounts are very useful to retrieve filesystems from several previous actions and use them together (to execute or produce something), as you can mount as many filesystems as you need.

Transfer of #FS via docker.#Copy

The aim of this action is to copy the content of an #FS to a rootfs. It relies on the core.#Copy action to perform this operation:

// Copy files from one FS tree to another
#Copy: {
$dagger: task: _name: "Copy"
// Input of the operation
input: dagger.#FS
// Contents to copy
contents: dagger.#FS
// Source path (optional)
source: string | *"/"
// Destination path (optional)
dest: string | *"/"
// Optionally include certain files
include: [...string]
// Optionally exclude certain files
exclude: [...string]
// Output of the operation
output: dagger.#FS @dagger(generated)
}

Let's take the same plan as the one used to previously show how to mount a dagger.#FS. In this example, we will rely on the docker.#Copy instead of the mount, so the #FS is made part of the rootfs and can be shared with other actions:

dagger-cue do verify --log-format plain
package main

import (
"dagger.io/dagger"

"universe.dagger.io/alpine"
"universe.dagger.io/docker"
)

dagger.#Plan & {
// client: filesystem:
actions: {
// Create base image for the two actions relying on
// docker.#Run universe package (exec and verify)
_img: alpine.#Build

// Create a file inside alpine's image
exec: docker.#Run & {
// Retrieve the image from the `image` (alpine.#Image) action
// Make it the base image for this action
input: _img.output
// execute a command in this container
command: {
name: "sh"
// create an output.txt file
flags: "-c": #"""
echo -n hello world >> /output.txt
"""#
}
}

// copy the rootfs of the `exec` action after execution
// (after the /output.txt file has been created)
// Copy it at /target, in container's rootfs
_copy: docker.#Copy & {
input: _img.output
contents: exec.output.rootfs
dest: "/target"
}

// Verify the content of an action
verify: docker.#Run & {
// Retrieve the image from the `_copy` action
// Make it the base image for this action
input: _copy.output
command: {
name: "sh"
// verify that the /target/output.txt file exists
flags: "-c": """
test "$(cat /target/output.txt)" = "hello world"
"""
}
}
}
}

Visually, these are the underlying steps of above plan:

diagram-representing-copy

The verify action does not have any mount, and instead has access to the /target artifact from the exec action due to the docker.#Copy (_copy action).

Mounting host #FS to container (#FS perspective)

Filesystems are not just shared between actions, they can also be shared between the host (e.g. dev/CI machine) and the Dagger Engine:

dagger client api

Example plan to read an #FS from the host

Below is a plan showing how to list the content of the current directory from which the dagger plan is being run (relative to the dagger CLI).

note

This example uses the client API, but if you only need access to files within your project, core.#Source may be a better choice.

dagger-cue do list --log-format plain
package main

import (
"dagger.io/dagger"

"universe.dagger.io/bash"
)

dagger.#Plan & {
// Path may be absolute, or relative to current working directory
// Relative path is relative to the dagger CLI position
client: filesystem: ".": read: {
// Load the '.' directory (host filesystem) into dagger's runtime
// Specify to Dagger runtime that it is a `dagger.#FS`
contents: dagger.#FS
}

actions: {
// Use the bash package to list the content of the local filesystem
list: bash.#RunSimple & {
// Script to execute
script: contents: """
ls -l /tmp/example
"""

// Context: Make sure action always gets executed (idempotence)
always: true

// Mount the client FS into the container used by the `bash` package
mounts: "Local FS": {
// Path key has to reference the client filesystem you read '.' above
contents: client.filesystem.".".read.contents
// Where to mount the FS, in your container image
dest: "/tmp/example"
}
}
}
}

A simplified visual representation of above plan would be:

dagger action

Example plan to write an #FS to the host

Let's now write to the host filesystem:

dagger-cue do create --log-format plain
package main

import (
"dagger.io/dagger"

"universe.dagger.io/bash"
)

dagger.#Plan & {
// Context: Client API
client: {
// Context: Interact with filesystem on host
filesystem: {
// Context: key here could be anything. Useful to track step in logs
"./tmp-example": {
// 3. write to host filesystem with the content of `export: directories: /tmp` key
// We access the `/tmp` fs by referencing its key
write: contents: actions.create.export.directories."/tmp"
}
}
}

actions: {
// Context: Action named `create` that creates a file inside a container and exports the `/tmp` dir
create: bash.#RunSimple & {
// 1. Create a file in /tmp directory
script: contents: """
echo test > /tmp/example
"""

// 2. Export `/tmp` dir in container and make it accessible to any other action as a `dagger.#FS`
export: directories: "/tmp": dagger.#FS

// Context: Make sure action always gets executed (idempotence)
always: true
}
}
}

A simplified visual representation of above plan would be:

dagger action

More information regarding the client API