Skip to content

Puppet⚓︎

Jobs⚓︎

Run the Puppet agent on a remote node⚓︎

puppet job run -n <certificate>

Facts⚓︎

Resolving custom Facts with facter⚓︎

Place fact file in a directory such as /tmp/facts

FACTERLIB=/tmp/facts facter -p myfact

Tasks⚓︎

Return Task output only⚓︎

The below uses jq to parse the JSON output of a Task, to return only the result.

$ puppet task run --format json service action=status name=puppet -n <certificate> | jq -r '.items[].results'
{
  "status": "running",
  "enabled": "true"
}

$ puppet task run --format json service action=status name=puppet -n <certificate> | jq -r '.items[].results[]'
running
true

Start a system service on a remote node⚓︎

puppet task run service name=sshd action=enable -n <certificate>

Puppet Query Language (PQL)⚓︎

Limit query results⚓︎

puppet query 'inventory [environment]{ facts.operatingsystem = "AIX" limit 10}'

Puppet environments⚓︎

List all the different Puppet environments⚓︎

puppet query 'inventory[environment]{ }' | jq -r '.[] | .environment' | sort | uniq

List all the different Puppet environments based upon a fact value⚓︎

$ puppet query 'inventory[environment]{ facts.operatingsystem = "AIX" }' | jq -r '.[] | .environment' | sort | uniq
mwark
goats

List hosts in a specific Puppet environment⚓︎

$ puppet query 'inventory[certname]{ environment = "mwark" }' | jq -r '.[] | .certname'
<certname1>
<certname2>
<certname3>

Count of hosts in each Puppet environments⚓︎

$ puppet query -k 'inventory[environment]{ }' | jq -r '.[] | .environment' | sort | uniq | while read -r env; do
  printf "%-5s %s\n" $(puppet query -k "inventory[certname]{ environment = \""${env}"\" }" | jq -r '.[] | .certname' | wc -l) "${env}"
done | sort -nrk1
12    mwark
3     production
1     goats

Miscellaneous⚓︎

Node certificate⚓︎

Clean⚓︎

Clean node certificates when a client has been rebuilt, or if a certificate has expired and needs to be renewed.

puppetserver ca clean --certname <certificate>
puppet ssl clean --localca

After the client certificate has been cleaned on the server and client, you can bootstrap the SSL process.

puppet ssl bootstrap

Show and verify⚓︎

Show the client certificate and verify it.

puppet ssl verify
puppet ssl show

You an also use openssl to read the certificate.

openssl x509 -in /etc/puppetlabs/puppet/ssl/certs/client_certificate.pem -noout -text

noop mode⚓︎

A Puppet agent in noop will report on the changes it would make. You can enable/disable noop from either the client, or via the puppet_conf Task.

Enable
puppet config set --section agent noop true
Disable
puppet config delete --section agent noop
Enable
puppet task run puppet_conf -p '{"action":"set", "setting":"noop", "value":"true", "section":"agent"}' -n <certificate>
Disable
puppet task run puppet_conf -p '{"action":"delete", "setting":"noop", "section":"agent"}' -n <certificate>

pxp-agent debug logging⚓︎

Update /etc/puppetlabs/pxp-agent/pxp-agent.conf and set loglevel to debug. Log files written /var/log/puppetlabs/pxp-agent/pxp-agent.log.

Puppet manages pxp-agent.conf, so changes will be reverted during the next agent run.

Expire a specific Puppet environment⚓︎

# environment=mwark
# curl --include \
  --cert $(puppet config print hostcert) \
  --key $(puppet config print hostprivkey) \
  --cacert $(puppet config print localcacert) \
  -X DELETE "https://$(puppet config print server):8140/puppet-admin-api/v1/environment-cache?environment=${environment}"

A successful request to this endpoint will return an 'HTTP 204: No Content'. The response body will be empty.

Show free puppetserver JRuby processes⚓︎

# curl -s -k https://localhost:8140/status/v1/services?level=debug | python3 -m json.tool | grep "jrubies"
                    "average-free-jrubies": 20.16288164135997,
                    "average-requested-jrubies": 6.113589418575067,
                    "num-free-jrubies": 23,
                    "num-jrubies": 30,
                    "average-free-jrubies": 20.16288164135997,
                    "average-requested-jrubies": 6.113589418575067,
                    "num-free-jrubies": 23,
                    "num-jrubies": 30,

Query RBAC logs⚓︎

Excel dump of all log data.

curl --cacert /etc/puppetlabs/puppet/ssl/certs/ca.pem -X GET -H "X-Authentication: $(puppet-access show)" -G "https://$(puppet config print server):4433/activity-api/v2/events.csv"

Removing the .csv will give you JSON format.

curl --cacert /etc/puppetlabs/puppet/ssl/certs/ca.pem -X GET -H "X-Authentication: $(puppet-access show)" -G "https://$(puppet config print server):4433/activity-api/v2/events"

Scripts⚓︎

Get facts for a host⚓︎

Script that uses puppet query to get facts for a single host.

$ facts_for_host
Usage:
  ./facts_for_host <certname> [fact_path] [--color=auto|always|never]

Examples:
  ./facts_for_host <certname>
    → pretty prints all facts for '<certname>'

  ./facts_for_host <certname> os
    → pretty prints the 'os' nested fact

  ./facts_for_host <certname> os.release.major
    → prints the raw value without quotes or color

Color:
  --color=auto   : enable colors when stdout is a TTY (default)
  --color=always : force color
  --color=never  : disable color
  Environment:
    NO_COLOR     : if set, disables color
Example on a server named gandalf
$ facts_for_host gandalf
<< entire fact output for gandalf >>
$ facts_for_host gandalf os
{
  "architecture": "PowerPC_POWER10",
  "family": "AIX",
  "hardware": "IBM,9080-HEX",
  "name": "AIX",
  "release": {
    "full": "7300-03-01-2520",
    "major": "7300"
  }
}
$ facts_for_host gandalf os.release.major
7300
facts_for_host
#!/opt/puppetlabs/puppet/bin/ruby

# frozen_string_literal: true

require 'json'
require 'open3'

USAGE = <<~USAGE
  Usage:
    ./facts_for_host <certname> [fact_path] [--color=auto|always|never]

  Examples:
    ./facts_for_host <certname>
      → pretty prints all facts for '<certname>'

    ./facts_for_host <certname> os
      → pretty prints the 'os' nested fact

    ./facts_for_host <certname> os.release.major
      → prints the raw value without quotes or color

  Color:
    --color=auto   : enable colors when stdout is a TTY (default)
    --color=always : force color
    --color=never  : disable color
    Environment:
      NO_COLOR     : if set, disables color
USAGE

def die(msg, code = 1)
  warn(msg)
  exit(code)
end

# --- Color handling (ANSI) ---
module Color
  ESC   = "\e["
  RESET = "#{ESC}0m"

  # Palette inspired by jq:
  PALETTE = {
    key:      "#{ESC}1;36m",  # bold cyan
    string:   "#{ESC}32m",    # green
    number:   "#{ESC}35m",    # magenta
    boolean:  "#{ESC}33m",    # yellow
    null:     "#{ESC}90m",    # bright black (gray)
    punct:    "#{ESC}0m",     # default
  }

  def self.enabled?(force = nil)
    return false if ENV.key?('NO_COLOR')
    case force
    when 'always' then true
    when 'never'  then false
    when 'auto', nil
      $stdout.tty?
    else
      $stdout.tty?
    end
  end

  def self.wrap(s, role, enabled)
    return s unless enabled
    code = PALETTE[role] || PALETTE[:punct]
    "#{code}#{s}#{RESET}"
  end
end

def parse_args(argv)
  color_mode = 'auto'
  args = []
  argv.each do |a|
    if a =~ /^--color=(auto|always|never)$/
      color_mode = a.split('=')[1]
    else
      args << a
    end
  end

  if args.length < 1
    puts USAGE
    exit(1)
  end

  certname  = args[0]
  fact_path = args[1] # optional
  [certname, fact_path, color_mode]
end

def fetch_facts(certname)
  query = %Q{facts[certname,name,value] { certname = "#{certname}" }}
  stdout, stderr, status = Open3.capture3('puppet', 'query', query)
  die("puppet query failed:\n#{stderr.strip}", 2) unless status.success?

  rows = JSON.parse(stdout)
  facts = {}
  rows.each do |row|
    facts[row['name']] = row['value']
  end
  facts
rescue Errno::ENOENT
  die("puppet command not found in PATH.", 3)
rescue JSON::ParserError => e
  die("JSON parse error: #{e.message}", 4)
end

def deep_fetch(obj, path)
  return obj if path.nil? || path.empty?

  current = obj
  path.split('.').each do |seg|
    if current.is_a?(Hash)
      return :not_found unless current.key?(seg)
      current = current[seg]
    elsif current.is_a?(Array)
      return :not_found unless seg =~ /^\d+$/
      idx = seg.to_i
      return :not_found if idx < 0 || idx >= current.length
      current = current[idx]
    else
      return :not_found
    end
  end
  current
end

# Pretty printer with color
def colorized_pretty(obj, indent = 0, enabled: true)
  sp = '  ' * indent
  case obj
  when Hash
    return Color.wrap("{}", :punct, enabled) if obj.empty?
    parts = ["{"]
    keys = obj.keys
    keys.each_with_index do |k, i|
      key_str = Color.wrap(%("#{k}"), :key, enabled)
      val_str = colorized_pretty(obj[k], indent + 1, enabled: enabled)
      parts << "#{sp}  #{key_str}: #{val_str}#{i == keys.length - 1 ? '' : ','}"
    end
    parts << "#{sp}}"
    parts.join("\n")
  when Array
    return Color.wrap("[]", :punct, enabled) if obj.empty?
    parts = ["["]
    obj.each_with_index do |v, i|
      val_str = colorized_pretty(v, indent + 1, enabled: enabled)
      parts << "#{sp}  #{val_str}#{i == obj.length - 1 ? '' : ','}"
    end
    parts << "#{sp}]"
    parts.join("\n")
  when String
    Color.wrap(%("#{obj}"), :string, enabled)
  when Numeric
    Color.wrap(obj.to_s, :number, enabled)
  when TrueClass, FalseClass
    Color.wrap(obj.to_s, :boolean, enabled)
  when NilClass
    Color.wrap('null', :null, enabled)
  else
    # Fallback: JSON-encode unknown types
    s = JSON.generate(obj)
    Color.wrap(s, :string, enabled)
  end
end

def primitive?(v)
  v.is_a?(String) || v.is_a?(Numeric) || v == true || v == false || v.nil?
end

def print_value(value, color_mode, raw_leaf: false)
  # If it's a leaf selection and primitive → print raw (no quotes, no color)
  if raw_leaf && primitive?(value)
    # nil as 'null' to remain JSON-compatible for scripting
    puts(value.nil? ? 'null' : value)
    return
  end

  # Otherwise, use colorized pretty printer
  enabled = Color.enabled?(color_mode)
  puts colorized_pretty(value, 0, enabled: enabled)
end

# --- main ---
certname, fact_path, color_mode = parse_args(ARGV)
facts = fetch_facts(certname)
die("No facts found for '#{certname}'.", 5) if facts.empty?

if fact_path.nil?
  # Pretty print entire facts (color)
  print_value(facts, color_mode, raw_leaf: false)
else
  value = deep_fetch(facts, fact_path)
  if value == :not_found
    hint = facts.keys.sort.join(', ')
    die("Fact path '#{fact_path}' not found. Top-level keys include: #{hint}", 6)
  else
    # If the selection resolves to a primitive, print raw (no quotes/no color)
    print_value(value, color_mode, raw_leaf: true)
  end
end

Get facts from all hosts⚓︎

Script that uses puppet query to get facts for all hosts.

$ facts_for_all_hosts
Usage:
  facts_for_hosts <facts> [--tsv]

Examples:
  # Default CSV output

  facts_for_hosts os.release.major,networking.ip

  # TSV output
  facts_for_hosts os.release.major,networking.ip --tsv

Notes:
  - Facts can be top-level (e.g., 'os') or dot-paths (e.g., 'os.release.major').
  - Non-primitive values (arrays/hashes) are serialized as JSON in the cell.
fact_for_all_hosts
#!/opt/puppetlabs/puppet/bin/ruby

# frozen_string_literal: true

require 'csv'
require 'json'

USAGE = <<~USAGE
  Usage:
    facts_for_hosts <facts> [--tsv]

  Examples:
    # Default CSV output

    facts_for_hosts os.release.major,networking.ip

    # TSV output
    facts_for_hosts os.release.major,networking.ip --tsv

  Notes:
    - Facts can be top-level (e.g., 'os') or dot-paths (e.g., 'os.release.major').
    - Non-primitive values (arrays/hashes) are serialized as JSON in the cell.
USAGE

def die(msg, code = 1)
  $stderr.puts(msg)
  exit(code)
end

# --- Parse arguments ---
if ARGV.empty?
  die(USAGE)
end

# Support a --tsv flag; default is CSV
tsv = ARGV.any? { |a| a == '--tsv' }
args = ARGV.reject { |a| a == '--tsv' }

# Each arg can itself be a CSV string; use CSV.parse_line to properly split
facts = args.flat_map { |a| CSV.parse_line(a) || [] }.map(&:strip).reject(&:empty?)
die("No facts parsed from arguments.\n\n#{USAGE}") if facts.empty?

# We only need to query PuppetDB for the top-level fact names
facts_to_query = facts.map { |f| f.sub(/\..*/, '') }.uniq

# --- Fetch data via puppet-query ---
# Note: keeping your original command name 'puppet-query' here.
query = "facts[certname,name,value]{name in #{facts_to_query.to_json} }"
raw = `puppet-query '#{query}'`
if $?.exitstatus != 0 || raw.nil? || raw.empty?
  die("puppet-query failed or returned no data for query:\n#{query}", 2)
end

data = JSON.parse(raw)

# --- Massage into { certname => { fact_name => value } } ---
results = Hash.new { |h, k| h[k] = {} }
data.each do |factinfo|
  cert = factinfo['certname']
  name = factinfo['name']
  val  = factinfo['value']
  results[cert][name] = val
end

# --- Helpers ---
def primitive?(v)
  v.is_a?(String) || v.is_a?(Numeric) || v == true || v == false || v.nil?
end

def extract_value(values_hash, fact_path)
  if fact_path.include?('.')
    top = fact_path.split('.')[0]
    rest = fact_path.split('.')[1..-1]
    v = values_hash[top]
    begin
      # Use dig for nested traversal; works on Hash/Array with appropriate keys/indexes
      v = v.dig(*rest) unless v.nil?
    rescue
      v = nil
    end
    v
  else
    values_hash[fact_path]
  end
end

def cell_value_for_output(v)
  # For empty hashes (meaning "not present" in the original script), return nil
  return nil if v == {}

  # For primitive values, return as-is (CSV/TSV library will quote strings if needed)
  return v if primitive?(v)

  # For arrays/hashes, serialize as compact JSON in the cell
  JSON.generate(v)
end

# --- Output (CSV by default; TSV if flag provided) ---
col_sep = tsv ? "\t" : ","

# Header
header = ['certname'] + facts
puts CSV.generate_line(header, col_sep: col_sep)

# Rows
results.each do |certname, values|
  row = [certname] + facts.map { |f| cell_value_for_output(extract_value(values, f)) }
  puts CSV.generate_line(row, col_sep: col_sep)
end

Hosts assigned to a class⚓︎

Lists hosts that contain a specific class. Each class name must start with a capital letter (e.g. Parent_class::Child_class)

Example
$ hosts_with_class Users::Local_users
<certname1>
<certname2>
<certname3>
hosts_with_class
#!/bin/ksh

set -ue

if (( $# != 1 ))
then
    print -u2 "Usage: $0 Classname"
    exit 1
fi

class="$1"

if [[ $class != [A-Z]* ]]
then
    print -u2 "Class name must be capitalised"
    exit 1
fi

puppet-query 'resources[certname] {
              type = "Class"
              and title = "'$class'"
              group by certname }' |
    jq -r '.[] | .certname'

Watch Puppet server code deployments⚓︎

$ watch /home/kristian/deploy_status.rb
Every 2.0s: /home/kristian/deploy_status.rb

deploys-status:
    0 queued
    0 deploying
    0 new
    0 failed
file-sync-storage-status:
    deployed: 6
file-sync-client-status: all-synced: true
    true : puppetserver
    true : puppetserver-orchestrator
    true : compiler1
    true : compiler2
deploy_status.rb
#!/opt/puppetlabs/puppet/bin/ruby

require 'net/http'
require 'uri'
require 'openssl'
require 'json'
require 'pp'

token = File.read(".puppetlabs/token").chomp # (1)!

uri = URI.parse("https://puppetserver:8170/code-manager/v1/deploys/status") # (2)!
request = Net::HTTP::Get.new(uri)
request.content_type = "application/json"
request["X-Authentication"] = token

req_options = {
  use_ssl: uri.scheme == "https",
  verify_mode: OpenSSL::SSL::VERIFY_NONE,
}

response = Net::HTTP.start(uri.hostname, uri.port, req_options) do |http|
  http.request(request)
end

if response.code != "200"
  puts response.to_s
  puts response.body
  exit 1
end
res = JSON.parse(response.body)

puts "deploys-status: "
res['deploys-status'].each_pair do |k,v|
  count = res['deploys-status'][k].length
  puts "%5d %s" % [count, k]
  printcount = k == "failed" ? 7 : 5
  v.take(printcount).each do |e|
    if k == "failed"
      timestamp = e['queued-at'][11,8]
      # pp e
      message = e['error']['msg'].tr("\n"," ").sub(/.*-> /,'')
      puts "        #{e['environment']} @#{timestamp}: #{message}"
    else
      puts "        #{e['environment']}"
    end
  end
end

puts "file-sync-storage-status: "
res['file-sync-storage-status'].each_pair do |k,v|
  puts "    #{k}: #{res['file-sync-storage-status'][k].length}"
end


puts "file-sync-client-status: all-synced: #{res['file-sync-client-status']['all-synced']}"
res['file-sync-client-status']['file-sync-clients'].keys.sort.each do |server|
  status = res['file-sync-client-status']['file-sync-clients'][server]['synced-with-file-sync-storage']
  puts "    %-5s: %s" % [ status, server ]
end
  1. The location of your puppet token file.
  2. URL of your Puppet server.