CoursesPuppetResources & the RAL

Resources & the RAL

The resource abstraction layer.

Intermediate12 min · lesson 2 of 12

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.

web.pp
# 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.

terminal
# which resource types does this node know about?
$ puppet describe --list | grep -E '^(cron|exec|file|package|service|user) '
output
cron - Installs and manages cron jobs
exec - Executes external commands
file - Manages files, including their content, owner ...
package - Manage packages
service - Manage running services
user - 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.

terminal
# what can I write, and what could run it?
$ puppet describe service --short
output
service
=======
Manage running services. Service support unfortunately varies
widely 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, timeout
Providers
---------
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.

How One Declaration Becomes One Change
1you declare
service { 'ssh': ensure => running }
2type checks it
is 'running' a legal value for this attribute?
3provider chosen
systemd here, launchd on a Mac, smf on Solaris
4read reality
the provider asks the box: is it running already?
5close the gap
change only what differs, log only what changed
The type is the interface you write against; the provider is the code that actually runs. When the box already matches the declaration, the provider does nothing and the run stays quiet.

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.

terminal
$ sudo puppet resource user deploy
output
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.

It reads /etc/shadow, and it is not a preview
Two hazards live inside one short command. Run as root on Linux, puppet resource user prints the password field straight out of /etc/shadow, so pasting a dump into a ticket, a wiki page or a chat channel hands out crackable hashes for every account on that host. Redact before you share, and name one user rather than dumping the whole type. Second hazard: the moment you append attribute=value, the command stops reporting and starts enforcing. Right then. Nothing is compiled from your manifests, there is no confirmation prompt, and no report reaches PuppetDB (the database that stores facts and run history for your fleet), so the change leaves no trace in the audit trail you would normally lean on. puppet resource service ssh ensure=stopped, typed on the box you are currently connected to, does exactly what it says on the tin.
terminal
# this is a live change, not a preview
$ sudo puppet resource service nginx ensure=running enable=true
output
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.

/root/harden.pp
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.

terminal
$ sudo puppet apply --noop /root/harden.pp
output
Notice: Compiled catalog for web1.acme.internal in environment production in 0.07 seconds
Notice: /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 event
Notice: Stage[main]: Would have triggered 'refresh' from 1 event
Notice: 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.

terminal
$ sudo puppet apply /root/harden.pp && sudo puppet apply /root/harden.pp
output
Notice: Compiled catalog for web1.acme.internal in environment production in 0.07 seconds
Notice: /Stage[main]/Main/File[/etc/ssh/sshd_config.d/10-hardening.conf]/mode: mode changed '0644' to '0600'
Notice: Applied catalog in 0.06 seconds
Notice: Compiled catalog for web1.acme.internal in environment production in 0.06 seconds
Notice: 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.

/root/dup.pp
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',
}
terminal
$ sudo puppet apply /root/dup.pp
output
Error: Evaluation Error: Error while evaluating a Resource Statement, Cannot alias
File[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".

One real object, one owner
That compile failure is Puppet doing you a favour. What it refuses to build is a machine where two pieces of code both believe they own a file, because that is how you end up with a config that flips between two states depending on which one ran last. Puppet catches the clash inside a single catalog. Nothing catches it across tools. A Puppet class enforcing mode 0600 and a leftover cron job running chmod 644 will fight each other indefinitely, and every Puppet report stays green, because from Puppet's side each run corrected the file successfully. When two things genuinely need a say over one file, your options are a template, concat fragments (the puppetlabs-concat module, which assembles one file out of several pieces), or a single resource fed from Hiera data. Never two declarations.

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.

Quick check
01What is the Resource Abstraction Layer actually doing for you?
Incorrect — Nothing transpiles your manifest; providers are Ruby code that reads and writes state, calling native tools or APIs as they need to.
Incorrect — The point is that a single declaration covers several families, so per-family manifests would defeat it.
Correct — the type is the interface you write against and the provider is the platform-specific implementation Puppet selects on each node.
Incorrect — That is what reports and PuppetDB are for; providers read live state fresh on every single run.
02You are SSH'd into a production host and type: puppet resource service nginx ensure=running enable=true. What happens?
Correct — passing attribute=value on the command line makes puppet resource enforce state rather than report it.
Incorrect — There is no --apply flag and no confirmation step; the change happens on the first invocation.
Incorrect — It is read-only only when you give it no attribute values.
Incorrect — puppet resource talks straight to the RAL: nothing is compiled from your manifests and no report reaches PuppetDB.
03A run fails to compile with: Cannot alias File[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). What is wrong, and what do you do?
Incorrect — The titles already differ (ssh-hardening versus the path), so the clash is in the namevar, not the title.
Incorrect — Compilation never touches the disk, so a missing path cannot cause this; the failure happens before any provider runs.
Incorrect — Puppet rejects the pair on identity alone; two declarations with identical attributes fail in exactly the same way.
Correct — a resource is unique per type plus namevar, whatever you chose to call it.

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.

Related