Getting started · tutorial

Automate a guest with a script

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

A provision script is a wscript file declared inside the machine it configures. vmlab runs it during vmlab up once that machine is ready. In this tutorial you write one that waits for the guest, runs a command, copies a file in and captures the screen, then run the same script again on demand with vmlab script.

Before you start

Start from a real one

The alpine-registry example ships a provision that does the first two things this tutorial needs: it waits for readiness and runs a command. Read it before writing your own.

examples/alpine-registry/scripts/setup.wsrust
// Provision for the alpine-registry lab: wait for the guest (which boots from
// a template pulled on-demand from the OCI registry), then prove it is up and
// reachable. `wait_ready` blocks until the vmlab guest agent answers.

use vmlab

fn setup(lab: Lab) -> Result[unit, string] {
    let alp = lab.vm("alp")?

    lab.log("waiting for the guest agent (template pulled from the registry on first up)...")
    alp.wait_ready(600)?
    lab.log("alp is up at " + alp.ip()?)

    let rel = alp.exec("/bin/cat", ["/etc/alpine-release"])?
    lab.log("alpine release: " + rel.stdout.trim())

    lab.log("SSH in with:  ssh vmlab@localhost -p 12222   (password: vmlab)")
    Ok(())
}

fn main(lab: Lab) {
    setup(lab).expect("alpine-registry setup failed")
}

Four things in this file are the shape of every provision script.

Write the script

In the directory holding the first-vm lab file, create scripts/setup.ws and a file to copy in, scripts/files/motd.

sh
mkdir -p scripts/files
printf 'provisioned by vmlab\n' > scripts/files/motd
scripts/setup.wsrust
// Wait for the guest, run a command, copy a file in, capture the screen.

use vmlab

fn setup(lab: Lab) -> Result[unit, string] {
    let alp = lab.vm("alp")?

    alp.wait_ready(600)?
    lab.log("alp is ready at " + alp.ip()?)

    // exec: program and argument list, captured stdout/stderr/exit code.
    let rel = alp.exec("/bin/cat", ["/etc/alpine-release"])?
    if rel.exit_code != 0 {
        return Err("could not read the release: " + rel.stderr)
    }
    lab.log("alpine release: " + rel.stdout.trim())

    // copy_to: a host path relative to this script, to an absolute guest path.
    alp.copy_to("files/motd", "/etc/motd")?
    let motd = alp.exec("/bin/cat", ["/etc/motd"])?
    lab.log("guest motd: " + motd.stdout.trim())

    // screenshot: a PNG under the lab's .vmlab/screenshots/ when the path is "".
    let shot = alp.screenshot("")?
    lab.log("screen captured to " + shot)
    Ok(())
}

fn main(lab: Lab) {
    setup(lab).expect("first-vm setup failed")
}

Three details are easy to get wrong.

Declare it on the machine

Add a provision {} block to the VM. The path is relative to the lab root.

vmlab.wclwcl
import <vmlab.wcl>

lab "first-vm" {

  vm "alp" {
    template = "ghcr.io/vmlabdev/vmlab-templates/alpine-3.23"
    arch     = "x86_64"
    memory   = 1GiB
    nic { nat = true }

    provision "scripts/setup.ws" { }
  }
}

validate compiles the script as part of validating the lab file, so a syntax error or a call to a method that does not exist is reported before anything boots.

sh
vmlab validate

The script compiles

vmlab validate reports the lab as ok with one VM. A typo in the script is reported here, with its line.

Run it with up

sh
vmlab up

up boots the VM, waits until it is ready, and then runs the provision. Each lab.log line appears in the terminal as the script reaches it. Every up runs the steps again, whether or not the VM was already running, so keep scripts safe to repeat. Copying the same file twice is harmless. Creating an account twice is not, and the mixed-lab example shows the guard pattern, checking the guest's state before changing it.

Confirm the two side effects from the host.

sh
vmlab exec alp -- cat /etc/motd
ls .vmlab/screenshots/

The guest is provisioned

/etc/motd in the guest reads provisioned by vmlab, and .vmlab/screenshots/ holds a PNG named after the VM and the time.

Run it again on demand

Any script can be run ad hoc against the running lab. vmlab script takes a path relative to the lab root and calls its main with the same lab handle a provision gets.

sh
vmlab script scripts/setup.ws

The one difference is ownership. A provision belongs to the machine that declared it, and lab.this_vm() returns that machine. Under `vmlab script there is no owning machine and this_vm()` returns an error. The script above uses lab.vm("alp") so it works both ways.

Provisions and playbooks are the two kinds of setup step, and they run interleaved in declaration order. The other place scripts run is an on {} handler, which reacts to an event such as vm.crashed. See Playbooks and Events and handlers.

Provisions run as the agent identity

With no login {} on the machine, exec and copy_to run as root or SYSTEM. To write into a user's home as that user, declare a login and take a second handle with as_login. The dev-container example uses it (see Example labs), and Logins explains the rules.

Next steps