Designing convenient roles

There are several approaches to building roles, and you must decide which ones are most convenient for you and your team.

High-quality roles strike a balance between readability and maintainability. For most people, the benefit of seeing the entire role in a single file outweighs the maintenance cost of repetition. Later, if you find the repetition burdensome, you can change your approach to reduce it. This might involve combining several similar roles into a more complex role, creating sub-roles that other roles can include, or pushing more complexity into your profiles.

So, begin with granular roles and deviate from them only in small, carefully considered steps.

Here's the basic Jenkins role we're starting with:

class role::jenkins::controller {
  include profile::base
  include profile::server
  include profile::jenkins::controller
}

First approach: Granular roles

The simplest approach is to make one role per type of node, period. For example, the Puppet Release Engineering (RE) team manages some additional resources on their Jenkins controllers.

With granular roles, we'd have at least two Jenkins controller roles. A basic one:

class role::jenkins::controller {
  include profile::base
  include profile::server
  include profile::jenkins::controller
}

...and an RE-specific one:

class role::jenkins::controller::release {
  include profile::base
  include profile::server
  include profile::jenkins::controller
  include profile::jenkins::controller::release
}

The benefits of this setup are:

  • Readability — By looking at a single class, you can immediately see which profiles make up each type of node.
  • Simplicity — Each role is just a linear list of profiles.

Some drawbacks are:

  • Role bloat — If you have a lot of only-slightly-different nodes, you quickly have a large number of roles.
  • Repetition — The two roles above are almost identical, with one difference. If they're two separate roles, it's harder to see how they're related to each other, and updating them can be more annoying.

Second approach: Conditional logic

Alternatively, you can use conditional logic to handle differences between closely-related kinds of nodes.

class role::jenkins::controller::release {
  include profile::base
  include profile::server
  include profile::jenkins::controller

  if $facts['group'] == 'release' {
    include profile::jenkins::controller::release
  }
}

The benefits of this approach are:

  • You have fewer roles, and they're easy to maintain.

The drawbacks are:

  • Reduced readability...maybe. Conditional logic isn't usually hard to read, especially in a simple case like this, but you might feel tempted to add a bunch of new custom facts to accommodate complex roles. This can make roles much harder to read, because a reader must also know what those facts mean.

    In short, be careful of turning your node classification system inside-out. You might have a better time if you separate the roles and assign them with your node classifier.

Third approach: Nested roles

Another way of reducing repetition is to let roles include other roles.

class role::jenkins::controller {
  # Parent role:
  include role::server
  # Unique classes:
  include profile::jenkins::controller
}

class role::jenkins::controller::release {
  # Parent role:
  include role::jenkins::controller
  # Unique classes:
  include profile::jenkins::controller::release
}

In this example, we reduce boilerplate by having role::jenkins::controller include role::server. When role::jenkins::controller::release includes role::jenkins::controller, it automatically gets role::server as well. With this approach, any given role only needs to:

  • Include the "parent" role that it most resembles.
  • Include the small handful of classes that differentiate it from its parent.

The benefits of this approach are:

  • You have fewer roles, and they're easy to maintain.
  • Increased visibility in your node classifier.

The drawbacks are:

  • Reduced readability: You have to open more files to see the real content of a role. This isn't much of a problem if you go only one level deep, but it can get cumbersome around three or four.

Fourth approach: Multiple roles per node

In general, we recommend that you assign only one role to a node. In an infrastructure where nodes usually provide one primary service, that's the best way to work.

However, if your nodes tend to provide more than one primary service, it can make sense to assign multiple roles.

For example, say you have a large application that is usually composed of an application server, a database server, and a web server. To enable lighter-weight testing during development, you've decided to provide an "all-in-one" node type to your developers. You could do this by creating a new role::our_application::monolithic class, which includes all of the profiles that compose the three normal roles, but you might find it simpler to use your node classifier to assign all three roles (role::our_application::app, role::our_application::db, and role::our_application::web) to those all-in-one machines.

The benefit of this approach are:

  • You have fewer roles, and they're easy to maintain.

The drawbacks are:

  • There's no actual "role" that describes your multi-purpose nodes; instead, the source of truth for what's on them is spread out between your roles and your node classifier, and you must cross-reference to understand their configurations. This reduces readability.
  • The normal and all-in-one versions of a complex application are likely to have other subtle differences you need to account for, which might mean making your "normal" roles more complex. It's possible that making a separate role for this kind of node would reduce your overall complexity, even though it increases the number of roles and adds repetition.

Fifth approach: Super profiles

Because profiles can already include other profiles, you can decide to enforce an additional rule at your business: all profiles must include any other profiles needed to manage a complete node that provides that service.

For example, our profile::jenkins::controller class could include both profile::server and profile::base, and you could manage a Jenkins controller server by directly assigning profile::jenkins::controller in your node classifier. In other words, a "main" profile would do all the work that a role usually does, and the roles layer would no longer be necessary.

The benefits of this approach are:

  • The chain of dependencies for a complex service can be more clear this way.
  • Depending on how you conceptualize code, this can be easier in a lot of ways!

The drawbacks are:

  • Loss of flexibility. This reduces the number of ways in which your roles can be combined, and reduces your ability to use alternate implementations of dependencies for nodes with different requirements.
  • Reduced readability, on a much grander scale. Like with nested roles, you lose the advantage of a clean, straightforward list of what a node consists of. Unlike nested roles, you also lose the clear division between "top-level" complete system configurations (roles) and "mid-level" groupings of technologies (profiles). Not every profile makes sense as an entire system, so you some way to keep track of which profiles are the top-level ones.

    Some people really find continuous hierarchies easier to reason about than sharply divided layers. If everyone in your organization is on the same page about this, a "profiles and profiles" approach might make sense. But we strongly caution you against it unless you're very sure; for most people, a true roles and profiles approach works better. Try the well-traveled path first.

Sixth approach: Building roles in the node classifier

Instead of building roles with the Puppet language and then assigning them to nodes with your node classifier, you might find your classifier flexible enough to build roles directly.

For example, you might create a "Jenkins controllers" group in the console and assign it the profile::base, profile::server, and profile::jenkins::controller classes, doing much the same job as our basic role::jenkins::controller class.

Important:

If you're doing this, make sure you don't set parameters for profiles in the classifier. Continue to use Hiera / Puppet lookup to configure profiles.

This is because profiles are allowed to include other profiles, which interacts badly with the resource-like behavior that node classifiers use to set class parameters.

The benefits of this approach are:

  • Your node classifier becomes much more powerful, and can be a central point of collaboration for managing nodes.
  • Increased readability: A node's page in the console displays the full content of its role, without having to cross-reference with manifests in your role module.

The drawbacks are:

  • Loss of flexibility. The Puppet language's conditional logic is often more flexible and convenient than most node classifiers, including the console.
  • Your roles are no longer in the same code repository as your profiles, and it's more difficult to make them follow the same code promotion processes.