The guide · explanation

The lab file

10 min read · 2026-09-02 · vmlab 0.9

A lab is one file, vmlab.wcl, written in WCL. It declares the lab's name, its machines, its segments and the automation that runs when the lab comes up. Everything else has a default: a lab with one VM and no network declarations still boots, gets an address and resolves names. This chapter explains the shape of the file, what nests where, how vmlab checks it, and how a value you leave out is filled in. The exact fields of every block are in the reference chapters, starting with Lab file: lab and segment.

The import and the lab block

Every lab file starts with import <vmlab.wcl>. That line binds the file to vmlab's schema, so the WCL parser itself rejects an unknown block or attribute before vmlab reads anything. A file without it is not a lab file. After the import comes one lab "<name>" { ... } block. The name is a DNS label, because it appears in every machine's DNS name, and it is the lab's identity on the host, as How vmlab runs a lab explains.

examples/mixed-lab/vmlab.wclwcl
# A small mixed Windows/Linux lab built on the two example templates
# (examples/templates/windows-server-2025 and examples/templates/
# ubuntu-24.04 — build those first). Demonstrates a NAT'd segment, a
# static IP, boot ordering, an SMB share, a host port-forward, an OCI
# container on the same segment (§18), and a provision script driving
# both guests.
#
# vmlab up
# curl http://localhost:18080      # nginx on nix01, via the forward
# curl http://localhost:18081      # nginx in the "web" container
# vmlab down

import <vmlab.wcl>

lab "mixed-lab" {

  gui = true  # open a VNC viewer for each guest on `vmlab up`

  segment "lan" {
    subnet = "10.70.0.0/24"
    nat = true  # apt needs egress
    forward {
      host_port = 18080
      to = "nix01:80"
    }  # host → nginx
  }

  vm "winsrv" {
    template = "x86_64/windows-server-2025"
    cpus = 4
    memory = 8GiB
    nic {
      segment = "lan"
      ip = "10.70.0.10"
    }  # DHCP reservation
    share {
      host = "./shared"
      guest = "S:"
    }  # auto-mounted when ready

    # Runs once winsrv is ready; nix01 depends on it, so it waits.
    provision "scripts/setup.ws" { }
  }

  vm "nix01" {
    template = "x86_64/ubuntu-24.04"
    memory = 2GiB
    depends_on = ["winsrv"]
    nic {
      segment = "lan"
    }  # dynamic lease
  }

  # An OCI container on the same segment: pulled like a docker image,
  # run in a micro-VM, resolvable as web.mixed-lab.<suffix> from the VMs.
  container "web" {
    image = "nginx:1.27"
    # The micro-VM's size comes from the profile; declare `cpus`/`memory`
    # here to override it. One of the two must supply them.
    profile = "container"
    depends_on = ["nix01"]
    nic {
      segment = "lan"
    }
    port {
      host = 18081
      container = 80
    }  # host → container nginx
    healthcheck {
      command = ["curl", "-fsS", "http://localhost/"]
      interval = 5s
    }
  }

  on "vm.crashed" {
    run = "scripts/on-crash.ws"
  }
}

What nests where

The lab block holds four kinds of child: segments, machines, event handlers, and lab-wide DNS entries. Machines in turn hold the things that belong to one machine. The rule of thumb is that a block lives inside the thing it configures, so there is nothing to cross-reference by name.

BlockNests inWhat it declares
segmentlabA virtual L2 switch, with its subnet, DHCP, DNS, NAT, guest routes, forwards and L3 rules. See Networking.
vmlabA VM booted from a template's linked clone, with its hardware and its children below. See Lab file: vm and its children.
containerlabAn OCI image run in a micro-VM, with env, volumes, ports and a healthcheck. See Lab containers.
onlabAn event handler: a wscript script bound to an event name. See Events and handlers.
record, sinkholelab or segmentStatic DNS entries and DNS sinkholes, lab-wide or per segment.
nicvm, container, templateA network interface on a segment. A machine with no nic blocks has no network hardware at all.
sharevmA host directory mounted in the guest. See Shared folders.
disk, media, gpuvm (and template for disks and media)Extra disks, ISO and floppy images built from folders, and GPU acceleration.
loginvm, containerA labelled identity a person's commands run as. See Logins.
provision, playbookvm, container, templateConfiguration steps run once the machine is ready, in declaration order. See Guest automation with wscript and Playbooks.
templatethe document, beside labA buildable template definition. See Templates and the store.

Configuration steps are the clearest case of the nesting rule. A provision or playbook block is declared inside the machine it configures. That machine is the target, so there is no target = field to get wrong. A machine's steps run in the order its blocks appear, once the machine is ready. Across machines they follow the order the machine blocks appear, with depends_on gating when each becomes eligible. A dependent waits for its dependency to be ready *and* for that dependency's steps to finish.

Connectivity is always explicit

A machine with no nic blocks is air-gapped. It still boots, still answers the agent, and can still be driven with exec and cp, because the agent rides virtio-serial rather than the network. Connectivity is a ladder you climb by declaration: nothing, then nic { nat = true } for internet-only access on the lab's built-in NAT segment, then a nic on a declared segment. Networking walks each rung.

Validation

vmlab validate evaluates the file against the schema and then applies every rule that can be checked without touching QEMU. Every other verb runs the same checks first and stops before any side effect on an error. The checks fall into two layers.

The schema layer is WCL's own: unknown blocks and attributes, wrong types, a required field missing, a value outside a declared range or option set. The schema declares these with decorators on each field, and the reference chapters reflect every one of them.

The semantic layer is vmlab's, and includes:

What validation deliberately does not check

@dev(workspace = …) on a machine whose agent cannot serve the workspace syncer is not a validation error. The agent's features are only known once it is running, so that failure is reported when the syncer starts, as Dev machines and the workspace syncer explains.

How a value resolves

Most hardware fields on a vm block are optional. A value you do not set comes from the template's recorded hardware, and a value not recorded there comes from the guest OS profile. The profile's defaults are the floor. The precedence is fixed:

text
VM block  >  template  >  profile

A template records its hardware when it is sealed, so a VM cloned from a template built with memory = 4GiB boots with four gibibytes unless the vm block says otherwise. The profile is a live layer, not frozen into the template: editing a profile reaches every VM that still resolves a field down to it.

Two machine kinds shorten the chain. A scratch VM has no template, so its chain is VM block then profile, which is why validation insists on arch, profile and disk. A container has no template either: its cpus and memory come from the block or from its profile, and the shipped container profile supplies a floor of one vCPU and 256 MiB. One resolver implements this precedence for both machine kinds, and no other surface reimplements it.

Decorators

A decorator is written on a machine block and states something *about* the machine rather than configuring something inside it. vmlab ships one: @dev, which marks a VM or container as a dev machine: a machine with a workspace vmlab keeps in step with a host directory. Every argument is optional. A bare @dev is a complete dev machine, and unset arguments resolve @dev then profile then vmlab's own floor.

examples/dev-container/vmlab.wclwcl
@dev(default = true, workspace = "./workspace")
container "dev01" {
  image   = "alpine:3.22"
  profile = "container"
  // A dev machine builds things, and a container names its own size when
  // the profile's floor is not the right one.
  cpus    = 2
  memory  = 1GiB
  // `:idle` keeps the micro-VM up without running the image's entrypoint
  // — a dev container has no service to be.
  mode    = :idle
  nic { segment = "lan" }

  // The container identity floor (§19.2): the agent is root and root needs
  // no credential to become an account, so a Linux `login {}` may declare
  // the account alone. `elevated` is a validation error on this side.
  login "dev" { user = "dev" default = true }

  provision "scripts/dev-user.ws" { }
  provision "scripts/home-bits.ws" { }
}

default = true names the lab's default dev machine, which is the same for everyone who opens the file. Which dev machine is *yours* is not a property of the file, and Dev machines and the workspace syncer explains where it is recorded instead. The decorator's arguments are listed with the machine blocks in Lab file: vm and its children.

Addressing a lab from the command line

The CLI finds the lab from your current directory by walking up to the nearest vmlab.wcl. Within it, machine verbs take a bare machine name. From anywhere else, or when several labs run at once, address a machine as lab/machine. Machine names are scoped per lab, and VMs and containers share one namespace, so web can be either kind but not both.