Resources & the RAL
The resource abstraction layer.
You flick a light switch and the room lights up. You never told anyone the voltage, the cable gauge, or whether the bulb is LED or filament. The switch is a promise about the end state, and everything behind the wall is somebody else's problem. Puppet works on that same split. You write down the state you want a machine to be in, and a layer underneath figures out which local machinery can deliver it. That layer is the RAL (Resource Abstraction Layer, the part of Puppet that turns a description of state into the right action for whatever platform it is standing on). The thing you write down is called a resource, and the plain text file you write it in is called a manifest, always ending in .pp.
A resource is the smallest unit of meaning in Puppet. Everything Puppet manages is one: a package, a file, a service, a user account, a cron job (a task Linux runs on a schedule), a firewall rule. The bigger pieces you meet later, classes and modules and roles, are arrangements of resources and nothing more. Get resources right and the rest is filing. Get them wrong and no amount of tidy module design will save the run.
Anatomy of a Declaration
Three visible parts, and one invisible part that bites people later. The type says what kind of thing this is: package, file, service. The title is a label you pick, and it has to be unique for that type inside one machine's configuration. The attributes are key-value pairs describing the state you want. Now the invisible part. Every type nominates one attribute as the answer to the question "which real object on this box?", and that attribute is called the namevar (name variable). For file it is path. For package, service and user it is name. Leave it out of your declaration and Puppet quietly copies the title into it, which is why a file resource titled with a full path needs no path attribute at all. A handful of types have more than one namevar, but treat one as the rule for now.
# type { 'title':# attribute => value,# }package { 'nginx':ensure => installed, # or latest, absent, purged, '1.24.0-2ubuntu7.3'}file { '/etc/nginx/conf.d/hardening.conf': # the title IS the path (the namevar)ensure => file,owner => 'root',group => 'root',mode => '0644',content => "server_tokens off;\n",}service { 'nginx':ensure => running,enable => true, # and come back after a reboot}
Read those three declarations again and notice what is absent. No apt install. No systemctl enable. No test to see whether the work was already done. You describe the destination, and the code underneath works out the route, including the very common case where the correct route is to do nothing at all. That missing check is exactly where hand-written shell scripts rot. One value in there deserves a hard look before you type it. ensure => latest tells Puppet to upgrade that package on every run, forever, accepting whatever the upstream repository published overnight with nobody reviewing it. That is a supply-chain decision wearing the costume of a convenience. Pin a version string on anything you actually care about.
One Interface, Many Kinds of Wiring
Here is where the abstraction pays rent. The type is the switch on the wall. The provider is the wiring behind it: the platform-specific code that reads current state and changes it. You write ensure => installed once. On Debian and Ubuntu that becomes apt work. On RHEL and Fedora, dnf. On FreeBSD, pkgng. On Windows the built-in choice is the windows provider, which drives MSI (Microsoft Installer) packages, while Chocolatey support arrives separately through the puppetlabs-chocolatey module. Puppet picks the wiring at runtime by looking at the node it is standing on. Both halves are visible from the command line.
# which resource types does this node know about?$ puppet describe --list | grep -E '^(cron|exec|file|package|service|user) '
cron - Installs and manages cron jobsexec - Executes external commandsfile - Manages files, including their content, owner ...package - Manage packagesservice - Manage running servicesuser - Manage users
Each line is a type this node understands, followed by the first sentence of its own documentation. Something that changed back in Puppet 6 still catches people out. A pile of types (cron, mount, host, yumrepo, sshkey, augeas, scheduled_task and the SELinux pair) were lifted out of Puppet's own code into small separate modules with names like puppetlabs-cron_core. They still show up in that list because the puppet-agent package ships them as vendored modules already sitting on the module path. Install the bare puppet Ruby gem instead and cron is missing until you add the module yourself. OpenVox, the community fork that appeared after the Puppet 8 licence change, carries the same types, the same providers and the same command names, so everything in this lesson works there unchanged.
# what can I write, and what could run it?$ puppet describe service --short
service=======Manage running services. Service support unfortunately varieswidely by platform ... (description trimmed here for space)Parameters----------binary, control, enable, ensure, flags, hasrestart, hasstatus,logonaccount, logonpassword, manifest, name, path, pattern, provider,restart, start, status, stop, timeoutProviders---------base, bsd, daemontools, debian, freebsd, gentoo, init, launchd,openbsd, openrc, openwrt, rcng, redhat, runit, service, smf, src,systemd, upstart, windows
Parameters is every attribute you are allowed to write. Providers is every implementation of the service type that ships with Puppet, which is a different thing from every one that will work here. Think of it as a job advert with requirements attached. A provider counts as suitable on this machine only if the commands it needs are actually installed and its confine rules (conditions the provider declares about the platform, things like "Linux only" or "only if systemctl exists") all pass. Among the suitable candidates, a defaultfor rule matched against facts such as os.family and os.name picks the winner, and on Ubuntu 24.04 that winner is systemd. You can override the choice on any single resource with provider => 'systemd'. Most of the time you should not, because you have thrown away the portability you were buying. The honest exception is when you mean a different software universe rather than a different operating system. package { 'puppet-lint': provider => 'gem' } says "the RubyGems one, not the distro package", and that is a real decision rather than an override.
The RAL abstracts how a thing gets done, not what it is called, and that leak finds you in week one. Apache is apache2 on Debian and httpd on RHEL. The OpenSSH server unit is ssh on Debian and sshd on RHEL. Puppet will not translate names for you. It will cheerfully try to start a service called apache2 on a RHEL box and fail. That is the honest trade-off. One manifest really does run across a mixed fleet, provided you feed each family the right names. The place for those names is Hiera (Puppet's built-in lookup system, which reads key-value data out of YAML files arranged in a hierarchy) keyed on os.family, rather than a wall of if-statements buried in your code.
Reading the Machine Back
The RAL runs backwards too, and that is the half people forget. puppet resource asks those same providers what is true on the box right now, then prints the answer as Puppet code. It is a mirror, not a report. Three jobs it does better than anything else: telling you an attribute's exact spelling, snapshotting a server you are about to bring under management, and checking whether a change you made actually landed.
$ sudo puppet resource user deploy
user { 'deploy':ensure => 'present',comment => 'Deploy service account',gid => 1001,groups => ['sudo'],home => '/home/deploy',password => '$y$j9T$kQ8dWJ2yV0pR7mA1cS4xB.$3Nn6oQ0hK2vP9tZ1rL8sYd4uX7cF5gJ2wE0mT6bH9aC',password_max_age => 99999,password_min_age => 0,password_warn_days => 7,shell => '/bin/bash',uid => 1001,}
Look at the shapes, because those are what you will be writing back. uid and gid come out as bare integers, groups as an array in square brackets, everything else as quoted strings. Swap the title for another account and you get that account instead, so puppet resource user root prints root's entry. Drop the title entirely and Puppet dumps the whole type: puppet resource package prints every installed package on the box as manifest code, which is a sane first draft when you are adopting a machine nobody has managed for six years. Add the -y flag (long form --to_yaml) and the same data comes out as YAML, which is the shape Hiera wants. Do not paste any of it back in blind, though. Some values are readable but have no business in version control, and the line above showing a password hash is the obvious one.
# this is a live change, not a preview$ sudo puppet resource service nginx ensure=running enable=true
Notice: /Service[nginx]/ensure: ensure changed 'stopped' to 'running'service { 'nginx':ensure => 'running',enable => 'true',}
Prove It: Noop, Then Apply, Then Apply Again
Here is a piece of real work. An SSH hardening drop-in file (a small config fragment that sshd reads alongside its main config) is supposed to be owned by root and set to mode 0600. During an incident last week somebody ran chmod 644 on it so a colleague could read it, and never put it back. The permissions are now looser than policy says and nothing noticed, because the contents of the file are still perfectly correct.
file { '/etc/ssh/sshd_config.d/10-hardening.conf':ensure => file,owner => 'root',group => 'root',mode => '0600',content => "PermitRootLogin no\nPasswordAuthentication no\nX11Forwarding no\n",}service { 'ssh':ensure => running,enable => true,}
Before touching a live host, run it in noop mode (no-operation, Puppet's dry run). Puppet builds the whole configuration, asks every provider what it would have to do, then does none of it.
$ sudo puppet apply --noop /root/harden.pp
Notice: Compiled catalog for web1.acme.internal in environment production in 0.07 secondsNotice: /Stage[main]/Main/File[/etc/ssh/sshd_config.d/10-hardening.conf]/mode: current_value '0644', should be '0600' (noop)Notice: Class[Main]: Would have triggered 'refresh' from 1 eventNotice: Stage[main]: Would have triggered 'refresh' from 1 eventNotice: Applied catalog in 0.05 seconds
One line of difference: the mode on disk is 0644 and the declaration says 0600. The service resource printed nothing at all, because sshd is already running and already enabled, and Puppet only reports what it would change. Those "Would have triggered a refresh" lines confuse everybody the first time they appear. They do not mean a service is about to be restarted. Class[Main] and Stage[main] are containers, boxes that hold other resources rather than real things on the box, and that wording is noop's clumsy way of saying a change would have happened somewhere inside them. Nothing was written to disk. Now do it for real, twice in a row.
$ sudo puppet apply /root/harden.pp && sudo puppet apply /root/harden.pp
Notice: Compiled catalog for web1.acme.internal in environment production in 0.07 secondsNotice: /Stage[main]/Main/File[/etc/ssh/sshd_config.d/10-hardening.conf]/mode: mode changed '0644' to '0600'Notice: Applied catalog in 0.06 secondsNotice: Compiled catalog for web1.acme.internal in environment production in 0.06 secondsNotice: Applied catalog in 0.04 seconds
The second run is the whole point. It printed no change lines because there was nothing left to change, which is what idempotent means (run it again and the second run does nothing), and that silence is your verification that the work is finished. Treat it as a test, not a formality. A manifest that reports a change on every single run is fighting the machine, and the usual culprit is exec, the escape hatch that runs an arbitrary shell command. Puppet has no idea what your command did or whether it needed doing, so an unguarded exec fires on every run forever. Give every one of them an unless, onlyif or creates condition. On a managed fleet the payoff compounds, because puppet agent wakes up on its own every 30 minutes by default (the runinterval setting), so that stray chmod would have been undone before anyone got round to filing a ticket, and PuppetDB keeps the report so you can ask afterwards which nodes drifted and when.
The Title Is Not Always the Namevar
The finished configuration Puppet builds for one node is called a catalog. Think of it as a work order for that machine: every job listed once, written in a form the providers can act on. Puppet files each resource in that work order twice, once under its title and once under its namevar, and a clash on either one stops the build. That is the source of the most common early error in Puppet. The two declarations below look different and are the same object.
file { '/etc/ssh/sshd_config.d/10-hardening.conf':ensure => file,mode => '0600',}file { 'ssh-hardening':path => '/etc/ssh/sshd_config.d/10-hardening.conf',ensure => file,mode => '0644',}
$ sudo puppet apply /root/dup.pp
Error: Evaluation Error: Error while evaluating a Resource Statement, Cannot aliasFile[ssh-hardening] to ["/etc/ssh/sshd_config.d/10-hardening.conf"] at (file:/root/dup.pp, line: 6); resource ["File","/etc/ssh/sshd_config.d/10-hardening.conf"] already declared (file: /root/dup.pp,line: 1) (file: /root/dup.pp, line: 6, column: 1) on node web1.acme.internal
Read the message slowly, because it spells out the actual rule. Puppet tried to file File[ssh-hardening] under the path /etc/ssh/sshd_config.d/10-hardening.conf, found that path already claimed by the resource on line 1, and refused to build the catalog. Notice where this happened. Compilation. No provider ran, nothing touched the disk, and the file on the box is exactly as it was. If both titles had been identical you would get the blunter version of the same complaint: "Duplicate declaration: File[/etc/ssh/sshd_config.d/10-hardening.conf] is already declared at (file: /root/dup.pp, line: 1); cannot redeclare".
So before you write a resource for anything that already exists on a box, point puppet resource at it first. One command hands you the exact attribute names, the current values and a working first draft of the manifest, which beats guessing from the docs and then debugging a typo across 200 nodes. Ordering is the next problem waiting for you: the config file has to land on disk before the service tries to read it, and nothing you have written so far says so.
Try this
Run puppet describe --list | grep -E '^(cron|exec|file|package|service|user) ' 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: it reads /etc/shadow, and it is not a preview. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.