Init Tasks
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:
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: truemarks 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 toroot.run- the script itself (executed withbash).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
rootunlessusersays otherwise. If a task prepares files for the interactive user (cloning a repo into/home/laborant, for example), setuser: 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
pathmust be absolute; missing parent directories are created.append: trueadds to an existing file instead of replacing it - the go-to for.bashrcand similar modifications.- Every entry needs
append: trueor bothownerandmodeset.owner(user,user:group, or numeric IDs) andmode(octal, without the leading zero - e.g."600") default toroot-owned"644"when the file is created, and are kept unchanged when appending. machinesrestricts an entry to the named machines; omit it to apply the entry to every machine in the play.
At most 20 entries per manifest.
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"
contentandsourceare 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: trueunpacks asourcearchive -.tar, or gzip-compressed.tar.gz/.tgz(no other compression is supported) - intopathbefore the machine boots, running in an isolated sandbox;ownersets who owns the extracted files (defaultroot), andmode,append, andcontentcan't be combined withextract.labctl content pushbuilds 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 declaringindex.md(ormanifest.yaml): the folder's files are tarred on every push, and on every change in--watchmode, 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-labctlwatches it regardless.- Limits: at most 1 GiB per fetched file.
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).
- Previous
- Multi-Network Playgrounds
- Next
- UI Tabs