Init tasks

Init tasks are shell scripts that run inside the playground machines during startup. They are the primary way to turn a generic base image into your environment: install packages, clone repositories, start services, generate data. The playground shows a loading screen until all init tasks complete, so the user always lands in a fully provisioned environment (unless they close the loading modal).

initTasks is a map of named tasks:

manifest.yaml
kind: playground
name: web-dev-lab
title: Web Dev Lab
playground:
  machines:
    - name: dev-01
      users:
        - name: laborant
          default: true
      drives:
        - source: docker
          mount: /
      network:
        interfaces:
          - network: local
  initTasks:
    init_fetch_app:
      init: true
      machine: dev-01
      user: laborant
      timeout_seconds: 120
      run: |
        git clone https://github.com/example/app.git ~/app

    init_start_services:
      init: true
      machine: dev-01
      needs:
        - init_fetch_app
      timeout_seconds: 180
      run: |
        cd /home/laborant/app && docker compose up -d
  accessControl:
    canList:
      - owner
    canRead:
      - owner
    canStart:
      - owner

Field by field:

  • init: true marks the task as an init task (executed once, at startup).
  • machine - which VM the task runs on.
  • user - the user to run the script as; defaults to root.
  • run - the script itself (executed with bash).
  • needs - names of tasks that must complete first.
  • timeout_seconds - defaults to 60 seconds; set it generously for anything that touches the network or a package manager.

Execution order

Tasks with no needs start concurrently as soon as their machine boots; needs chains them into a dependency graph. Since every task names its target machine, the graph can span the whole playground - a task on one machine can wait for provisioning on another, which is how you express "start the app server only after the database VM is seeded".

Two practical gotchas:

  • Tasks run as root unless user says otherwise. If a task prepares files for the interactive user (cloning a repo into /home/laborant, for example), set user: laborant - or you'll leave root-owned files in their home directory.
  • Init tasks execute once per playground instance during its initialization; they are not re-run after in-session machine reboots.

Debugging init tasks

A failing or timed-out init task keeps the playground stuck on the loading screen, so test your scripts by starting the playground and watching the task progress. labctl playground tasks <play-id> shows the status of each task from the command line:

NAME                 MACHINE  STATUS     INIT  HELPER
init_fetch_app       dev-01   completed  true  false
init_start_services  dev-01   running    true  false

While the tasks are still running, you can already SSH into the machines (labctl ssh <play-id> -m <machine>) and inspect the environment - handy for figuring out why a script misbehaves.

For a closer look - each task's exit code and captured stdout/stderr, the machines' boot logs, and a live stream of a machine's systemd journal - use the Play Debug Console or the labctl playground machine commands described in Debugging Playgrounds.

Parameterized playgrounds

Init tasks can be made conditional on init conditions - user-supplied parameters requested at start time:

  initConditions:
    values:
      - key: k8s_flavor
        default: k3s
        options:
          - k3s
          - kubeadm
  initTasks:
    init_install_kubeadm:
      init: true
      machine: dev-01
      conditions:
        - key: k8s_flavor
          value: kubeadm
      run: |
        ...

When starting such a playground, the UI prompts for the values, and with labctl they are passed explicitly:

labctl playground start web-dev-lab-<suffix> -i k8s_flavor=kubeadm

Tasks whose conditions don't match the chosen values are simply skipped (they won't even appear in the task list). Combined with options, default, and free-form values (validated by an optional validationRegex), init conditions let one playground serve several scenarios - different Kubernetes flavors, tool versions, or difficulty levels.

A good real-world example is the Kubernetes Cluster playground (k8s-omni) - a multi-node kubeadm cluster where both the container runtime and the networking plugin are chosen at start time:

  initConditions:
    values:
      - key: Container runtime
        default: containerd
        options:
          - containerd
          - cri-o
      - key: Networking plugin
        default: flannel
        nullable: true  # can be left unset - the cluster starts with no CNI plugin installed
        options:
          - calico
          - cilium
          - flannel
          - static

Every runtime- and plugin-specific provisioning step (installing containerd or CRI-O, applying the Cilium or Flannel manifests, configuring static routes, and so on) is a separate init task guarded by the matching condition:

    init_cplane_10_cri_o_install:
      init: true
      machine: cplane-01
      conditions:
        - key: Container runtime
          value: cri-o
      run: |
        apt-get install -y cri-o podman
        systemctl enable --now crio

This is the real power of parameterized playgrounds: instead of maintaining a dozen near-identical copies, a single manifest covers the whole matrix of container runtimes × networking plugins - including a cluster with no CNI at all, for practicing networking setup from scratch (a classic CKA exercise).

The k8s-omni manifest is public, so you can study the complete technique with:

labctl playground manifest k8s-omni

Startup files and welcome messages

For small tweaks - shell profiles, config files, seed data, helper scripts - a full init task is overkill. playground.startupFiles is a list of files baked into the playground's machines before they boot:

playground:
  startupFiles:
    - path: /home/laborant/.bashrc
      append: true
      content: |
        export PATH=$PATH:/usr/local/go/bin:$HOME/go/bin
        export GOPATH=$HOME/go/

    - path: /etc/app/config.yaml
      owner: laborant
      mode: "600"
      content: |
        environment: playground
      machines: [dev-01]        # optional; omitted = every machine of the play
  • path must be absolute; missing parent directories are created.
  • append: true adds to an existing file instead of replacing it - the go-to for .bashrc and similar modifications.
  • Every entry needs append: true or both owner and mode set. owner (user, user:group, or numeric IDs) and mode (octal, without the leading zero - e.g. "600") default to root-owned "644" when the file is created, and are kept unchanged when appending.
  • machines restricts an entry to the named machines; omit it to apply the entry to every machine in the play.

At most 20 entries per manifest.

Important

Startup files is the only reliable way to customize login shell behavior (e.g., by placing something in the ~/.bashrc file). Init tasks run already after the machine is booted, and technically, the user may acquire a shell before all init tasks complete.

Rule of thumb: use startup files for content you already know at authoring time (configs, aliases, seed data, helper scripts), and init tasks for anything that must be executed (installing packages, starting services).

Fetching files instead of inlining them

Instead of content, a source fetches the file rather than inlining its bytes - or, with extract: true, unpacks a tar archive into a destination directory:

playground:
  startupFiles:
    # A helper script from this content's own __static__ folder.
    - path: /usr/local/bin/setup.sh
      source: __stаtic__/setup.sh
      owner: root
      mode: "755"

    # A tar archive extracted into a destination directory, created if missing.
    - path: /opt/app            # destination directory
      source: __stаtic__/app.tar.gz
      extract: true
      owner: laborant

    # Any https URL - GitHub raw content is the typical case.
    - path: /usr/local/bin/cdebug
      source: https://raw.githubusercontent.com/iximiuz/cdebug/main/hack/install.sh
      owner: root
      mode: "755"
  • content and source are mutually exclusive - exactly one of them per entry.
  • Files are fetched and cached by the platform, so a startup file always reflects the current source without any cache-busting on your part.
  • extract: true unpacks a source archive - .tar, or gzip-compressed .tar.gz/.tgz (no other compression is supported) - into path before the machine boots, running in an isolated sandbox; owner sets who owns the extracted files (default root), and mode, append, and content can't be combined with extract.
  • labctl content push builds the archive for you when a startup file points at __static__/<folder>.tar.gz (or .tgz/.tar) and a <folder>/ directory exists next to the declaring index.md (or manifest.yaml): the folder's files are tarred on every push, and on every change in --watch mode, so the folder is the source of truth and the archive needs no build step. Keep the folder itself out of the push with .labctlignore - labctl watches it regardless.
  • Limits: at most 1 GiB per fetched file.
Note

machines[].startupFiles (declared inside a machine, rather than at the top of playground:) still works - it's the original, "legacy" form. It can be combined with the playground-level form: existing manifests using it keep working unchanged and are never reformatted, but the playground-level list shown above is recommended for new manifests. When both are present for a machine, the playground-level entries are applied first, then that machine's own startupFiles.

Welcome messages

The first thing a user sees in a terminal is the machine's welcome message. It's configured per user and is well worth the effort - a good welcome message explains what the machine is, what's installed, and where to start:

      users:
        - name: laborant
          default: true
          welcome: |
            This is a development machine with Go, Docker, and kubectl preinstalled.
            The demo app lives in ~/app - run `make help` to see what it can do.

Set welcome: '-' to suppress the message entirely (useful for secondary machines).

Next
UI Tabs