Classes & defined types
Group and parameterize resources.
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.
# Autoloading: class `ntp` MUST live in <module>/manifests/init.pp# class `ntp::config` would live in <module>/manifests/config.ppclass 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.
# 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 placescontain profile::firewall # firewall's resources now sit INSIDE profile::baserequire profile::pki # this class waits for whatever profile::pki contains}
class demo {notify { 'ntp configured': }}include demoinclude demo # same class, declared three timesinclude demo
# Does a class evaluate once, or once per include?$ puppet apply /tmp/once.pp
Notice: Compiled catalog for web01.acme.internal in environment production in 0.03 secondsNotice: ntp configuredNotice: /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.
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 itStdlib::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,}}
# 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' }"
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.internalError: 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.
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.
---ntp::impl: 'chrony'ntp::servers:- '0.pool.ntp.org'- '1.pool.ntp.org'ntp::enabled: true
# 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
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 foundHierarchy entry "Per-OS defaults"Path "/etc/puppetlabs/code/environments/production/data/os/Debian.yaml"Original path: "os/%{facts.os.family}.yaml"Path not foundHierarchy 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.
node 'web01.acme.internal' {include profile::base # profile::base already does `include ntp`class { 'ntp': # second declaration of the SAME classservers => ['10.0.0.10'],}}
$ puppet agent -t
Info: Using environment 'production'Info: Retrieving pluginfactsInfo: Retrieving pluginInfo: Retrieving localesInfo: Loading factsError: 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.internalNotice: 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.
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.
# Autoloads from profile/manifests/vhost.ppdefine 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 collidefile { "/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.
# profile::vhost notifies Service['apache2'], so this manifest owns itservice { 'apache2':ensure => 'running',enable => true,}profile::vhost { 'example.com': }profile::vhost { 'api.example.com':port => 8080,docroot => '/srv/api',}
# Stamp two instances, but dry-run before touching anything$ puppet apply --noop --show_diff /tmp/vhosts.pp
Notice: Compiled catalog for web01.acme.internal in environment production in 0.42 secondsNotice: /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 eventsNotice: /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 eventsNotice: /Stage[main]/Main/Service[apache2]: Would have triggered 'refresh' from 2 eventsNotice: Class[Main]: Would have triggered 'refresh' from 4 eventsNotice: 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.
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 dependencypackage { '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}}}
---profile::web::vhosts:'example.com':port: 80'api.example.com':port: 8080docroot: '/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.)
$ puppet apply /etc/puppetlabs/code/environments/production/manifests/site.pp
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.
require 'spec_helper'describe 'profile::web' doon_supported_os.each do |os, os_facts|context "on #{os}" dolet(:facts) { os_facts }let(:params) do{ 'vhosts' => { 'example.com' => { 'port' => 80 } } }endit { is_expected.to compile.with_all_deps }it { is_expected.to contain_profile__vhost('example.com').with_port(80) }endendend
# inside the profile module$ pdk new defined_type vhost$ pdk validate$ pdk test unit
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.7pdk (INFO): Using Puppet 8.10.0pdk (INFO): Using Ruby 3.2.7pdk (INFO): Using Puppet 8.10.0[✔] Preparing to run the unit tests.profile::webon ubuntu-24.04-x86_64is expected to compile into a catalogue without dependency cyclesis expected to contain Profile::Vhost[example.com] with port => 80Finished 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.
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.