zester
GuidesBasket

Querying Basket Data

Basket data can be queried from the CLI, inside templates, or directly from the NATS KV bucket. This page covers every method.


CLI Queries

The zester basket command provides two subcommands for querying basket data from the terminal.

List All Basket Keys for a Peel

$ zester basket list web-01
FUNCTION
network.ip_addrs
network.fqdn
custom.app_version

This scans the basket KV bucket for all keys prefixed with the given peel ID and displays the function portion of each key.

Get Basket Data with Targeting

The get subcommand accepts the same target expressions used everywhere in Zester -- glob, regex, fact-based, list, and compound:

# Glob: all web servers
$ zester basket get 'web*' network.ip_addrs
PEEL      VALUE
web-01    10.0.1.50
web-02    10.0.1.51
web-03    10.0.1.52
# Fact-based: all peels with role "database"
$ zester basket get 'G@role:database' network.ip_addrs
PEEL      VALUE
db-01     10.0.2.10
db-02     10.0.2.11
# Explicit list
$ zester basket get 'L@db-01,db-02' disk.usage
PEEL    VALUE
db-01   {"root": "45%", "data": "72%"}
db-02   {"root": "38%", "data": "65%"}
# Get a specific peel's basket value
$ zester basket get 'L@app-01' custom.app_version
PEEL    VALUE
app-01  2.1.0

Missing Data

When a peel has not published data for the requested function, the output shows (no data):

$ zester basket get 'web*' nonexistent.function
PEEL      VALUE
web-01    (no data)
web-02    (no data)
web-03    (no data)

When no peels match the target expression:

$ zester basket get 'nonexistent*' any.function
No peels matched the target expression.

Template Queries

The basket() function is available inside .zy template files and is the primary way to use basket data in state files and configuration templates.

Syntax

basket("<target>", "<function>")
ArgumentTypeDescription
<target>stringA targeting pattern (glob, fact, compound, etc.)
<function>stringThe basket function/key name to retrieve

Returns: A list of maps, where each map contains:

KeyTypeDescription
peel_idstringThe peel ID that published the data
valueanyThe basket value for that peel

Basic Usage

Nginx upstream template
upstream_config:
  file.managed:
    - path: /etc/nginx/upstream.conf
    - content: |
        upstream backend {
        {% for entry in basket("role:webserver", "network.ip_addrs") %}
          server {{ entry.value }}:8080;
        {% endfor %}
        }

The first argument is a targeting pattern -- in this case role:webserver which is shorthand for G@role:webserver in template context. The second argument is the function name to query.

Glob Targeting in Templates

All web peels by name
{% for entry in basket("web*", "network.ip_addrs") %}
  server {{ entry.peel_id }} {{ entry.value }}:8080;
{% endfor %}

Fact-Based Targeting in Templates

Peels matching a fact
{% for entry in basket("G@os:linux", "network.ip_addrs") %}
  - host: {{ entry.value }}
{% endfor %}

Compound Targeting in Templates

Compound expression
{% for entry in basket("role:webserver and G@os:linux", "network.ip_addrs") %}
  server {{ entry.peel_id }} {{ entry.value }}:8080;
{% endfor %}

Using basket() with Conditionals

Conditional logic with basket
{% set db_hosts = basket("role:database", "network.ip_addrs") %}
{% if db_hosts %}
database:
  hosts:
  {% for entry in db_hosts %}
    - {{ entry.value }}
  {% endfor %}
{% else %}
database:
  hosts:
    - localhost
{% endif %}

Empty results

If no peels match the target or none have published the requested function, basket() returns an empty list. Always handle the empty case in templates to avoid generating broken configuration.

Scoped Queries

When a peel has basket_scope configured in its settings, every basket() call is automatically filtered. The scope expression is compound-ANDed with the target you provide:

# Template writes:
basket("*", "default_ipv4")

# With basket_scope = "G@cluster_name:cluster-a", executes as:
basket("* and G@cluster_name:cluster-a", "default_ipv4")

This means templates do not need to be cluster-aware -- they can use broad targets like "*" or "role:webserver", and the scope narrows the results automatically. The same template produces cluster-specific output on each peel:

Cluster-scoped /etc/hosts-internal
/etc/hosts-internal:
  file.managed:
    - content: |
        # Auto-generated by Zester basket
        # All peels in the cluster
        {% for entry in basket("*", "default_ipv4") %}
        {{ entry.value }}  {{ entry.peel_id }}
        {% endfor %}

On web-01 (cluster-a), this produces entries for web-01, web-02, and db-01. On web-03 (cluster-b), it produces entries for web-03 and db-02.


Use Cases

Load Balancer Configuration

Discover backend server IPs and generate HAProxy or Nginx configs:

HAProxy backend discovery
haproxy_backends:
  file.managed:
    - path: /etc/haproxy/haproxy.cfg
    - content: |
        frontend http
          bind *:80
          default_backend webservers

        backend webservers
          balance roundrobin
        {% for entry in basket("role:webserver", "network.ip_addrs") %}
          server {{ entry.peel_id }} {{ entry.value }}:8080 check
        {% endfor %}

Firewall Rules

Build allow lists from peer addresses:

iptables allow list
firewall_allow:
  file.managed:
    - path: /etc/iptables/peers.rules
    - content: |
        # Auto-generated peer allow list
        {% for entry in basket("role:app", "network.ip_addrs") %}
        -A INPUT -s {{ entry.value }}/32 -p tcp --dport 5432 -j ACCEPT
        {% endfor %}
        -A INPUT -p tcp --dport 5432 -j DROP

Monitoring and Metrics Discovery

Auto-discover scrape targets for Prometheus:

Prometheus target discovery
prometheus_targets:
  file.managed:
    - path: /etc/prometheus/targets.json
    - content: |
        [
        {% for entry in basket("*", "monitoring.endpoint") %}
          {
            "targets": ["{{ entry.value }}"],
            "labels": {"peel": "{{ entry.peel_id }}"}
          }{{ "," if not loop.last }}
        {% endfor %}
        ]

DNS Record Generation

Auto-generate DNS zone entries from peer data:

DNS zone file
dns_zone:
  file.managed:
    - path: /etc/bind/db.internal
    - content: |
        $TTL 300
        {% for entry in basket("*", "network.ip_addrs") %}
        {{ entry.peel_id }}    IN A    {{ entry.value }}
        {% endfor %}

NATS KV Direct Access

For advanced use cases, basket data can be read directly from the NATS KV bucket.

Bucket Details

PropertyValue
Bucket namebasket
EncodingMessagePack
History1 revision per key
TTLNone (persistent)
Key format<peel-id>.<function>

Reading with NATS CLI

# List all basket keys
$ nats kv ls basket

# Get raw value (MessagePack binary)
$ nats kv get basket web-01.network.ip_addrs

Reading with Go

kv, err := bus.GetBucket(ctx, js, bus.BucketBasket)
if err != nil {
    log.Fatal(err)
}

var value string
if err := bus.KVGet(ctx, kv, "web-01.network.ip_addrs", &value); err != nil {
    log.Fatal(err)
}

fmt.Println(value)
// Output: 10.0.1.50

Watching for Changes

The master or any consumer can watch for basket updates in real time:

watcher, err := kv.WatchAll(ctx)
if err != nil {
    log.Fatal(err)
}

for entry := range watcher.Updates() {
    if entry == nil {
        continue // initial values loaded
    }
    fmt.Printf("Basket updated: %s\n", entry.Key())
}

No polling required

NATS KV watch delivers updates as they happen. Unlike Salt Mine where the master must poll for changes, basket consumers are notified immediately when a peel publishes new data.


Staleness and Freshness

Basket data is only as fresh as the last refresh interval. Consider these factors:

FactorImpact
Peel is downBasket data goes stale; last-known value persists in KV
Peel restartsBasket data refreshes immediately on startup
Network partitionPeel cannot update KV; stale data persists until reconnection
Function errorFailed function execution does not update KV; previous value persists

Staleness detection

Since the basket KV bucket has no TTL, stale data from disconnected peels persists indefinitely. Monitor peel connectivity to identify stale basket data, or include a timestamp in custom basket values for application-level freshness checks.


Comparison with Salt Mine

FeatureSalt mine.get()Zester basket()
Template functionsalt['mine.get']('target', 'function')basket("target", "function")
Return formatDict of {minion_id: value}List of {peel_id, value} maps
TargetingGlob only (with tgt_type for others)Glob, regex, fact, compound, list -- auto-detected
Storage backendMaster process memoryNATS JetStream KV (persistent)
AvailabilityLost on master restartSurvives restarts -- durable in NATS
Update latencyPolling interval (default 60m)Configurable per-function, instant via KV watch
CLI accesssalt-run mine.getzester basket get

Migrating from Salt Mine

Replace salt['mine.get']('target', 'function') calls in your Jinja templates with basket("target", "function"). The return format differs -- Salt returns a dictionary keyed by minion ID, while Zester returns a list of maps with peel_id and value keys. Update your loop syntax accordingly:

Salt:

{% for minion, ip in salt['mine.get']('role:webserver', 'network.ip_addrs', tgt_type='grain').items() %}
  server {{ minion }} {{ ip }}:8080;
{% endfor %}

Zester:

{% for entry in basket("role:webserver", "network.ip_addrs") %}
  server {{ entry.peel_id }} {{ entry.value }}:8080;
{% endfor %}

On this page