20  Platform Image

To begin, we must first set the foundations of a software platform, i.e., what kind of computer to isolate. This notebook defines the platform image in Singularity. It should only answer:

What minimal computer does this project run on?

The platform image must not yet know about /work/.spack-env/view, R_HOME, rv libraries, Quarto project extensions, or project-specific activation.

We import the Installable class, which allows us to define additional platform-level tools, like code, rv, uv, and quarto. These tools are installed in the platform image, and are available to all projects that use this platform image.

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Optional

@dataclass
class Installable:
    """A tool installed into the platform image."""

    name: str
    kind: str
    commands: list[str]
    path: Optional[str] = None
    test: list[str] = field(default_factory=list)

    def install_lines(self) -> list[str]:
        return self.commands

    def test_lines(self) -> list[str]:
        return self.test
from rse_workbench.platform_image import Installable

And we also import the compose_platform_recipe and recipe_to_definition functions, which allow us to compose a Singularity definition file from our platform and basic installables. This is a structured way to define the platform image, and is wrapped in a Python function for our convenience.

from datetime import datetime
from spython.main.parse.recipe import Recipe
from spython.main.parse.writers import SingularityWriter

def compose_platform_recipe(
    base_image: str,
    installables: list[Installable] | None = None,
    env_vars: dict[str, str] | None = None,
    dnf_update: list[str] = [
        "dnf install -y ca-certificates tar gzip which findutils file shadow-utils",
        "dnf install -y epel-release",
        "mkdir -p /work /tmp/spack-user-config /tmp/spack-user-cache /tmp/spack-misc-cache",
    ],
) -> tuple[Recipe, str]:
    """Build a Singularity definition for the reusable platform image."""

    recipe = Recipe()
    recipe.fromHeader = base_image.replace("docker://", "")
    recipe.test = ["command -v spack"]

    recipe.comments = [
        "This Singularity definition is generated by the RSE Workbench.",
        "It defines the base platform image for reproducible research projects.",
        "Definition created on: " + datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
    ]
    
    recipe.install = dnf_update

    for item in installables:
        recipe.install.extend(item.install_lines())
        recipe.test.extend(item.test_lines())

    recipe.environ = [
        "PATH=/usr/local/bin:/opt/views/view/bin:/root/.cargo/bin:/usr/cargo/bin:$PATH",
        "SPACK_DISABLE_LOCAL_CONFIG=true",
        "SPACK_USER_CONFIG_PATH=/tmp/spack-user-config",
        "SPACK_USER_CACHE_PATH=/tmp/spack-user-cache",
        "SPACK_MISC_CACHE_PATH=/tmp/spack-misc-cache",
    ]

    for key, value in (env_vars or {}).items():
        recipe.environ.append(f"{key}={value}")
    
    recipe.workdir = "/work"

    return recipe

def recipe_to_definition(recipe: Recipe) -> str:
    """Convert a Singularity Recipe to a definition string."""
    writer = SingularityWriter({"spython-base": recipe})
    return writer.convert()
from rse_workbench.platform_image import compose_platform_recipe, recipe_to_definition

20.1 Platform tools

Here, we’re attaching VSCode to the container. VSCode is the decided -upon IDE for this project, but it could be whatever you want! By using the Installable class, we can define any additional platform-level tools, like rv, uv, and quarto. These tools are installed in the platform image, and are available to all projects that use this platform image.

code = Installable(
    name="code",
    kind="binary",
    path="/usr/local/bin/code",
    commands=[
        r'''
tmpdir="$(mktemp -d)"
cd "$tmpdir"
curl -L --fail --show-error --output vscode_cli.tar.gz "https://code.visualstudio.com/sha/download?build=stable&os=cli-alpine-x64"
tar -xzf vscode_cli.tar.gz
CODE_BIN="$(find "$tmpdir" -type f -name code | head -n 1)"
test -n "$CODE_BIN"
test -x "$CODE_BIN" || chmod +x "$CODE_BIN"
install -m 0755 "$CODE_BIN" /usr/local/bin/code
/usr/local/bin/code --version
'''
    ],
    test=["command -v code", "code --version"],
)

To manage our R and Python environments, we use rv and uv, respectively. These are installed in the platform image, and are available as binaries:

rv = Installable(
    name="rv",
    kind="binary",
    path="/usr/local/bin/rv",
    commands=[
        r'''
curl -sSL https://raw.githubusercontent.com/A2-ai/rv/refs/heads/main/scripts/install.sh | bash
RV_BIN="$(find /root/.local/bin /home -path '*/.local/bin/rv' -type f 2>/dev/null | head -n 1)"
if [ -z "$RV_BIN" ]; then
  RV_BIN="$(find / -path '*/.local/bin/rv' -type f 2>/dev/null | head -n 1)"
fi
test -n "$RV_BIN"
install -m 0755 "$RV_BIN" /usr/local/bin/rv
'''
    ],
    test=["command -v rv", "rv --version"],
)

uv = Installable(
    name="uv",
    kind="binary",
    path="/usr/local/bin/uv",
    commands=[
        r'''
curl -LsSf https://astral.sh/uv/install.sh | sh
UV_BIN="$(find /root/.local/bin /home -path '*/.local/bin/uv' -type f 2>/dev/null | head -n 1)"
if [ -z "$UV_BIN" ]; then
  UV_BIN="$(find / -path '*/.local/bin/uv' -type f 2>/dev/null | head -n 1)"
fi
test -n "$UV_BIN"
install -m 0755 "$UV_BIN" /usr/local/bin/uv
'''
    ],
    test=["command -v uv", "uv --version"],
)

We will also install Quarto, which is a publishing system for data science. Quarto is installed in the platform image, and is available as a binary:

quarto = Installable(
    name="quarto",
    kind="binary",
    path="/usr/local/bin/quarto",
    commands=[
        r'''
QUARTO_VERSION="1.9.38"
if [ "$(uname -m)" = "aarch64" ]; then
  QUARTO_ARCH="arm64"
else
  QUARTO_ARCH="amd64"
fi

mkdir -p "/opt/quarto/${QUARTO_VERSION}"
curl -o quarto.tar.gz -L \
  "https://github.com/quarto-dev/quarto-cli/releases/download/v${QUARTO_VERSION}/quarto-${QUARTO_VERSION}-linux-${QUARTO_ARCH}.tar.gz"
tar -zxvf quarto.tar.gz -C "/opt/quarto/${QUARTO_VERSION}" --strip-components=1
rm quarto.tar.gz
test -x "/opt/quarto/${QUARTO_VERSION}/bin/quarto"
ln -sf "/opt/quarto/${QUARTO_VERSION}/bin/quarto" /usr/local/bin/quarto
'''
    ],
    test=["command -v quarto", "quarto --version"],
)

arf is useful but not a required part of the platform image. Keep it optional until embedded R is stable.

arf = Installable(
    name="arf",
    kind="cargo",
    path="/usr/local/bin/arf",
    commands=[
        r'''
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/eitsupi/arf/releases/latest/download/arf-console-installer.sh | sh
ARF_BIN="$(find /root/.cargo/bin /home -path '*/.cargo/bin/arf' -type f 2>/dev/null | head -n 1)"
if [ -z "$ARF_BIN" ]; then
  ARF_BIN="$(find / -path '*/.cargo/bin/arf' -type f 2>/dev/null | head -n 1)"
fi
test -n "$ARF_BIN"
install -m 0755 "$ARF_BIN" /usr/local/bin/arf
'''
    ],
    test=["command -v arf", "arf --version"],
)

20.2 Compose definition

We use the spython library to compose a Singularity definition file from our platform and basic installables. This is a structured way to define the platform image, and is wrapped in a Python function for your convenience:

recipe = compose_platform_recipe(
    base_image=BASE_IMAGE,
    installables=[code, rv, uv, quarto],
    dnf_update=[
        "dnf install -y ca-certificates tar gzip which findutils file shadow-utils lsof",
        "dnf install -y epel-release",
        "dnf install -y python3 python3-pip",
        "mkdir -p /work /tmp/spack-user-config /tmp/spack-user-cache /tmp/spack-misc-cache",
    ],
)

When working with Spack, you’ll want to set the following environment variables in the platform image, so that we can be sure that it always knows how to reference Spack’s configuration and cache directories:

recipe.environ.extend(
    [
        "PATH=/usr/local/bin:/opt/spack/bin:/usr/bin:/usr/sbin:/sbin:/bin",
        "SPACK_DISABLE_LOCAL_CONFIG=true",
        "SPACK_USER_CONFIG_PATH=/tmp/spack-user-config",
        "SPACK_USER_CACHE_PATH=/tmp/spack-user-cache",
        "SPACK_MISC_CACHE_PATH=/tmp/spack-misc-cache",
    ]
)

Finally, we write the Singularity definition to file:

definition = recipe_to_definition(recipe)
project_contract.singularity_def.write_text(definition)
print(definition)

To learn more about Singularity, see the Singularity documentation.

The next notebook builds and tests this basic image.