CoursesPuppetClasses & defined types

Classes & defined types

Group and parameterize resources.

Intermediate14 min · lesson 5 of 12

A class is a recipe card. Loose resources scattered across your manifests (the .pp files where you write Puppet code) are ingredients on separate index cards: a package here, a config file there, a service that has to stay running. A class gathers everything that makes up one coherent thing (time sync, the web tier, your SSH hardening) under a single name you can pull off the shelf whenever a machine needs it. A defined type is the other tool on that shelf. A class is a dish you cook once per machine. A defined type is a cookie cutter, and you stamp it as many times as you like: one per website, one per user account, one per firewall rule.

Confusing the two produces Puppet's least forgiving failure. Every time an agent checks in, the server compiles a catalog for that one machine: a finished shopping list of every resource it should have, plus the order things go in. Declare the same class twice the wrong way and the compile does not skip that class and carry on. It throws the entire catalog away. A machine with no fresh catalog keeps quietly enforcing the last one it was handed, while its runs still look healthy in the log. That is the failure worth learning on purpose, rather than meeting at 3am with a hardening change stuck in the pipeline.

Defining Is Not Declaring

Writing class ntp { ... } in a manifest does nothing to any machine. (ntp is the Network Time Protocol, the thing that keeps a server's clock honest.) That line is the recipe card going into the box: Puppet now knows what "ntp" means, and nothing else has happened. Declaring the class is the separate act of ordering the dish, and only then do its resources land in the machine's catalog.

The file layout is not decoration either. Puppet autoloads classes, meaning it works out the file from the name and reads it on demand. Class ntp comes from <module>/manifests/init.pp. Class ntp::config comes from <module>/manifests/config.pp. Name and path have to agree. When they do not, the compile stops with "Could not find class ::ntp for web01.acme.internal" and no node gets a catalog.

site-modules/ntp/manifests/init.pp
# Autoloading: class `ntp` MUST live in <module>/manifests/init.pp
# class `ntp::config` would live in <module>/manifests/config.pp
class ntp {
package { 'ntp':
ensure => installed,
}
file { '/etc/ntp.conf':
ensure => file,
owner => 'root',
group => 'root',
mode => '0644',
# `restrict default ... noquery` stops this box being used as an
# amplifier in a reflection attack. ntpd syntax, not chrony's.
content => "server pool.ntp.org iburst\nrestrict default nomodify notrap nopeer noquery\n",
require => Package['ntp'],
notify => Service['ntp'],
}
# Debian/Ubuntu names. Picking per-OS names from facts is the pp-facts lesson.
service { 'ntp':
ensure => 'running',
enable => true,
}
}

Three functions declare a class, and the differences carry real weight. include is the workhorse. It is idempotent (calling it a second time changes nothing), so ten different profiles can each include ntp and you still end up with exactly one NTP configuration. contain declares the class the same way, and additionally parks that class inside the class doing the containing. require declares the class and makes everything in the current class wait until that class has finished. Note that this is the require function, which is a different animal from the require metaparameter (an ordering attribute you can hang on any resource) that you write inside a resource body.

The containment difference is the one that bites in production. Treat a class as a cardboard box. Ordering arrows get drawn between boxes, and an arrow only drags along whatever is genuinely inside the box. If profile::app plainly includes profile::firewall, the firewall class's resources sit in the catalog but not inside profile::app's box, so an ordering arrow drawn against profile::app sails straight past them and your rules can land after the service they were meant to protect. contain puts them in the box. The same catch applies to require profile::pki: the wait only covers what profile::pki actually contains, so if pki itself only includes its subclasses, their resources escape the ordering entirely.

site-modules/profile/manifests/base.pp
# A "profile" is a small wrapper class from the roles-and-profiles
# pattern: one technology, wired up the way your org wants it.
class profile::base {
include ntp # idempotent: safe to call from ten places
contain profile::firewall # firewall's resources now sit INSIDE profile::base
require profile::pki # this class waits for whatever profile::pki contains
}
/tmp/once.pp
class demo {
notify { 'ntp configured': }
}
include demo
include demo # same class, declared three times
include demo
terminal
# Does a class evaluate once, or once per include?
$ puppet apply /tmp/once.pp
output
Notice: Compiled catalog for web01.acme.internal in environment production in 0.03 seconds
Notice: ntp configured
Notice: /Stage[main]/Demo/Notify[ntp configured]/message: defined 'message' as 'ntp configured'
Notice: Applied catalog in 0.01 seconds

One Notice line, not three. A class is a singleton, which is a fancy way of saying there can only ever be one of it: Puppet evaluates the body at most once per catalog no matter how many places declare it. That property is exactly what makes include safe to sprinkle everywhere. The resource path in that output, /Stage[main]/Demo/Notify[ntp configured], is Puppet telling you which container the resource came out of. Stage[main] is the single default stage every resource lives in unless you deliberately say otherwise, and you will read paths like this constantly in reports. The resource-like form, class { 'ntp': ... }, is the exception that hurts, and it gets its own section below.

Parameters Turn A Recipe Into A Template

A recipe card is far more use when the quantities are blanks you fill in. A class works the same way: the parts that vary become parameters. You declare them in the class signature with a data type and, wherever you can sensibly manage it, a default. Types get checked while the catalog compiles, on the server, before a single byte changes on the node. Array[String[1]] rejects an empty string in the list (the [1] means at least one character long). Enum['chrony','ntp'] rejects any word that is not one of those two. Stdlib::Absolutepath, a type alias shipped by the puppetlabs-stdlib module, rejects a relative path. A class whose parameters all carry defaults can be pulled in with a bare include, which is what you want.

Defaults are evaluated top to bottom, so a parameter further down the list can read one declared above it. That is how $conf below picks its path from $impl without you having to spell it out at every call site.

site-modules/ntp/manifests/init.pp
class ntp (
Enum['chrony','ntp'] $impl = 'ntp',
Array[String[1]] $servers = ['pool.ntp.org'],
Boolean $enabled = true,
# Defaults evaluate in order, so this one can read $impl above it
Stdlib::Absolutepath $conf = $impl ? {
'chrony' => '/etc/chrony/chrony.conf',
default => '/etc/ntp.conf',
},
) {
# `server <host> iburst` is valid in ntp.conf AND chrony.conf.
# Anything implementation-specific belongs in a template, not here.
$conf_body = $servers.map |String[1] $s| { "server ${s} iburst" }.join("\n")
package { $impl:
ensure => installed,
}
file { $conf:
ensure => file,
owner => 'root',
group => 'root',
mode => '0644',
content => "${conf_body}\n",
require => Package[$impl],
notify => Service[$impl],
}
service { $impl:
ensure => $enabled ? { true => 'running', default => 'stopped' },
enable => $enabled,
}
}
terminal
# Run on a host with the code deployed: `puppet apply` reads the
# production environment's modulepath, so `ntp` resolves normally.
# A String where an Array is required:
$ puppet apply -e "class { 'ntp': servers => '0.pool.ntp.org' }"
# A value outside the Enum:
$ puppet apply -e "class { 'ntp': impl => 'chronyd' }"
output
Error: Evaluation Error: Error while evaluating a Resource Statement, Class[Ntp]: parameter 'servers' expects an Array value, got String (line: 1, column: 1) on node web01.acme.internal
Error: Evaluation Error: Error while evaluating a Resource Statement, Class[Ntp]: parameter 'impl' expects a match for Enum['chrony', 'ntp'], got 'chronyd' (line: 1, column: 1) on node web01.acme.internal

Both runs died during compilation. Nothing touched the node. No package was half installed, no service was left stopped, no config file was written with a value that would have broken time sync fleet-wide. That is the whole argument for typing every parameter you write, and it is a security argument as much as a correctness one.

In Puppet, the string "false" is true
Only the literal false and undef are false in Puppet. Every string is true, including the empty string and the word "false". So if $permit_root has no type on it, and somebody writes profile::ssh::permit_root: "false" in YAML (a plain-text data format; those quotes turn the value into a string rather than a boolean), your if $permit_root check takes the true branch. You have now shipped the exact opposite of the policy you wrote, on every node, with no error anywhere. Declare Boolean $permit_root instead and that same YAML fails the compile with "parameter 'permit_root' expects a Boolean value, got String". Data types are the cheapest security control in a Puppet codebase.

Where The Values Come From

Code says what to do. Data says what to do it with. Declare a class and Puppet performs automatic parameter lookup: for every parameter you did not set explicitly, it asks Hiera for a key named <class>::<parameter>, so ntp::servers and ntp::enabled. Hiera is Puppet's built-in lookup system, a stack of YAML files searched from most specific to most general, like checking the sticky note on this one server's desk before checking the department handbook. This is the idiom the entire ecosystem is built on, include in code and values in data, and Hiera gets its own lesson.

Automatic lookup runs for both declaration styles, not only for include. What differs is that a value you hard-code in the resource-like form wins outright and can never be moved. class { 'ntp': servers => [...] } welds data into code and blocks anyone from overriding that value later for one environment or one node, which is why shared modules avoid it.

data/common.yaml
---
ntp::impl: 'chrony'
ntp::servers:
- '0.pool.ntp.org'
- '1.pool.ntp.org'
ntp::enabled: true
terminal
# Run on the Puppet primary server. --node pulls that node's real facts
# from PuppetDB, so the hierarchy interpolates exactly as it would
# during a genuine compile for that machine.
$ puppet lookup ntp::servers --node web01.acme.internal --explain
output
Searching for "ntp::servers"
Global Data Provider (hiera configuration version 5)
Using configuration "/etc/puppetlabs/puppet/hiera.yaml"
No such key: "ntp::servers"
Environment Data Provider (hiera configuration version 5)
Using configuration "/etc/puppetlabs/code/environments/production/hiera.yaml"
Hierarchy entry "Per-node data"
Path "/etc/puppetlabs/code/environments/production/data/nodes/web01.acme.internal.yaml"
Original path: "nodes/%{trusted.certname}.yaml"
Path not found
Hierarchy entry "Per-OS defaults"
Path "/etc/puppetlabs/code/environments/production/data/os/Debian.yaml"
Original path: "os/%{facts.os.family}.yaml"
Path not found
Hierarchy entry "Common data"
Path "/etc/puppetlabs/code/environments/production/data/common.yaml"
Original path: "common.yaml"
Found key: "ntp::servers" value: [
"0.pool.ntp.org",
"1.pool.ntp.org"
]

The precedence is fixed and worth memorizing. An explicit value in a resource-like declaration wins, and a value pushed in by a node classifier (the external system that decides which classes a node gets) counts as explicit. Below that sits automatic Hiera lookup. Below that, the default in the class signature. If none of the three produce a value, the compile fails with "Class[Ntp]: expects a value for parameter 'servers'". Add --compile to that lookup command when your hierarchy interpolates variables that only exist once a catalog is being built. This is the command that answers the question somebody asks you mid-incident: which file set this value, and at which layer?

Sit with the security property hiding in there. Anyone who can merge a one-line change to a YAML file changes what a class enforces on every node that includes it, with no edit to any manifest at all. An attacker who lands write access to your Hiera data never has to write a line of Puppet. Flipping one boolean opens a firewall or switches off a hardening class across the whole fleet. Data in a control repository (the Git repo holding your environments, your site manifest and your module list) deserves the same review, the same branch protection and the same audit trail as the code, because it is policy.

The Duplicate Declaration Trap

The two declaration styles are not symmetric, and that asymmetry is the most common self-inflicted outage in a Puppet codebase. include, contain and require are always safe against a class that is already declared, even one declared with class { }. The resource-like form throws the instant it evaluates a class anything else has already declared, like two people trying to sign the same name into the same slot in the visitor book. Two resource-like declarations always collide. A node classifier passing parameters that way, plus a profile that quietly includes the same class, is all it takes.

manifests/site.pp
node 'web01.acme.internal' {
include profile::base # profile::base already does `include ntp`
class { 'ntp': # second declaration of the SAME class
servers => ['10.0.0.10'],
}
}
terminal
$ puppet agent -t
output
Info: Using environment 'production'
Info: Retrieving pluginfacts
Info: Retrieving plugin
Info: Retrieving locales
Info: Loading facts
Error: Could not retrieve catalog from remote server: Error 500 on SERVER: Server Error: Evaluation Error: Error while evaluating a Resource Statement, Duplicate declaration: Class[Ntp] is already declared at (file: /etc/puppetlabs/code/environments/production/site-modules/profile/manifests/base.pp, line: 4); cannot redeclare (file: /etc/puppetlabs/code/environments/production/manifests/site.pp, line: 4, column: 3) on node web01.acme.internal
Notice: Using cached catalog from environment 'production'
Info: Applying configuration version '1753084921'
Notice: Applied catalog in 4.21 seconds

Read that output slowly, because it is built to lull you. One Error line, and then the run carries straight on and signs off with a cheerful "Applied catalog in 4.21 seconds". The node checks in again in half an hour and does the same thing. Nothing is down. Nothing crashed. And nothing you have merged since the breakage is being enforced anywhere.

A broken compile freezes a node, it does not stop it
usecacheonfailure defaults to true, so when the server cannot compile, the agent falls back to the last catalog it cached and applies that one instead. Your fleet keeps turning in runs that finish cleanly while running last week's policy, which is precisely the window an attacker wants after a hardening change lands. Watch the log lines, and watch the right one. "Caching catalog for web01.acme.internal" is an Info message, so at default verbosity you will never see it. "Notice: Using cached catalog from environment" prints at default level and only ever appears in the bad case. Alert on that string, and on compile errors in the Puppet Server log. Alerting on node silence catches nothing here, because the node is not silent. You can set usecacheonfailure = false in puppet.conf to fail loudly instead, but weigh it honestly: one bad merge then halts enforcement everywhere, including all the parts that were working fine.

Defined Types: One Cutter, Many Cookies

A class cannot help you when the same bundle of resources is needed twenty times over with different values, because it can only exist once per node. That is the job of a defined type. You write it with the define keyword, and every declaration stamps out a fresh instance with its own parameters. Inside the body, $title holds the string you gave that instance and $name is set to the same string, which is how each stamp gets its own identity. Parameter defaults can read $title as well, so a sensible path falls out of the name.

Class or defined type?
Does one node need this bundle of resources more than once?
No, once per node
Class
include it and let Hiera supply the parameters
Yes, once per thing
Defined type
one instance per vhost, user or rule; $title in every inner title
Once, but different per node
Still a class
same include everywhere, different Hiera data for that node
site-modules/profile/manifests/vhost.pp
# Autoloads from profile/manifests/vhost.pp
define profile::vhost (
Integer[1,65535] $port = 80,
Stdlib::Absolutepath $docroot = "/var/www/${title}",
String[1] $servername = $title,
Boolean $ssl_only = true,
) {
file { $docroot:
ensure => directory,
owner => 'www-data',
group => 'www-data',
mode => '0755',
}
# Every resource title carries $title, or two instances collide
file { "/etc/apache2/sites-available/${title}.conf":
ensure => file,
owner => 'root',
group => 'root',
mode => '0644',
content => epp('profile/vhost.conf.epp', { # EPP = Embedded Puppet template
'servername' => $servername,
'port' => $port,
'docroot' => $docroot,
'ssl_only' => $ssl_only,
}),
require => File[$docroot],
notify => Service['apache2'],
}
}

The absolute rule of defined types: every resource inside must have a title that varies with $title. Two instances that both declare File['/etc/apache2/sites-available/site.conf'] are two resources fighting over one identity in a single catalog, and Puppet aborts the compile exactly as it did for the duplicate class. Interpolate $title into every path, every config name, every derived resource.

One more trap sits in that manifest, and it is easy to miss. notify => Service['apache2'] points at a resource this defined type does not declare, so something else in the catalog has to declare it. Use the type on its own and the run fails complaining it cannot find Service[apache2] for that file. A defined type that reaches out to resources it does not own is only safe inside a profile that guarantees those resources exist, which is why the demo manifest below declares the service itself.

/tmp/vhosts.pp
# profile::vhost notifies Service['apache2'], so this manifest owns it
service { 'apache2':
ensure => 'running',
enable => true,
}
profile::vhost { 'example.com': }
profile::vhost { 'api.example.com':
port => 8080,
docroot => '/srv/api',
}
terminal
# Stamp two instances, but dry-run before touching anything
$ puppet apply --noop --show_diff /tmp/vhosts.pp
output
Notice: Compiled catalog for web01.acme.internal in environment production in 0.42 seconds
Notice: /Stage[main]/Main/Profile::Vhost[example.com]/File[/var/www/example.com]/ensure: current_value 'absent', should be 'directory' (noop)
Notice: /Stage[main]/Main/Profile::Vhost[example.com]/File[/etc/apache2/sites-available/example.com.conf]/ensure: current_value 'absent', should be 'file' (noop)
Notice: Profile::Vhost[example.com]: Would have triggered 'refresh' from 2 events
Notice: /Stage[main]/Main/Profile::Vhost[api.example.com]/File[/srv/api]/ensure: current_value 'absent', should be 'directory' (noop)
Notice: /Stage[main]/Main/Profile::Vhost[api.example.com]/File[/etc/apache2/sites-available/api.example.com.conf]/ensure: current_value 'absent', should be 'file' (noop)
Notice: Profile::Vhost[api.example.com]: Would have triggered 'refresh' from 2 events
Notice: /Stage[main]/Main/Service[apache2]: Would have triggered 'refresh' from 2 events
Notice: Class[Main]: Would have triggered 'refresh' from 4 events
Notice: Applied catalog in 0.09 seconds

Those containment paths are an audit trail. /Stage[main]/Main/Profile::Vhost[api.example.com]/File[/srv/api] names the exact instance that produced the change, which is what you want six weeks later when somebody asks who created a world-readable directory on a web server. Run every defined type change with --noop (no operation, a dry run that reports what would change and changes nothing) and --show_diff (print the line-by-line differences it would write into files) before it goes anywhere near production, because one mistake in a title expression multiplies by the number of instances.

Titles Usually Come From Data

In real code you rarely hand-write twenty declarations. You keep the list in Hiera and loop over it, which means the titles arrive from data, and data holds surprises.

site-modules/profile/manifests/web.pp
class profile::web (
Hash[String[1], Hash[String[1], Any]] $vhosts = {},
) {
# Every profile::vhost instance notifies Service['apache2'],
# so this class has to own it or the catalog has a dangling dependency
package { 'apache2':
ensure => installed,
}
service { 'apache2':
ensure => 'running',
enable => true,
require => Package['apache2'],
}
$vhosts.each |String[1] $name, Hash $opts| {
profile::vhost { $name:
* => $opts, # splat: hash keys become parameters
}
}
}
data/role/web.yaml
---
profile::web::vhosts:
'example.com':
port: 80
'api.example.com':
port: 8080
docroot: '/srv/api'

The splat operator (* => $opts) tips a hash out into parameters, so port: 8080 in YAML arrives as port => 8080 in the declaration. Compact and sharp-edged in the same breath. A key that is not a parameter of the defined type is a compile error. A title that already exists elsewhere in the catalog is the same duplicate declaration failure as before, except now it is triggered by whoever edited a data file rather than by anyone who touched code. (create_resources() is the older function that did this job. It still works, but the each loop above is what you write today.)

terminal
$ puppet apply /etc/puppetlabs/code/environments/production/manifests/site.pp
output
Error: Evaluation Error: Error while evaluating a Resource Statement, Duplicate declaration: Profile::Vhost[api.example.com] is already declared at (file: /etc/puppetlabs/code/environments/production/site-modules/profile/manifests/web.pp, line: 17); cannot redeclare (file: /etc/puppetlabs/code/environments/production/site-modules/profile/manifests/legacy_api.pp, line: 12, column: 3) on node web01.acme.internal

Nobody edited legacy_api.pp that week. Someone added one key to a YAML file, and a manifest written eighteen months earlier collided with it. So the last piece is CI (continuous integration, the checks that run automatically on every merge request). puppet parser validate is not enough on its own: it checks syntax, parsing the manifest without ever evaluating it, so it waves a duplicate declaration straight through and hands you false confidence. What catches these is a real compile, and the Puppet Development Kit (PDK, the toolkit that scaffolds and tests Puppet modules) gives you one. pdk new defined_type writes both the manifest and a matching spec file, and rspec-puppet's compile matcher builds an actual catalog from actual facts.

site-modules/profile/spec/classes/web_spec.rb
require 'spec_helper'
describe 'profile::web' do
on_supported_os.each do |os, os_facts|
context "on #{os}" do
let(:facts) { os_facts }
let(:params) do
{ 'vhosts' => { 'example.com' => { 'port' => 80 } } }
end
it { is_expected.to compile.with_all_deps }
it { is_expected.to contain_profile__vhost('example.com').with_port(80) }
end
end
end
terminal
# inside the profile module
$ pdk new defined_type vhost
$ pdk validate
$ pdk test unit
output
pdk (INFO): Creating '/home/eng/control-repo/site-modules/profile/manifests/vhost.pp' from template.
pdk (INFO): Creating '/home/eng/control-repo/site-modules/profile/spec/defines/vhost_spec.rb' from template.
pdk (INFO): Running all available validators...
pdk (INFO): Using Ruby 3.2.7
pdk (INFO): Using Puppet 8.10.0
pdk (INFO): Using Ruby 3.2.7
pdk (INFO): Using Puppet 8.10.0
[✔] Preparing to run the unit tests.
profile::web
on ubuntu-24.04-x86_64
is expected to compile into a catalogue without dependency cycles
is expected to contain Profile::Vhost[example.com] with port => 80
Finished in 2.14 seconds (files took 1.9 seconds to load)
2 examples, 0 failures

with_all_deps is the part that earns its place in the pipeline. It fails the build on a duplicate declaration, on a dependency that nothing declares, and on a dependency cycle: the three ways a class or a defined type poisons a catalog without anyone noticing until the agents do. None of this changes if you run OpenVox, the community fork that appeared after the Puppet 8 license change, because it is the same language, the same include, the same defined types and the same duplicate declaration error. Wire pdk validate and pdk test unit into every merge request for every role, and the class you break on a Friday afternoon breaks in a pull request instead of on four hundred nodes that will happily keep applying last week's policy.

Quick check
01Three different profiles each call include ntp on the same node. What ends up in the catalog?
Incorrect — a class is a singleton, evaluated at most once per catalog, so there is no last one to win.
Correct — include is idempotent, which is exactly why it is safe to call from anywhere in your codebase.
Incorrect — only the resource-like class { } form collides, and include never does, even against a class already declared that way.
Incorrect — nothing is merged, because the class body is only ever evaluated once.
02Class ntp declares Array[String[1]] $servers = ['pool.ntp.org']. Hiera's common.yaml sets ntp::servers: ['10.0.0.10']. A node's site.pp declares class { 'ntp': servers => ['192.0.2.5'] }. Which value reaches the catalog?
Incorrect — the signature default is the last resort, used only when no explicit value and no Hiera key exist.
Incorrect — automatic Hiera lookup beats the signature default, but an explicit value in a resource-like declaration beats Hiera.
Correct — precedence runs explicit resource-like value, then automatic Hiera lookup, then the class default, then compile error.
Incorrect — automatic parameter lookup is a single lookup, and merge behavior applies across Hiera layers via lookup_options, never across declaration styles.
03puppet agent -t on a node prints "Error: Could not retrieve catalog from remote server: Error 500 ... Duplicate declaration: Class[Ntp] is already declared", then "Using cached catalog from environment 'production'", then "Applied catalog in 4.21 seconds". What is actually happening?
Incorrect — compilation is all or nothing per node, so a single evaluation error means no new catalog at all.
Incorrect — an Error 500 is a server-side compile failure, not a connection problem, and the agent never falls back to puppet apply.
Incorrect — the cached catalog is the last one that compiled successfully, so nothing merged since the breakage is being enforced.
Correct — usecacheonfailure defaults to true, so you fix the double declaration and confirm the "Using cached catalog" notice stops appearing.

Try this

Run puppet apply /tmp/once.pp on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: in Puppet, the string "false" is true. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related