Starlark Custom Modules
Zester supports custom state modules written in Starlark, a sandboxed Python dialect. Drop .star files into _modules/ directories and they become available as state modules alongside built-in Go modules.
Quick Start
Create a module file:
# _modules/nginx.star
def configured(id, config):
file_write(config["path"], config["content"], 0o644)
cmd_run("nginx", args=["-s", "reload"])
return {"changed": True, "diff": "wrote config and reloaded"}Use it in state files identically to built-in modules:
web-config:
nginx.configured:
- path: /etc/nginx/nginx.conf
- content: "worker_processes auto;"
- require:
- pkg.installed:install-nginxModule Naming
Module names are derived from the filename and function name: {filename}.{function}.
_modules/nginx.starcontainingconfigured()→nginx.configured_modules/nginx.starcontainingrunning()→nginx.running
Multiple Actions Per File
A single .star file can define multiple actions:
# _modules/nginx.star → registers nginx.configured AND nginx.running
def configured(id, config):
file_write(config["path"], config["content"], 0o644)
return {"changed": True}
def running(id, config):
r = cmd_run("systemctl", args=["is-active", "nginx"])
if r["exit_code"] == 0:
return {"changed": False}
cmd_run("systemctl", args=["start", "nginx"])
return {"changed": True}Private Helpers
Functions starting with _ are private helpers and not registered as modules:
def _reload_service(name):
cmd_run("systemctl", args=["reload", name])
def configured(id, config):
file_write(config["path"], config["content"], 0o644)
_reload_service("nginx")
return {"changed": True}Return Values
Apply Function (required)
Must return a dict with:
| Key | Type | Required | Description |
|---|---|---|---|
changed | bool | Yes | Whether the system was modified |
diff | str | No | Description of changes |
details | dict | No | Module-specific result data (string keys and values) |
def configured(id, config):
return {"changed": True, "diff": "wrote config", "details": {"path": config["path"]}}Check Function (optional)
If defined as {name}_check, enables proper dry-run/check mode. Must return:
| Key | Type | Required | Description |
|---|---|---|---|
needs_change | bool | Yes | Whether changes are needed |
diff | str | No | Description of what would change |
def configured_check(id, config):
if file_exists(config["path"]) and file_read(config["path"]) == config["content"]:
return {"needs_change": False}
return {"needs_change": True, "diff": "content differs"}If no _check function is defined, Check always reports needs_change: True (like cmd.run).
Revert Function (optional)
If defined as {name}_revert, enables state revert:
def configured_revert(id, config):
file_remove(config["path"])
return {"changed": True, "diff": "removed config"}If no _revert function is defined, revert is a no-op.
Self-Documentation
Starlark modules describe themselves the same way built-in Go modules do: the
loader captures each module's documentation at load time and exposes it through
sys.doc (and zester doc). There is nothing to generate — the docs come
straight from the .star source.
Two optional, additive conventions feed the captured documentation:
- A function docstring (a Google-style string literal as the function's first statement).
- A parameter-declaration dict — either a per-function
<fn>_paramsor a module-globalPARAMS.
Neither is required. A module with neither is still self-documented (its name, kind, and source location) and accepts arbitrary parameters.
Docstrings
The first line is the summary; the paragraph(s) before an Args: section become
the description; the Args: section supplies per-parameter help text:
def configured(id, config):
"""Manage the nginx config file.
Renders the configuration from settings and reloads the daemon on change.
Args:
path: absolute path to the config file
mode: file mode as an octal string
"""
...
return {"changed": True}Returns:, Raises:, and other Google-style sections end the Args: block and
are not captured.
Parameter declarations (PARAMS / <fn>_params)
Declaring parameters is what turns on unknown-key validation: a config key
that is neither a declared parameter, nor a requisite/attribute directive
(require, watch, onlyif, order, …), nor name, is reported per the
peel's validation policy (a warning by default). Declare parameters with a dict
whose keys are parameter names and whose values describe each one:
# Applies to every action in this file that has no <fn>_params of its own.
PARAMS = {
"path": {"type": "str", "required": True, "usage": "config file path"},
"mode": {"type": "str", "default": "0644", "usage": "file mode (octal string)"},
"reload": {"type": "bool", "default": True},
"aliases_example": {"type": "str", "aliases": ["altkey"]},
}
# A per-function declaration overrides PARAMS for that one action.
configured_params = {
"path": {"type": "str", "required": True},
}
def configured(id, config):
"""Manage the nginx config file."""
...Per-parameter dict keys (all optional):
| Key | Meaning |
|---|---|
type | str (default), bool, int, float, list, or dict |
required | True marks the parameter mandatory in the docs/schema |
default | a scalar default (string/bool/int/float) shown in the docs |
usage / help | help text (a docstring Args: entry fills this when omitted) |
aliases | additional accepted config keys |
A shorthand form maps a name directly to its help string:
PARAMS = {"path": "config file path"}.
Notes and caveats:
- Starlark stays dynamically typed: declared types drive the documentation
and JSON Schema, but the module still receives the raw
configdict and reads it directly. A type mismatch against a declared parameter is not fatal. - An empty
PARAMS = {}leaves the module open (no unknown-key warnings) — omit the dict, or declare the parameters you actually accept. - Only scalar defaults are rendered; a default containing a comma is skipped (the tag grammar reserves commas).
- Edits are picked up on hot-reload: changing a docstring or
PARAMSand bumping the file's mtime re-captures the documentation on the next load.
Discovery and Loading
Global Modules
Files in {statesDir}/_modules/ are loaded at peel startup and available to all state files.
Hot-Reload
The Loader tracks each .star file's modification time. On subsequent LoadGlobal or LoadDir calls, if a file's modification time has changed, the file is re-parsed and its modules are re-registered. This means updating a .star file takes effect on the next compile cycle without restarting the peel.
Formula-Specific Modules
Files in {formula}/_modules/ are loaded when that formula is compiled. For example, state.apply "webserver" loads:
{statesDir}/_modules/*.star(global, loaded at startup){statesDir}/webserver/_modules/*.star(formula-specific, loaded at compile time){statesDir}/common/_modules/*.star(from includes, loaded at compile time)
Formula-specific modules override global modules with the same name.
Distribution
.star files in _modules/ are within --states-dir, so they flow through the existing KV distribution path (master publishes to state-files bucket, peels cache locally). No additional configuration needed.
Available Builtins
Command Execution
result = cmd_run("nginx", args=["-s", "reload"], cwd="/etc", env={"PATH": "/usr/bin"}, shell=False)
# result = {"stdout": "...", "stderr": "...", "exit_code": 0}
# On Go-level exec failure (e.g., binary not found): result also includes {"error": "..."}cmd_run never raises on non-zero exit — it always returns the result dict. Check exit_code explicitly. If the command could not be started at the Go level (e.g., binary not found, permission denied), the result dict additionally contains an "error" key with the error message.
File Operations
content = file_read("/etc/hosts") # returns str
file_write("/etc/test.conf", "content", mode=0o644) # returns None
file_append("/var/log/app.log", "new line\n") # returns None
exists = file_exists("/etc/hosts") # returns bool
info = file_stat("/etc/hosts") # returns {"size": int, "mode": int, "is_dir": bool} or None
file_remove("/tmp/old.conf") # returns None
file_mkdir("/etc/app/conf.d", mode=0o755) # returns NonePackage Management
installed = pkg_is_installed("nginx") # returns bool
pkg_install("nginx", version="1.24") # returns None
pkg_remove("nginx") # returns NoneHTTP Requests
resp = http_get("https://api.example.com/status", headers={"Authorization": "Bearer token"}, timeout=30)
resp = http_post("https://api.example.com/data", body='{"key":"value"}', headers={"Content-Type": "application/json"})
resp = http_request("PUT", "https://api.example.com/resource", body="data")
# resp = {"status": 200, "body": "...", "headers": {"Content-Type": "application/json"}}HTTP builtins never raise on non-2xx — check status explicitly. Timeout is in seconds.
JSON
data = json.decode('{"key": "value"}') # returns dict
text = json.encode({"key": "value"}) # returns strUses the standard go.starlark.net/lib/json module.
Utilities
encoded = base64_encode("hello") # returns str
decoded = base64_decode(encoded) # returns str
digest = hash_sha256("hello") # returns hex-encoded str
sleep(5) # sleeps 5 seconds (respects cancellation)Logging
log.info("starting configuration")
log.warn("deprecated option used")
log.error("failed to connect")Context Variables
os_family = facts["os"]["family"] # read-only dict of current facts
role = settings["role"] # read-only dict of current settingsBoth facts and settings are frozen (immutable) dicts.
Shared Code Between Modules
Use Starlark's load() to import from other .star files in the same _modules/ directory:
# _modules/helpers.star
def make_result(changed, diff=""):
return {"changed": changed, "diff": diff}# _modules/nginx.star
load("helpers.star", "make_result")
def configured(id, config):
file_write(config["path"], config["content"], 0o644)
return make_result(True, "wrote config")Error Handling
- Starlark runtime errors (KeyError, type errors, etc.) are surfaced as Go errors and the state fails
- Bad
.starfiles (syntax errors) are skipped with a warning during loading; other modules still load - Provider nil checks return descriptive errors (e.g., "pkg_install: package provider not available")
Example: Complete Module
# _modules/app.star
def _ensure_dir(path):
if not file_exists(path):
file_mkdir(path, mode=0o755)
def deployed(id, config):
"""Deploy application from a tarball URL."""
url = config["url"]
dest = config.get("dest", "/opt/app")
_ensure_dir(dest)
resp = http_get(url, timeout=60)
if resp["status"] != 200:
return {"changed": False, "diff": "download failed: HTTP %d" % resp["status"]}
file_write(dest + "/app.tar.gz", resp["body"], 0o644)
cmd_run("tar", args=["xzf", "app.tar.gz"], cwd=dest)
return {"changed": True, "diff": "deployed from %s" % url, "details": {"dest": dest}}
def deployed_check(id, config):
dest = config.get("dest", "/opt/app")
if file_exists(dest + "/app.tar.gz"):
return {"needs_change": False}
return {"needs_change": True, "diff": "app not deployed"}
def deployed_revert(id, config):
dest = config.get("dest", "/opt/app")
file_remove(dest + "/app.tar.gz")
return {"changed": True, "diff": "removed deployment"}Usage:
deploy-app:
app.deployed:
- url: https://releases.example.com/app-v1.2.tar.gz
- dest: /opt/myapp
- require:
- pkg.installed:install-deps