guest@6admin.io:~$

Puppet: my first modules, or moving from the ssh loop to declaring

My bash scripts, a host list, a CSV of variables and an ssh loop pushing template-rendered configs have served me well since 2007, but they describe steps and that is precisely what I wanted to leave behind when I wrote my first Puppet modules for the fleet I run at work. Puppet is a real DSL, a real agent/master model (0.25 at the moment) and a promise my scripts never kept: declare the desired state, not the steps. Ssh, ntp, postfix, munin, zabbix, varnish, rsyslog: these modules cover the common base and I extend them as needs come up.

Anatomy of a module

The pattern I ended up adopting everywhere, as it lives in ssh/manifests/init.pp:

class ssh {
    $packagename = $::operatingsystem ? {
        /(?i)(ubuntu|debian)/  => 'openssh-server',
        /(?i)(redhat|centos)/  => 'openssh',
        default                => undef
    }
}
class ssh::install {
    package { $ssh::packagename: ensure => installed }
}
class ssh::authkeys {
    file { '/root/.ssh/authorized_keys':
        ensure  => file,
        replace => false,
        owner   => 'root', group => 'root', mode => '0644',
        content => template("ssh/authorized_keys.erb"),
        require => Class["ssh::install"],
        notify  => Class["ssh::service"],
    }
}
class ssh::service {
    service { $ssh::servicename:
        ensure     => running,
        hasstatus  => true, hasrestart => true, enable => true,
        require    => Class["ssh::authkeys"],
    }
}

The install/config/service triad, the selector on $::operatingsystem to survive Debian and RedHat in the same fleet, the require/notify chains that make the dependency graph the real program and ERB templates for the configs. The replace => false on authorized_keys is the detail that matters: the key is laid down on the first run and whatever the local admin adds afterwards is never overwritten.

What Puppet brings

Three things the ssh loop never gave me. Idempotence, first, since rerunning changes nothing when the state is already right, where my scripts replayed everything on every pass. Convergence, then, because the agent catches drift instead of stopping at the initial install. The graph, finally, where the require chains explain the order and the catalog explains the why. There is a price, of course: an agent on every node, a master, certificates to sign and Ruby grumbling on older machines, but the trade is well worth it.

[edit: 2026] The oldpuppet repo survived, but it was only pushed to GitHub in April 2016, one commit per module (“Adding puppet’s apt module”…), with a README that sets the tone: “My first puppet’s modules. Not really proud of this too, but it’s maybe usefull for someone.” It holds 19 modules and with them the sins of the time, which is what makes it useful as an archive. In ntp/manifests/init.pp, the whole config class is commented out (ntp.conf was managed elsewhere) and the service stops on a hardcoded exempted host, if $::fqdn != 'exceptedhost.domain.tld', the exception baked into the manifest, Puppet’s house of evil. The capitalised references, Class["ssh::install"], belong to a syntax that later versions moved from warning to error. And the version control for these modules was the puppetmaster itself, which makes me smile today. As for what came next, I played a bit with Ansible as early as 2012, but Puppet remained the bulk of my work until 2014 and I only adopted Ansible professionally around 2016; the 2007 bash+CSV contraption kept evolving on its own, which I tell in Ansible’s ancestor .