Today we’ll do a walkthrough of building, then (partially) configuring a zone
with Gurp. If you want to play along at home
you’ll need an OmniOS box, and root privileges. Then make yourself a gurp
directory and cd into it. Mine is in my $HOME.
I’m also going to sneak in a bit of Janet instruction as well, from the very basic to the this-is-why-people-hate-Lisp.
Get Gurp!
I keep meaning to have Github Actions do a cross-compiled build so people can download a binary, but I haven’t got round to it yet. So, on an OmniOS box with GCC, Rust, and Git:
$ git clone git@github.com:snltd/gurp.git
$ cd gurp
$ CFLAGS=-std=c99 cargo install --path cli
Sorry about the CFLAGS: without it GCC can’t build the Janet amalgamation, and
I can’t work out a nicer way to fix it. If you can,
chuck me a PR.
I’m going to assume your Cargo bin directory (probably ~/.cargo/bin) is in
your PATH.
Gurp New Zone!
Our final aim is to configure a zone, but that zone does not yet exist. Let’s make it with Gurp.
The first rule of Gurp is that configuration must belong to a (host). The name
doesn’t particularly matter, unless you explicitly refer to it, which we’re not
going to here, so we can call it anything.
# gurp-zone.janet
(host "example")
That’s enough to compile:
$ gurp compile --format=json gurp-zone.janet
{"metadata":{"name":"example"},"resources":{"ensure":{},"remove":{}}}
You can see that we are all set up for resources which Gurp will ensure
(i.e. guarantee the state of) or remove. (Self-explanatory.)
Let’s add a zone resource. We’ll call the zone gurp-example. When we want to
ensure a Gurp resource type, we use a Janet function call, and – Janet lesson
number one! – that means putting the () around the operand and the
arguments.
All Gurp resources have a name. That’s usually the name the operating system will use to refer to the resource so, in this case, the name the zone will have.
(host "example"
(zone/ensure "gurp-example"))
Notice that the resource definition is inside, that is, belongs to the
host definition. Let’s compile again.
$ gurp compile --format=json gurp-zone.janet
error: Failed to validate user input for zone 'gurp-example': zone missing
required key(s): brand
That’s reasonable. illumos has lots of zone types, and we can’t expect Gurp to pick one for us. We need to add a resource specification. To start with let’s just address the error and give it a zone brand. Which brings us to…
Janet lesson number two Key-value pairs are defined as :key "value" or
"key" "value". The leading : things are “keywords”, which are similar to
Ruby symbols. There’s no separator other than whitespace, and when you’re
defining Gurp resource specs, you will always use keywords.
(host "example"
(zone/ensure "gurp-example" :brand "lipkg"))
I chose an lipkg zone because it’s reasonably quick to install, and lets us do
pretty much anything inside it.
$ gurp compile --format=janet gurp-zone.janet
{:metadata
{:name "example"}
:resources {:ensure @{:zone @[{ :_id "/NO-ROLE/zone/gurp-example"
:autoboot true
:boot-after-install true
:brand "lipkg"
:name "gurp-example"
:recreate 0
:zonepath "/zones/gurp-example"}]} :remove @{}}}
We saw JSON earlier, but your host description actually compiles into
a Janet struct.
That gets turned into JSON internally, and you can choose to see either. I
picked Janet this time because it clearly illustrates the key-value syntax we
were just talking about. (Don’t worry about the @s. They just mean the
structure which follows them is mutable.)
Second, the brand we specified is there, but there’s other stuff too, which we didn’t ask for. Where did that come from?
To minimise boilerplate, Gurp has hardcoded default values for most resource
types. You can see them all as a big struct by running gurp show defaults,
and you can also get most of them by describing resource types. Actually, let’s
do that.
$ gurp describe zone
(zone/ensure)
mandatory keys
brand string Zone brand. byhve and illumos are not supported
optional keys
attr See 'zone-attr'
autoboot string Boot the zone on system boot. Default 'true'
boot-after-install string Boot the zone n it is installed. Default 'true'
bootstrap-from string Copy gurp into the zone, and apply the given file,
file relative to zone root
capped-memory struct Set memory cap. Keys must be :physical and :swap,
values are strings like '4G'
clone-from string Instead of installing, clone from the given zone,
which must exist and be halted
copy-in struct Copy files into the zone. Key (keyword) is src,
val is dest, relative to zone root. Unqualified
src is assumed to be in ../files/
datasets tuple ZFS datasets (as strings) to be delegated to zone
dns struct DNS info. :domain is a string; :nameservers a tuple
of strings
exec-in tuple Runs the given commands (:string) in the zone after
booting
final-state string Put the zone in the given state. Also accepts 'reboot'
fs See 'zone-fs'
lx-image string Install zone using this image. See docs for pattern
rules
net See 'zone-network'
rctl See 'zone-rctl'
recreate number 1-in-n chance the zone will be destroyed and
recreated. Default '0'
zonepath string Path to zone root
(zone/remove)
No mandatory keys
No optional keys
That’s pretty helpful. We can clearly see that we have to supply a :brand; we
can see where those default values came from; and we can see how to add other
things to the zone. There’s also
documentation which tells you the same things in more detail.
You can’t build a zone without at least one network interface. Gurp defines
those with a special function. That’s why the net property refers you to
zone-network. Let’s look that up.
$ gurp describe zone-network
(zone-network/ensure)
mandatory keys
allowed-address string IP address, with /netmask
optional keys
defrouter string IP address of default router
global-nic string Physical NIC on which to create zone VNIC. Default
'auto'
We can see we need to supply an IP address, and if we wish, we can give it a default router.
There’s also :dns option in the main help, so let’s add that along with the
network. It’s just a struct:
:dns {:domain "lan.id264.net" :nameservers ["192.168.1.53" "192.168.1.1"]}))
Our grand aim is to use Gurp to configure this zone, so let’s use the
(zone-fs) function to mount our ~/gurp directory, read-only, inside the
zone. Consulting gurp describe once again:
$ gurp describe zone-fs
(zone-fs/ensure)
mandatory keys
special string The directory in the global zone
optional keys
options tuple Options with which to mount fs inside zone
type string The type of fs mount. Default 'lofs'
it looks like we need
(zone-fs "/gurp" :options ["ro"] :special "/home/rob/gurp"))
Finally, I want to copy our Gurp binary into the zone. There’s a special
:copy-in key for this, which takes a struct and copies the key (in the global
zone) to the value (in the managed zone).
Gurp has a little helper function (run-cmd), which returns the standard out of
simple commands (by which I mean it can’t deal with pipes.) So we can write
:copy-in {(run-cmd "which gurp") "/usr/bin/gurp"}
There are several helpers, which you can read about in the documentation.
Let’s also create a ZFS dataset, and delegate it to the zone. ZFS resources are
easy to create, especially if you don’t want them mounted, because providing no
properties defaults the mountpoint to none. Change the name to whatever
suits your system.
(zfs/ensure "rpool/gurp-example")
We can bring one or more datasets into the zone through the :datasets key.
Since we’re going to reference that ZFS path more than once, let’s bind it in a
(def). This is like setting a constant,
(def zfs-dataset "rpool/gurp-example")
Putting all of that together we get:
(host "example"
(def zfs-dataset "rpool/gurp-example")
(zfs/ensure zfs-dataset)
(zone/ensure "gurp-example"
:brand "lipkg"
(zone-network "geg_nic0"
:allowed-address "192.168.1.170/24"
:defrouter "192.168.1.1")
:copy-in {(run-cmd "which gurp") "/usr/bin/gurp"}
:datasets [zfs-dataset]
(zone-fs "/gurp"
:options ["ro"]
:special "/home/rob/gurp")
:dns {:domain "lan.id264.net"
:nameservers ["192.168.1.53" "192.168.1.1"]}))
Let’s try it. This is as root, from the global zone. Do you trust me? We haven’t mentioned any other zones in the config, so nothing else will be touched. (Output has a line broken for formatting.)
# gurp apply ./gurp-zone.janet
2025-09-02T21:52:07.101244Z INFO doers::host: Configuring host: example
2025-09-02T21:52:07.171966Z INFO doers::zfs: creating filesystem: rpool/gurp-example
2025-09-02T21:52:07.245985Z INFO doers::zone::doer: installing gurp-example [lipkg]
2025-09-02T21:55:08.948577Z INFO doers::zone::doer: copying /home/rob/.cargo/bin/gurp
-> /zones/gurp-example/root/usr/bin/gurp
2025-09-02T21:55:09.037572Z INFO commands::apply: Run time: 181.982s
2025-09-02T21:55:09.037615Z INFO commands::apply: resources: 2 changes: 2
Let’s have a look around.
# zlogin gurp-example
[Connected to zone 'gurp-example' pts/5]
OmniOS r151054 omnios-r151054-180b86b9dd July 2025
root@gurp-example:~# ping google.com
google.com is alive
root@gurp-example:~# mount | grep ^/gurp
/gurp on /gurp read only/setuid/devices/dev=42d0028 on Mon Sep 1 21:54:26 2025
root@gurp-example:~# ls -l /usr/bin/gurp
-rwxr-xr-x 1 root root 17641968 Sep 1 22:09 /usr/bin/gurp
root@gurp-example:~# zfs list rpool/gurp-example
NAME USED AVAIL REFER MOUNTPOINT
rpool/gurp-example 96K 71.9G 96K /rpool/gurp-example
root@gurp-example:~# ^D
logout
[Connection to zone 'gurp-example' pts/5 closed]
Looks good. And if we re-run Gurp:
# gurp apply ./gurp-zone.janet
root@serv:/home/rob/gurp# gurp apply ./gurp-zone.janet
2025-09-02T22:01:43.622220Z INFO doers::host: Configuring host: example
2025-09-02T22:01:43.723749Z INFO commands::apply: Run time: 117.123ms
2025-09-02T22:01:43.723781Z INFO commands::apply: resources: 2 changes: 0
Here’s a caveat. Gurp cannot change an existing zone. It can blow it away
and re-create it, but not change it. So if we took that fs binding out of our
zone config, Gurp, because it understands its limitations, would still say
“nothing to change”.
Gurp That Zone!
This is all going to be rather contrived and useless, but hopefully in an informative way.
For our first task, let’s make a user, and let’s put all the logic to do that into a role. I’m going to follow that odd pattern people have these days, where every user has its own corresponding group. I never understood why they do that, but it serves a purpose here.
In my ~/gurp directory, (which is mounted ro in the zone), I’m going to make
a roles/ directory, then make the user role.
$ mkdir roles
$ hx roles/user.janet
Let’s do a little bit more elementary Janet and make a couple of bindings.
(def user-name "rob")
(def home-directory-path (pathcat "/export/home" user-name))
The first one we already saw: it creates a symbol user-name which resolves to
rob. That’s a like a non-mut Rust let: you can’t change it, but you can
re-bind it with a new (def).
The second line is a little bit more involved. It binds to the
home-directory-path symbol the output of the (pathcat) function. This is
another Gurp helper, which joins all its arguments together into a
fully-qualified path.
(def user-name "rob")
(def home-directory-path (pathcat "/export/home" user-name))
(indoc zshrc ```
bindkey -v
bindkey -a / vi-history-search-backward
setopt RM_STAR_SILENT
setopt extendedglob```)
(role user
(user/ensure user-name
:uid 264
:primary-group (this :group user-name :name)
:gecos "Gurp Example User"
:shell "/bin/zsh"
:home-dir home-directory-path
:other-groups ["sysadmin"])
(group/ensure user-name
:gid 264)
(file/ensure (pathcat home-directory-path ".zshrc")
:content zshrc
:mode "0700"
:owner (this :user user-name :name)
:group (this :group user-name :name))
(directory/ensure home-directory-path
:mode "0755"
:owner (this :user user-name :name)
:group (this :group user-name :name))
(zfs/ensure (zfscat "rpool/gurp-example" user-name)
:properties {:mountpoint home-directory-path
:quota "10G"})
(pkg/ensure "shell/zsh"))
Let’s walk through this. The name of the user, and of the group, is whatever is
in the user-name binding. The primary-group of the user is found by looking
up a property of another resource.
(this :group user-name :name)
Means “in this role, find a group called user-name, and give me its name
property”. We could, of course, simply have used the user-name binding again,
but I wanted to show you the references feature. We use references again in the
directory resource.
Looking closely, you see that the user resource references the group
resource before the latter is defined, and requires zsh to exist, but we don’t
reference that package until the very end of the file. Furthermore, we ask for
the .zshrc file before the directory which contains it.
The referencing is fine, because references are resolved once all resources are known. The resource dependendies aren’t a problem because of Gurp’s implicit dependency handling.
Packages are installed first. Directories are created before files and links. Services are managed finally, when we know whether anything has changed which requires them to refresh or restart. You can see the full resource ordering in the docs.
Within a resource type, resources are applied in the order in which they are defined.
indoc is a macro (nicked from
the Rust crate of the same name) which
lets you inline and nicely indent a document. We could equally well have put a
literal string into the :content property, read a file off disk with Janet’s
(slurp), or used Gurp’s :from property.
A role on its own is no good though. Remember, we need a (host). So make a
gurp-example.janet to hold it. This should be at the same level as the
roles/ directory.
(use roles/user)
(host "example-zone"
(user))
We bring the role into scope with (use roles/user). Janet module paths can be
a bit clunky, so Gurp manipulates the *syspath* binding to smooth things a
little. Unqualified paths are relative to the directory holding your main config
file. That is, the one which defines the (host).
Let’s see what Gurp makes of the code we wrote. Running as root, inside the
zone:
root@gurp-example:/gurp# gurp apply --noop /gurp/example-zone.janet
2025-09-02T22:29:38.907717Z INFO doers::host: Configuring host: example-zone
2025-09-02T22:29:38.914619Z INFO doers::zfs: creating filesystem: rpool/gurp-example/rob
2025-09-02T22:29:39.586549Z INFO doers::pkg: installing: shell/zsh
2025-09-02T22:29:41.498944Z INFO doers::group: creating group: rob
2025-09-02T22:29:41.499151Z INFO doers::user: creating user: rob
2025-09-02T22:29:41.499227Z INFO doers::directory: creating directory: /export/home/rob
2025-09-02T22:29:41.499279Z INFO doers::file: Creating /export/home/rob/.zshrc
2025-09-02T22:29:41.503111Z INFO commands::apply: Run time: 2.612s
2025-09-02T22:29:41.503176Z INFO commands::apply: resources: 6 changes: 6
The reason that took so long (2.6s) is because when you do a package no-op, Gurp
actually runs pkg -n, which does a proper package install dry-run. If you’re
curious what other commands it might run, put it into DEBUG mode by setting the
RUST_LOG env var. (Again, lines broken to fit the page.)
root@gurp-example:/gurp# RUST_LOG=debug gurp apply --noop /gurp/example-zone.janet
2025-09-02T22:30:30.210347Z DEBUG janet_int::reader: reading host config from
/gurp/example-zone.janet
2025-09-02T22:30:30.210542Z DEBUG janet_int::helpers: Initialising janet client
2025-09-02T22:30:30.233448Z DEBUG doers::host: Janet returned 963 char JSON buffer
2025-09-02T22:30:30.233473Z DEBUG doers::host: Unpacking JSON into HostConfig
2025-09-02T22:30:30.233803Z INFO doers::host: Configuring host: example-zone
2025-09-02T22:30:30.233851Z DEBUG doers::host: applying user 1/1:
/user/zfs/rpool_gurp-example_rob
2025-09-02T22:30:30.233942Z DEBUG doers::zfs: command="/usr/sbin/zfs list -H -o name"
2025-09-02T22:30:30.242036Z INFO doers::zfs: creating filesystem: rpool/gurp-example/rob
2025-09-02T22:30:30.242063Z DEBUG doers::zfs: command="/usr/sbin/zfs create -o
quota=10G -o mountpoint=/export/home/rob -n
rpool/gurp-example/rob"
2025-09-02T22:30:30.242091Z DEBUG doers::pkg: command="/bin/pkg list -aHo name,flags"
2025-09-02T22:30:30.920513Z DEBUG doers::pkg: scheduled for install: shell/zsh
2025-09-02T22:30:30.920542Z DEBUG doers::pkg: ensure pkg list: shell/zsh
2025-09-02T22:30:30.920548Z INFO doers::pkg: installing: shell/zsh
2025-09-02T22:30:30.920556Z DEBUG doers::pkg: command="/bin/pkg install -n shell/zsh"
2025-09-02T22:30:32.774067Z DEBUG doers::host: applying user 1/1: /user/group/rob
2025-09-02T22:30:32.774483Z INFO doers::group: creating group: rob
2025-09-02T22:30:32.774501Z DEBUG doers::group: command="/usr/sbin/groupadd -g 264 rob"
2025-09-02T22:30:32.774516Z DEBUG doers::host: applying user 1/1: /user/user/rob
2025-09-02T22:30:32.774582Z INFO doers::user: creating user: rob
2025-09-02T22:30:32.774601Z DEBUG doers::user: command="/usr/sbin/useradd -c Gurp
Example User -g rob -d /export/home/rob -s /bin/zsh
-u 264"
2025-09-02T22:30:32.774618Z DEBUG doers::host: applying user 1/1:
/user/directory/_export_home_rob
2025-09-02T22:30:32.774637Z INFO doers::directory: creating directory: /export/home/rob
2025-09-02T22:30:32.774681Z DEBUG doers::host: applying user 1/1:
/user/file/_export_home_rob_.zshrc
2025-09-02T22:30:32.774698Z INFO doers::file: Creating /export/home/rob/.zshrc
2025-09-02T22:30:32.775857Z INFO commands::apply: Run time: 2.566s
2025-09-02T22:30:32.775876Z INFO commands::apply: resources: 6 changes: 6
2025-09-02T22:30:32.775881Z DEBUG gurp: exiting 0
Looks good to me. Let’s apply it.
root@gurp-example:~# gurp apply /gurp/example-zone.janet
...
2025-09-02T22:35:05.072179Z INFO commands::apply: Run time: 26.686s
2025-09-02T22:35:05.072258Z INFO commands::apply: resources: 6 changes: 6
But hang on. We now need to add a new user, following the same pattern. We don’t want to duplicate all that config, but this is a real language, so we can easily break it out into a function.
Janet lesson no. 3 Functions look like this
(defn user-and-group
"Create a user, group, home dir and zshrc, following a pattern"
[user-name uid]
(def home-directory-path (pathcat "/export/home" user-name))
(user/ensure user-name
:uid uid
:primary-group (this :group user-name :name)
:gecos "Gurp Example User"
:shell "/bin/zsh"
:home-dir home-directory-path
:other-groups ["sysadmin"])
(group/ensure user-name
:gid (this :user user-name :uid))
(file/ensure (pathcat home-directory-path ".zshrc")
:content zshrc
:mode "0700"
:owner (this :user user-name :name)
:group (this :group user-name :name))
(zfs/ensure (zfscat "rpool/gurp-example" user-name)
:properties {:mountpoint home-directory-path
:quota "10G"})
(directory/ensure home-directory-path
:mode "0755"
:owner (this :user user-name :name)
:group (this :group user-name :name)))
(role user
(user-and-group "rob" 264)
(user-and-group "klf" 265)
(pkg/ensure "shell/zsh"))
All we’ve done is lift-and-shift the resources into a function, and moved the
home-directory-path binding in there with it. Then we call that function twice
from inside the role. This illustrates a key point of Gurp: it doesn’t matter
where you call the (resource/ensure) functions from. If you call them at all,
the resource will be ensured.
In case you’re wondering, if we called the function twice with the same args, Gurp would throw a
duplicate keyerror. It works out keys from role, resource type, and name, so it isn’t that hard to fool it if you really want to. There’s no wholesale, detailed, checking for collisions.
Oh no, I was so eager to test the new function that I accidentally applied it,
and now I have a klf user that I’m not sure I need.
Let’s remove that user, with a very small change to the role.
(role user
(user-and-group "rob" 264)
(section "remove-klf-and-all-her-things"
(user/remove "klf")
(zfs/remove "rpool/gurp-example/klf"))
(pkg/ensure "shell/zsh"))
We don’t need to remove the home directory (in fact, we can’t) because it’s the top level of the user’s ZFS filesystem.
The (section) is garnish. It’s a no-op macro that can be useful to divide
larger configs into chunks of related configuration. I thought I’d show it to
you, but it makes no difference to anything. Give it a try.
root@gurp-example:/gurp# gurp apply /gurp/example-zone.janet
2025-09-02T22:43:34.678350Z INFO doers::host: Configuring host: example-zone
2025-09-02T22:43:35.356421Z INFO doers::user: removing user: klf
2025-09-02T22:43:35.384443Z INFO doers::zfs: removing filesystem: rpool/gurp-example/klf
2025-09-02T22:43:35.447747Z INFO commands::apply: Run time: 790.815ms
2025-09-02T22:43:35.447785Z INFO commands::apply: resources: 8 changes: 2
We could move the indoc and the function to modules/user-and-group.janet. If
we rename the function as create, then our role file simply becomes
(import ../modules/user-and-group)
(role user
(user-and-group/create "rob" 264)
(section "remove-klf-and-all-her-things"
(user/remove "klf")
(zfs/remove "rpool/gurp-example/klf"))
(pkg/ensure "shell/zsh"))
You could trivially write unit tests for the module. There’s lots of prior art
in the resource tests in
the Gurp Janet test directory
but in a nutshell, you just need to assert the state of a *collector* var.
We can make the role more generic if we create some kind of centralised configuration store.
Let’s make a file called vars.janet, in our top-level gurp/ directory, and
bring back that klf user.
# vars.janet
(def hosts
{"gurp-example"
{:users {"rob" 264
"klf" 265 }}})
I think the mix of “strings” and :keywords helps to make it clear which parts of the structure may be changed, but you could make everything one or the other if you preferred.
Now we can change our role to this:
(import vars)
(import modules/user-and-group)
(role user
(loop [[user-name uid] :pairs (get-in vars/hosts [(this-host) :users] {})]
(user-and-group/create user-name uid))
(pkg/ensure "shell/zsh"))
Janet lesson no. 4. This is the most involved we’ve got with the language so far.
(get-in vars/hosts [(this-host) :users] {})
is a bit of a mouthful. The get-in function digs into the nested struct bound
to vars/hosts, using the keys given in the array that follows. The first key
is the output of the (this-host): a Gurp helper that returns the name defined
in the top-level (host) declaration. The {} is a default value that
(get-in) should return if it can’t find what it’s looking for.
The loop iterates over key-value pairs in whatever (get-in) returns. The
inner square-brackets destructure, binding the key to user-name and the value
to uid. For each bound pair, it executes the body of the loop, which is the
call to our module function.
A little complicated for this scenario perhaps, but it shows that we could have
users for arbitrarily many machines in our vars file, and have a users role
add the correct ones to each machine, using a common module. If you use Janet
table prototypes you can quickly
create hierarchical lookup tables.
To finish off, flushed with confidence, let’s get too clever for our own good.
Our zone has a gurp binary, and the config it needs to assert. Let’s make a
cron job to run Gurp every fifteen minutes. But, so we don’t get every host in
our estate running at exactly the same time, let’s use a hash of the hostname to
generate those four offsets.
As well as using (this-host), we can get our real hostname with (hostname).
Let’s do that, for the change.
seq is like loop, but instead of the body being executed purely for
side-effects, it returns a tuple containing the results of each of the body
invocations. A string is an iterable made up of characters, so we can write
(seq [char :in (hostname)] char) and get a tuple of ASCII character codes.
(You don’t have unicode hostnames, do you?)
apply takes any function, and applies it across all elements of an iterable.
If the function is +, it will sum every element in a tuple. So
(apply + (seq [char :in (hostname)] char)) gives us a number we can hash.
Remainder division is done by %, so we can get a hash of our number with
(% (apply + (seq [char :in (hostname)] char)) 15).
That’s the minutes past the hour our first invocation should be at, so we now
need to turn that one number into a tuple of four, each fifteen minutes later
than the previous. As we’re trying to see as much of Janet as possible, let’s
use a generator. (generate [n :range [0 60 15]] n) will make a lazy sequence
of numbers from 0 to 60, with a step of 15. We can then pick of as many as we
like with (take).
(take 4
(generate [offset :range [0 100 15]]
(+
offset
(% (apply + (seq [char :in (hostname)] char)) 15))))
Do you love it, or do you hate it?
The nice way to use this would be to put it in a helper function, but to absolutely hammer home the point that THIS IS NOT YAML…
(let [minutes (take 4
(generate [offset :range [0 60 15]]
(+ offset
(% (apply + (seq [char :in (hostname)] char)) 15))))]
(cron/ensure "run gurp"
:minute (string/join minutes ",")
:command (string/format "/usr/bin/gurp apply /gurp/%s.janet" (hostname))))
If that doesn’t put you off, nothing will, and I’ll see you next time when we’ll do something more useful.
To clean up, you just need to apply this in the global zone.
# cleanup.janet
(host "example"
(zfs/remove "rpool/gurp-example")
(zone/remove "gurp-example"))