Virtual resources are a very powerful and not well understood feature of puppet. I will here explain what they are and why there are useful, using as example the management of users in puppet.
By default, in puppet, a resource may be specified only once. The typical example when this can be hurtful is when a user needs to be created on for instance the database and web servers. This user can be only defined once, not once in the database class and once in the webserver class.
If you were to define this user as a virtual resource, then you can define them in multiple places without issue. The caveat is that as the name suggests this user is virtual only, and is not actually created on the server. Some extra work is needed to create (realize in puppet-speak) the user.
Data structure and definitions
Jump to the next section if you directly want to go to the meat of the post. I still want to detail the data structure for better visualisation.
The full example can be found on github. The goal is to be able to define users with the following criteria and assumptions:
- User definition is centralised in one place (typically common.yaml). A defined user on hiera does not mean that they are created on any server, it must be explicitly required.
- A user might be ‘normal’ or have sudo rights. Sudo rights mean that they can do whatever they wishes, passwordless. There is no finer granularity.
- A user might be normal on a server, sudo on another one, absent on others. This can be defined anywhere in the hiera hierarchy.
As good practice, all can be done via hiera. A user can be defined so, with simple basic properties:
accounts::config::users: name: # List of roles the user belongs to. Not necessarily matched to linux groups # They will be used in user::config::{normal,super} in node yaml files to # decide which users are present on a server, and which ones have sudo allowed. # Note that all users are part of 'all' groups roles: ['warrior', 'priest', 'orc'] # default: bash shell: "/bin/zsh" # already hashed password. # https://thisdataguy.com/2014/06/10/understand-and-generate-unix-passwords # python -c 'import crypt; print crypt.crypt("passwerd", "$6$some_random_salt")' # empty/absent means no login via password allowed (other means possible) pass: '$6$pepper$P9Wt3.3Uqh9UZbvz5/6UPtHqa4KE/2aeyeXbKm0mpv36Z5aCBv0OQEZ1e.aKcPR6RBYvQIa/ToAfdUX6HjEOL1' # A PUBLIC rsa key. # Empty/absent means not key login allowed (other means possible) sshkey: 'a valid public ssh key string'
Roles here have no direct Linux counterpart, they have nothing to do with linux groups.
They are only an easy way to manage users inside hiera. You can for instance say
that all system administrators belong to the role sysops, and grant sudo to the sysops group everywhere in one go.
Roles can be added at will, and are just a string tag. Role names will be used later to actually select and create users.
To then actually have users created on a server, roles must be added to 2 specific configuration arrays, depending if a role must have sudo rights or not. Note that all values added to these arrays are merged along the hierarchy, meaning that you can add users to specific servers in the node definition.
For instance, if in common.yaml we have:
accounts::config::sudo: ['sysadmin'] accounts::config::normal: ['data']
and in a specific node definition (say a mongo server) we have:
accounts::config::sudo: ['data'] accounts::config::normal: ['deployer']
– all sysadmin users will be everywhere, with sudo
– all data users will be everywhere, without sudo
– all data users will have the extra sudo rights on the mongo server
– all deployer users will be on the mongo server only, without sudo
Very well, but to the point please!
So, why do we have a problem that cannot be resolved by usual resources?
- I want the user definition to be done in one place (ie. one class) only
- I would like to avoid manipulate data outside puppet (not in a ruby library)
- If a user ends up being normal and sudo in a server, declaring them twice will not be possible
How does this work?
Look at the normal.pp manifest, Unfortunately, the sudo.pp manifest duplicates it almost exactly. The reasons is ordering and duplication of definition of the roles resource. This is a detail.
Looking at the file, here are the interesting parts. First accounts::normal::virtual
class accounts::normal { ... define virtual() {...} create_resources('@accounts::normal::virtual', $users) ... }
This defines a virtual resource (note the @ in front of the resource name on the create_resources line), which is called for each and every element of $users. Note that as it is a virtual resource, users will not actually be created (yet).
The second parameter to create_resources() needs to be a hash. Keys will be resource titles, attributes will be resource parameters. Luckily, this is exactly how we defined users in hiera!
This resource actually does not do much, it just calls the actual user creating resource, called Accounts::Virtual. Accounts::Virtual is a virtual resource, used as you would call any other puppet resource:
resource_name {title: attributes_key => attribute_value}
This is how the resource is realised. As said above, creating a virtual resource (virtual users in our case) does not automatically create the user. By calling it directly, the user is finally created:
accounts::virtual{$title: pass => $pass, shell => $shell, sshkey => $sshkey, sudo => false }
Note the conditional statement just before:
unless defined (Accounts::Virtual[$title]) { ... }
In my design, there is no specific sudoer resource. The sudoer file is managed as part as the user resource. This means that if a user is found twice, once as normal and once as sudo, the same user resource could be declared twice. As the sudo users are managed before the normal users, we can check if the user has already been defined. If that’s the case, the resource will not be called a second time.
This is all and well, but how is the accounts::normal::virtual resource called? Via another resource, of course! This is what roles (accounts::normal::roles) does:
define roles($type) { ... } create_resources('accounts::normal::roles', $normal)
Notice the difference in create_resources? There is no @ prefix in the resource name. This means that this resource is directly called with $normal as parameter, and is not virtual.
Note the $normal parameter. It is just some fudge to translate an array (list of role to create as normal user) to a hash, which is what create_resources() requires.
Inside account::normal::roles, we found the nicely named spaceship operator. Its role will be to realise a bunch resources, but only a subset of them. You can indeed give a filter parameter. In our case (forgetting the ‘all’ conditional, which is just fudging to handle a non explicit group), you can see its use to filter on roles:
Accounts::Normal::Virtual <| roles == $title |>
What this says is simply that we realise the resources Accounts::Normal::Virtual, but only for users having the value $title in their roles array.
To sum up, here is what happened in pseudo code
- for each role as $role (done directly in a class)
- for each user as $user (done in the role resource)
- apply the resource virtual user (done in the virtual user resource)
- for each user as $user (done in the role resource)
Easy, no?