zester
GuidesFacts

Built-in Collectors

Zester ships with seven built-in fact collectors. Each collector gathers information about one domain of the system and stores it under its own namespace in the facts map.

All collectors implement the Collector interface defined in pkg/facts/collector.go:

type Collector interface {
    Name() string
    Collect(ctx context.Context) (map[string]any, error)
    Interval() time.Duration
}

If Interval() returns zero, the collector runs once at startup. A non-zero interval causes the Facts Manager to re-run the collector on that schedule and republish updated facts.


OS Collector

Namespace: os Refresh: Once at startup Source: pkg/facts/collectors/os.go

Collects operating system and kernel metadata using gopsutil with a fallback to Go's runtime package.

Fields

KeyTypeDescription
os.namestringOperating system name (e.g., "linux", "darwin", "windows")
os.familystringPlatform family (e.g., "debian", "rhel", "standalone")
os.platformstringSpecific distribution (e.g., "ubuntu", "centos", "arch")
os.platform_versionstringDistribution version (e.g., "22.04", "9.3")
os.archstringCPU architecture from Go runtime (e.g., "amd64", "arm64")
os.kernelstringKernel version string (e.g., "6.1.0-18-amd64")
os.kernel_archstringKernel architecture (e.g., "x86_64", "aarch64")
os.uptimeuint64System uptime in seconds
os.boot_timeuint64Unix timestamp of last boot

Example Output

{
  "os": {
    "name": "linux",
    "family": "debian",
    "platform": "ubuntu",
    "platform_version": "22.04",
    "arch": "amd64",
    "kernel": "6.1.0-18-amd64",
    "kernel_arch": "x86_64",
    "uptime": 864523,
    "boot_time": 1707321600
  }
}

Fallback behavior

If gopsutil cannot read host information (e.g., in a minimal container), the OS collector falls back to Go's runtime package and returns only name, arch, and family.


Network Collector

Namespace: network Refresh: Once at startup Source: pkg/facts/collectors/network.go

Collects hostname, FQDN, IP addresses, and network interface details.

Fields

KeyTypeDescription
network.hostnamestringShort hostname from os.Hostname()
network.fqdnstringFully qualified domain name
network.ipv4[]stringAll IPv4 addresses across all interfaces
network.ipv6[]stringAll IPv6 addresses across all interfaces
network.interfaces[]objectList of network interface objects
network.interfaces[].namestringInterface name (e.g., "eth0", "ens5")
network.interfaces[].macstringMAC / hardware address
network.interfaces[].flags[]stringInterface flags (e.g., "up", "broadcast")
network.interfaces[].addrs[]stringCIDR addresses assigned to this interface

Example Output

{
  "network": {
    "hostname": "web-01",
    "fqdn": "web-01.prod.example.com",
    "ipv4": ["10.0.1.15", "172.17.0.1"],
    "ipv6": ["fe80::1", "::1"],
    "interfaces": [
      {
        "name": "eth0",
        "mac": "02:42:ac:11:00:02",
        "flags": ["up", "broadcast", "multicast"],
        "addrs": ["10.0.1.15/24"]
      },
      {
        "name": "lo",
        "mac": "",
        "flags": ["up", "loopback"],
        "addrs": ["127.0.0.1/8", "::1/128"]
      },
      {
        "name": "docker0",
        "mac": "02:42:d8:5a:3b:1c",
        "flags": ["up", "broadcast", "multicast"],
        "addrs": ["172.17.0.1/16"]
      }
    ]
  }
}

DefaultIP Collector

Namespace: (root) Refresh: Once at startup Source: pkg/facts/collectors/defaultip.go

Determines the peel's default outgoing IPv4 address using the UDP dial trick: a UDP socket is opened to a non-routable address (TEST-NET-1, RFC 5737 — 192.0.2.1:9) without sending any data, and the local address is read from the socket. The OS kernel selects the source address according to the routing table, making this a reliable way to detect the primary outbound interface's address even on multi-homed hosts.

The result is merged at the root level of the facts map (like Custom facts) so it appears as facts.default_ipv4, not under a nested namespace.

Fields

KeyTypeDescription
default_ipv4stringPrimary outbound IPv4 address (e.g., "10.0.1.15")

Example Output

{
  "default_ipv4": "10.0.1.15"
}

Empty on failure

If the UDP dial fails (e.g., in a network-isolated container), the collector returns an empty map. No error is logged and no default_ipv4 key is set.


CPU Collector

Namespace: cpu Refresh: Once at startup Source: pkg/facts/collectors/cpu.go

Collects CPU model, vendor, clock speed, and core counts.

Fields

KeyTypeDescription
cpu.countintLogical CPU count (from Go runtime.NumCPU())
cpu.logical_countintLogical CPU count (same as count)
cpu.physical_countintPhysical core count (excludes hyperthreading)
cpu.modelstringCPU model name (e.g., "Intel(R) Xeon(R) Platinum 8375C")
cpu.vendorstringCPU vendor ID (e.g., "GenuineIntel", "AuthenticAMD")
cpu.mhzfloat64Clock speed in MHz
cpu.cache_sizeint32L2 cache size in KB

Example Output

{
  "cpu": {
    "count": 8,
    "logical_count": 8,
    "physical_count": 4,
    "model": "Intel(R) Xeon(R) Platinum 8375C CPU @ 2.90GHz",
    "vendor": "GenuineIntel",
    "mhz": 2900.0,
    "cache_size": 54272
  }
}

Memory Collector

Namespace: memory Refresh: Every 5 minutes Source: pkg/facts/collectors/memory.go

Collects RAM and swap usage statistics. This collector runs on a 5-minute interval because memory usage changes over time.

Fields

KeyTypeDescription
memory.totaluint64Total physical memory in bytes
memory.availableuint64Available memory in bytes
memory.useduint64Used memory in bytes
memory.used_percentfloat64Memory usage as a percentage (0-100)
memory.freeuint64Free memory in bytes
memory.swap_totaluint64Total swap space in bytes
memory.swap_useduint64Used swap space in bytes
memory.swap_freeuint64Free swap space in bytes

Example Output

{
  "memory": {
    "total": 17179869184,
    "available": 8589934592,
    "used": 7516192768,
    "used_percent": 43.75,
    "free": 1073741824,
    "swap_total": 4294967296,
    "swap_used": 0,
    "swap_free": 4294967296
  }
}

Using memory facts for capacity planning

Memory facts update every 5 minutes. Use them in settings templates to dynamically size application buffers:

# /srv/zester/settings/postgres/memory.zy
postgres:
  shared_buffers: {{ (facts.memory.total * 0.25) | int }}
  effective_cache_size: {{ (facts.memory.total * 0.75) | int }}

Disk Collector

Namespace: disk Refresh: Every 5 minutes Source: pkg/facts/collectors/disk.go

Collects mount point information and disk usage statistics. Only real partitions are included (pseudo-filesystems are excluded).

Fields

KeyTypeDescription
disk.mounts[]objectList of mount point objects
disk.mounts[].devicestringBlock device path (e.g., "/dev/sda1")
disk.mounts[].mountpointstringMount path (e.g., "/", "/data")
disk.mounts[].fstypestringFilesystem type (e.g., "ext4", "xfs")
disk.mounts[].opts[]stringMount options
disk.mounts[].totaluint64Total space in bytes
disk.mounts[].useduint64Used space in bytes
disk.mounts[].freeuint64Free space in bytes
disk.mounts[].used_percentfloat64Disk usage as a percentage (0-100)

Example Output

{
  "disk": {
    "mounts": [
      {
        "device": "/dev/sda1",
        "mountpoint": "/",
        "fstype": "ext4",
        "opts": ["rw", "relatime"],
        "total": 107374182400,
        "used": 32212254720,
        "free": 75161927680,
        "used_percent": 30.0
      },
      {
        "device": "/dev/sdb1",
        "mountpoint": "/data",
        "fstype": "xfs",
        "opts": ["rw", "noatime"],
        "total": 1099511627776,
        "used": 549755813888,
        "free": 549755813888,
        "used_percent": 50.0
      }
    ]
  }
}

Custom Collector

Merge: Root level (no namespace) Refresh: Every 30 seconds Source: pkg/facts/collectors/custom.go

Reads persistent custom facts from a YAML file on disk. This is the Zester equivalent of Salt's /etc/salt/grains. Custom facts are merged at the top level of the facts map -- a key role: webserver in the file becomes facts.role, not facts.custom.role. Operators create or edit the file directly on the peel; changes are picked up automatically within 30 seconds.

File Location

/etc/zester/facts

The file is plain YAML. If the file does not exist, the collector returns an empty map (no error).

Example File

# /etc/zester/facts
roles:
  - webserver
  - proxy
datacenter: us-east-1
tier: production
cluster:
  name: web-cluster-01
  shard: 2

Example Output

After the Custom collector runs, its facts appear at the top level of the facts map (not under a custom namespace):

{
  "roles": ["webserver", "proxy"],
  "datacenter": "us-east-1",
  "tier": "production",
  "cluster": {
    "name": "web-cluster-01",
    "shard": 2
  }
}

These sit alongside the built-in namespaces (os, cpu, etc.) -- just like Salt's /etc/salt/grains.

Querying Custom Facts

# Get a custom fact directly (no namespace prefix)
zester 'web-01' facts.get datacenter

# Get a nested custom fact
zester 'web-01' facts.get cluster.name

# Set a custom fact from the CLI (writes to /etc/zester/facts)
zester 'web-01' facts.set datacenter us-west-2

In Templates

{% if "webserver" in facts.roles %}
nginx:
  worker_processes: {{ facts.cpu.count }}
{% endif %}

datacenter: {{ facts.datacenter }}

Configurable path

The Custom collector accepts a Path field. If empty (the default), it reads from /etc/zester/facts. Custom paths can be set when registering the collector in cmd/zester-peel/main.go.


Complete Facts Example

When all seven collectors run, a peel produces a combined facts map like this:

{
  "os": {
    "name": "linux",
    "family": "debian",
    "platform": "ubuntu",
    "platform_version": "22.04",
    "arch": "amd64",
    "kernel": "6.1.0-18-amd64",
    "kernel_arch": "x86_64",
    "uptime": 864523,
    "boot_time": 1707321600
  },
  "network": {
    "hostname": "web-01",
    "fqdn": "web-01.prod.example.com",
    "ipv4": ["10.0.1.15"],
    "ipv6": ["fe80::1"],
    "interfaces": [
      {
        "name": "eth0",
        "mac": "02:42:ac:11:00:02",
        "flags": ["up", "broadcast", "multicast"],
        "addrs": ["10.0.1.15/24"]
      }
    ]
  },
  "cpu": {
    "count": 8,
    "logical_count": 8,
    "physical_count": 4,
    "model": "Intel(R) Xeon(R) Platinum 8375C CPU @ 2.90GHz",
    "vendor": "GenuineIntel",
    "mhz": 2900.0,
    "cache_size": 54272
  },
  "memory": {
    "total": 17179869184,
    "available": 8589934592,
    "used": 7516192768,
    "used_percent": 43.75,
    "free": 1073741824,
    "swap_total": 4294967296,
    "swap_used": 0,
    "swap_free": 4294967296
  },
  "disk": {
    "mounts": [
      {
        "device": "/dev/sda1",
        "mountpoint": "/",
        "fstype": "ext4",
        "opts": ["rw", "relatime"],
        "total": 107374182400,
        "used": 32212254720,
        "free": 75161927680,
        "used_percent": 30.0
      }
    ]
  },
  "default_ipv4": "10.0.1.15",
  "roles": ["webserver"],
  "datacenter": "us-east-1",
  "tier": "production"
}

Custom facts are optional

Custom facts from /etc/zester/facts are merged at the top level. If the file is absent, no custom keys appear. Keys like roles, datacenter, and tier above come from the Custom collector.

On this page