Chapter 7. Roles and Profiles

Roles and profiles is a best-practice design pattern for Puppet that provides an interface between your business logic and reusable Puppet modules. Profiles are reusable groups of modules that configure applications. Roles utilize one or more profiles to implement business- or site-specific requirements. When implemented properly, this design pattern greatly simplifies node classification in large, diverse organizations.

Note

Roles and profiles were not a feature provided by Puppet; they are a design pattern originally described in Craig Dunn’s blog post, “Designing PuppetRoles and Profiles”. It described an evolution of Puppet usage that became a community standard and recognized best practice.

Back in Chapter 1, we discussed how the single responsibility principle enables creation of reusable building blocks for modular design. Those principles help create great modules, but reusable modules need to be combined together to deploy a complete service or application. A functional service often consists of different software components, each of which already has modules designed to configure it. A profile is the larger building block that encapsulates a specific build of that service.

Without using roles and profiles, a node would receive a list of classes from either a node statement or an ENC. This has some significant limitations:

  • An ENC can provide only classes and parameters: it cannot declare resources or defined types.

  • An ENC cannot declare relationships between modules.

  • The ENC won’t provide feedback about or dependency checking for modules that work together.

  • ENCs are configured from a database, the data of which is rarely under change-control or versioned.

Let’s begin by briefly defining what profiles and roles actually are:

Profiles

A profile utilizes reusable modules to create a specific software or service configuration (technology stack). It implements component-specific code and logic to customize the delivery. For a metaphoric example: Profile “omelette” uses components “eggs,” “peppers,” and “cheese” with a site-specific cooking style.

Roles

A role codifies a business-level configuration based on a selection of profiles to add to the node. The role will persist even if components of the role are switched out. For example: Role “western_breakfast” uses profiles “omelette,” “hashbrowns,” and “coffee,” but could become healthier with iteration.

There’s nothing intrinsically special about roles or profiles. There is no feature in Puppet called a role nor is there one called a profile. Roles and profiles are modules, classes, or external data implemented the same as anything else in Puppet. Their special purpose comes only from wide adoption of this convention as an abstraction layer.

Roles

A role provides the complete specification of a node by selecting and influencing profiles. Roles codify your infrastructure, making it simpler to reproduce configurations by eliminating ad hoc node classification.

A role is simply a declaration of profiles. It establishes relationships between profiles and provides the shared context for a specific implementation.

Creating Readable Roles

Roles should be easy to identify and easy to read. Because the role specifies only the profiles to be used to build it, roles should be easily readable by people with no programming experience, regardless of the profile implementation. Let’s examine some readable role declarations in the subsections that follow.

Role classes

The original role implementation and perhaps the most widely documented is the use of a class to declare a role. Example 7-1 demonstrates a role as implemented by a class.

Example 7-1. Role class example
class role::web_frontend() {
  include profile::yumrepos
  include profile::antivirus
  include profile::mysql::client
  include profile::webserver
  include profile::firewall
}

This example demonstrates the value of well-named modules and classes. The list of descriptive profiles makes it easy to understand which pieces come together to create a frontend web server. Because the role is independent of the profile implementation, profiles can be completely redesigned without affecting the role.

Tip

Roles should always be clear and easy to read. Any role with more than a page of code likely needs to have its profiles refactored for more effective use.

Hiera-defined roles

Because roles should contain nothing more than a list of profiles to be applied, it is entirely possible to provide a role definition in Hiera. This requires three components:

  • Node facts or the ENC must identify the role for each node.

  • Hiera must use the role provided in the hierarchy.

  • The node must include() classes provided by Hiera data.

The Hiera hierarchy for the environment should contain a level that utilizes the role provided, as shown here:

  - name: "Role data"
    path: "roles/%{foreman_data.role}.yaml"
Tip

Be cautious about the source of your data if the role provides access to security-conscious data. A trusted fact or an ENC-provided value will be more secure than a fact provided by an untrusted node.

The role-specific data file should contain the list of profile classes to be applied:

---
classes:
  - 'profile::yumrepos'
  - 'profile::antivirus'
  - 'profile::mysql::client'
  - 'profile::webserver'
  - 'profile::firewall'

Assuming that classes are loaded from Hiera with lookup('classes', Array, 'unique').include, this web frontend hiera data file implements the same web frontend role shown in Example 7-1.

Design Roles for a Singular Use Case

There’s really only one rule for role design: it should implement a single, specific node configuration. The only things different on two nodes of the same role should be their hostname, IP address, and such forth.

Because roles are a manifestation of an organization’s specific nuances, there are no hard-and-fast rules for what a role should and shouldn’t do. However, there are certain things to look for (code smells) that imply the role isn’t scoped correctly:

The role utilizes parameter input to make implementation choices

Are you sure this isn’t two different roles?

The role implements logic or resources directly

It’s likely that the logic or resources are general enough to be within a profile used by other roles.

The role is longer than a paragraph

If the role includes many profiles, it’s likely to reuse groups of profiles that could be shared with other roles.

The role implements explicit ordering of the profiles provided

Avoid ordering relationships between the profiles contained in the roles unless they are absolutely necessary. Many profiles source common modules, creating ordering conflicts.

The same role is applied to nodes that don’t provide identical services

If a role builds nodes that provide different services, it contains deterministic, not declarative logic.

Any one of these code smells isn’t necessarily bad, but if multiple of these conditions are true, it’s very likely that the role should be broken down into profiles and rescoped.

Provide the Role for Use in Data Lookups

You can assign roles in a multitude of ways, but the most common and flexible ways to do so are the following:

  • A custom fact identifies the role.

  • The ENC provides the role as a node parameter.

Identifying node roles in the ENC

The classic use of ENCs was to supply a list of classes for a node to apply. The ENC administrator would select the classes to apply to each node, but this has a significant number of limitations:

  • Most ENCs provide no change history or versioning of the node configuration.

  • The ENC interface doesn’t display dependencies or relationships between the classes chosen.

  • The ENC configuration isn’t available for use in acceptance tests.

Due to limitations of interaction between the node classifier and Puppet, a human would need to manually input all dependency classes into the node data. This invariably led to many one-off snowflake configurations with no change history. In contrast, assigning a single role from the ENC provides numerous benefits:

  • Roles and profiles are stored and versioned in your code repository, providing a history of changes for review.

  • Roles and profiles can express complex relationships with the full functionality of the Puppet language.

  • Roles can be independently versioned and tested without the ENC.

If your site utilizes an external node classifier, configure the ENC to provide the role as a node parameter. The ENC then focuses solely on identifying the appropriate role for each node. This keeps similar nodes controlled through a common source, minimizing drift between nodes of the same type.

Tip

Roles provide businesss-specific implementations that you can select from the node classifier without knowledge of the Puppet module structure.

A long-term benefit of roles is that they are portable between ENCs. If you switch from one ENC to another (i.e., from Foreman to Puppet Enterprise), there’s no need to migrate the class grouping from one ENC’s internal structure to the other. This simplifies upgrades and allows the flexibility to test and migrate to a different node classifier.

Identifying node roles using node facts

If an ENC is not used, node roles can be provided by custom facts. The custom fact would determine the role based on information available to the node. Following is an overly simplified example of a custom fact providing the role to be assigned:

Facter.add(:role) do
  setcode do
    if Facter.value(:hostname) =~ /^(w+)[0-9]+/
      role = $1
    else
      role = 'unknown'
    end
    role
  end
end

This custom fact determines the role by stripping digits and anything after them from the node’s hostname. Most node-naming schemes are more complex, but this shows the general idea.

Deconstructing the hostname assigned to the node is perhaps the most common, but a custom fact can use any information available on the node—network interfaces, data from files, a cloud provider’s metadata—anything available to the node can be used in the logic for role selection.

Profiles

Profiles are the place for business logic. They instruct how to combine modules to build real-world applications, and they provide a bridge between node data, site data, and general-purpose code in a reusable component that roles can utilize.

Profiles should contain all the logic necessary to build site-specific configurations. By centralizing that logic in a profile, the component modules remain free of the weirdness inherent in a given implementation. With the implementation customized within the profile, you need only look at a single layer. It also becomes much easier to use community and vendor-provided modules.

Profiles facilitate the SoC by defining the relationship between modules for a specific implementation scenario. This helps manage or eliminate module interdependencies. Without profiles, modules would be forced to be more monolithic in nature, and include or require every possible dependent module. As a result, it becomes difficult to test the code without testing every module that it requires. It’s tremendously difficult then to change the dependent modules because they are referenced in too many places. The web of interdependencies can severely hamper refactoring efforts, thus slowing your ability to respond to changing requirements.

Instead, you can declare module dependencies in a profile. Because your application stack logic is handled in the profile, modules can be focused on a specific component implementation. Dependencies are reduced to the absolute minimum needed for the module to be successfully tested.

As an example, imagine for a second that your site has a policy of blocking all incoming network connections to all hosts. A web server will need TCP ports 80/http and 443/https open. Most Puppet modules that configure web servers software would not change the firewall (SoC). Rather than write your own module to do both, you could create a profile that does the following:

  1. Accepts input for which TCP ports on which to accept connections.
  2. Uses a public module like puppetlabs/apache to configure the web server to listen on those TCP ports.
  3. Uses a public module like puppetlabs/mysql to configure a database for WordPress to use.
  4. Uses a public module like puppetlabs/firewall to configure the firewall to allow connections on those TCP ports.

With this profile design, site-specific data is provided to off-the-shelf modules to create a specific configuration pattern. The profile becomes easy-to-read documentation of how this technology stack is configured without getting bogged down in the implementation details.

A Sample Service Profile

Let’s examine a sample profile for the aforementioned standalone WordPress service, as shown in Example 7-2.

Example 7-2. Example WordPress profile
class profile::wordpress (
  $wp_hostname = $facts['fqdn'],
  $tcp_port    = 80,
  $directory   = '/var/www/html',
) {
  include firewall
  include apache
  include apache::mod::php
  include mysql::client
  include mysql::server

  class { 'mysql::bindings':
    php_enable => true,
  }

  class { 'wordpress':
    install_dir => $directory,
  }

  apache::vhost { $wp_hostname:
    port    => $tcp_port,
    docroot => $directory,
  }

  firewall { '010 Allow Wordpress Access':
    action => 'accept',
    proto  => 'tcp',
    dport  => $tcp_port,
  }
}

This example follows the basic pattern of a profile. It is not concerned with basic system configuration or any other application. It is concerned only with configuration of a WordPress instance.

There are no strict ordering dependencies between modules, although a few resources establish autodependency relationships. There are one or two classes that are parse-order dependent, but they are contained entirely within the profile and are not sensitive to the structure of other modules or the order in which this profile is evaluated versus other profiles.

Providing Actionable Data in Profile Parameters

The profile in Example 7-2 passes in only the hostname, port, and docroot parameters. This is because these three parameters are the ones for which this profile has specific logic.

You should use profile parameters when the parameter affects the behavior of the profile or when the data needs to be modified before being passed to the module. Using input parameters this way increases the versatility and DRY-ness of the profile. Rather than creating multiple profiles that are basically the same, use a single profile that can adjust its behavior based on the input supplied. Used in this manner, profile parameters provide the business- or site-specific data for a bespoke configuration.

For example, Consul service discovery uses the same configuration file for both clients and servers. The difference between the two is established entirely based on data in the service. In this case, a Consul profile might pass the bootstrap_expect and join_wan parameters to the Puppet-approved KyleAnderson/consul module only when the server option (node- or role-specific data) is enabled.

Unlike component modules, it is acceptable to look up site-specific data in a profile. Profiles implement business logic, so it’s perfectly alright to have unique and site-specific logic in the profile. The guideline to create reusable code is relaxed at this level: your profiles should be closely tied to your specific technology stack and its data.

Profile-specific defaults can be provided in the profile’s data, thus keeping the business logic and business data close together.

Shared Hiera data

This profile relies on Hiera automatic parameter lookups for configuration of its component modules. The values for any parameter other than hostname, TCP port, and docroot parameters will fall back to a Hiera data lookup in the module’s namespace. To change the database password, set wordpress::db_password in the Hiera data files.

This usage of default values from Hiera provides a DRY configuration that can be shared by multiple profiles without repeating yourself. Although DRY is good, it is less readable. Puppet recommends supplying all component module parameters from the profile to aid in readability. DRY is good; readability is good—you should carefully balance the two!

Implementing Business Logic in Profiles

Profiles are the appropriate place for business logic. You can use this logic to act based on input parameters or node facts to build the profile appropriately for a given node type.

The following profile installs the appropriate antivirus based on the node’s OS:

class profile::antivirus {
  case $facts['kernel'] {
    'Linux': {
      include clamav
    }
    'windows': {
      include eset_nod32
    }
    default: {
      warning("No antivirus available for ${facts['kernel']}")
    }
  }
}

This profile contains the conditional logic based on the OS, allowing you to include include 'profile::antivirus' for every node, regardless of OS type.

Firewall rules might be owned by multiple teams at a large or diverse organization. That breakdown would be specific to the organization, so this is the kind of logic that belongs in a profile. Let’s assume that there are security rules provided by corporate security, site-specific rules provided by the operations team, and application-specific rules necessary to allow the application to work. Here is a fairly arbitrary example that generates a list of firewall rules from data provided by three different teams:

class profile::firewall(
  Hash $site_rules     = {},
  Hash $app_rules      = {},
  Hash $security_rules = {},
) {
  use 'stdlib'

  class { 'iptables':
    # merge rules from three teams, rightmost value wins conflict
    rules => deep_merge($site_rules, $app_rules, $security_rules),
  }
}

The number of cases for which this is actually required should be fairly minimal; in most cases careful design of your data hierarchy should avoid the need to perform a hash merge data lookup. But if it needs to get weird, a profile is the appropriate place to encapsulate that weirdness.

It’s really when you put the combination of business logic and conditional logic based around the node type that you can create very readable, DRY profiles. The following example realizes virtual yum repositories based on the OS release and the application to be deployed on the node:

class profile::yumrepos(
  String $application = 'no_match',
  Hash $repositories  = {},
) {
  # Virtualize all hiera-listed repositories
  create_resources('@yumrepo', $repositories)

  # Realize all common repos
  Yumrepo <| tag == 'global' |>

  # Realize os-specific repos
  Yumrepo <| tag == "${facts['os']['name']}${facts['os']['release']['major']}" |>

  # Realize application repos
  Yumrepo <| tag == $application |>
}

This example demonstrates how to implement the business logic of a specific repository structure while tracking the needs of different operating systems, all while utilizing the common yumrepo resource.

Defining Module Relationships in Profiles

Profiles are the place to create relationships between modules and their dependencies. By creating intermodule relationships at the profile level, your modules neither need to be concerned with how their dependencies are satisfied nor how they relate to other modules.

Remember: profiles use site-specific data to assemble working services based on business logic. There is absolutely nothing wrong with creating relationships in your profiles when needed. If the installer for a component module requires Java, the profile should include Java and order it before the component module. This allows the component module’s requirements to be satisfied differently for different use cases.

Just as with module development, it’s a good idea to keep your profiles self-contained for a singular use case. Try to avoid creating relationships between profiles in your profiles; instead, try to make your profiles as self-sufficient as possible, and use roles to establish profile relationships where necessary.

Keep in mind that in many cases, it’s not necessary to create relationships between modules. As we saw in “A Sample Service Profile”, it is often sufficient to simply add modules to your profile. Use relationships to identify dependencies, not to attempt to control the order. Attempts to specify ordering will only create unnecessary conflicts.

Creating Metaprofiles to Group Configurations

If you find that certain combinations of profiles are common, it’s both reasonable and effective to create a metaprofile that connects the various profiles. Especially if there are a few parameters that would influence the implementation of each of those profiles, a metaprofile provides a great way to group together combinations by business logic.

The example that follows utilizes business logic for a standard web server build, making use of the profiles described previously. It provides a common technology stack without stipulating the specific usage details (e.g., WordPress, static content, whatever) that a specific profile can supply.

class profile::webserver(
  Boolean $external = true,
) {
  include profile::yumrepos
  include profile::antivirus
  include profile::apache
  include profile::mail::client
  if( $external ) {
    include profile::firewall
  }
}

This metaprofile provides a reusable combination of profiles that a specific application profile or role could apply. In doing so it provides a useful building block for role composition.

Tip

A role is a metaprofile with a single use. This metaprofile isn’t usable as a role because it doesn’t include the unique part of the role—the actual application being served.

Let’s next discuss the good and bad, things to do, and things to watch out for when using metaprofiles.

Avoid creating a base profile

It’s a common rookie mistake to create a base metaprofile that will be assigned to every node. The expectation is that this base profile would handle all basic system configuration tasks, security policy, and postprovisioning tasks. Creating a metaprofile like this can definitely be useful, but it can also constrain you quite a bit.

The problem with base profiles is that because every node uses them, people tend to hack every one-off configuration into the base profile. Instead of having a reusable component that can be utilized, it quickly becomes a deep web of interlaced dependencies that are impossible to untangle. Yes, profiles are supposed to encapsulate the quirks of a specific implementation. Base profiles often quickly become the home for every quirk and one-off configuration. In short, it recreates the monolith that you were trying to avoid.

Warning

A profile should provide a single implementation. If you have conditionals for wildly different use cases in the same profile, you should separate them.

Encapsulate one-off configurations in new profiles

Instead of hacking quirks for multiple use cases into a common base, you should create a new profile that contains the specific quirk. Allow each role or metaprofile to select which implementation to use.

class profile::webserver::nginx() {
  include 'profile::webserver'
  include 'nginx'
}

Designing an Appropriate Profile Structure

How do you structure your profiles when you have a lot of service profiles that share the same basic design patterns? By breaking them down into the smallest reusable components.

Implement the smallest usable component

The WordPress profile we created in Example 7-2 installs the web server and the database on the same node. In any realistic production service that functionality would be provided by multiple roles. By splitting the WordPress profile into its two major components, we can easily create three roles containing different combinations of those profiles. We have standalone MySQL and WordPress roles for larger scale production hosts, and and a combined role for test nodes.

Handling multiple instances

Many profiles will need to implement multiple instances of something. Because a profile is implemented by a class singleton, you cannot declare it multiple times. Yet a single node can, for example, host multiple Java applications in a single Tomcat application server. Each application will likely use additional tomcat::instance and tomcat::service instances.

There are two different approaches you can use to solve this problem:

  • Create a unique profile for each application instance that does the following:

    • Includes the Tomcat profile (which will occur only once)

    • Configures the application

  • Accept array or hash input that specifies a list of instances to create so that a single profile can create the entire list of instances.

Each of these approaches has different advantages and disadvantages. Choose the one that is easier for you to maintain based on how you handle the data.

You can use both approaches together. For example, you could create different profiles that configure different database types. These profiles could all accept a list of nodes involved in database replication.

A profile for each service instance

With this structure, each service instance has its own profile, and each profile is independent of the other service profiles. Common dependencies can be implemented by a shared profile, whereas relationships are custom to the instance profile. This is the appropriate solution when you have a limited number of well-known service instances. Using Tomcat as an example, there would be a unique profile for each application instance, as demonstrated here:

class profile::tomcat::titleservice() {
  include 'profile::tomcat'

  tomcat::instance { 'titleservice':
    catalina_home => '/opt/tomcat',
    catalina_base => '/opt/titleservice/root',
  }
}

This approach tends to be highly flexible; each profile can have unique dependencies and logic. This provides a lot of flexibility while remaining fairly DRY. You can use resource-style class declarations in the profile. Each instance profile includes the shared service profile, which avoids duplicate resource declaration errors even with class-style resource declaration.

Creating instances from a data example

If your profile needs to create multiple instances of a service using a single profile, you can pass a list of instances as an array or hash using a profile class parameter, as illustrated in the example that follows. This approach works best when you don’t know in advance what instances are needed and want to allow another group to specify them.

class profile::tomcat(
  Hash $instances = {}.
) {
  include 'profile::tomcat'

  $instances.each |$name, $values| {
    tomcat::instance { $name:
      catalina_home => $values['catalina_home'],
      catalina_base => $values['catalina_base'],
    }
  }
}

This approach tends to keep your code DRY—the same profile creates all of your instances from a single iteration loop. The disadvantage of this approach is that all of your instances need to be fairly generic. There’s limited ability to customize each instance outside of the data attributes provided. Handling special cases with this type of profile will often result in code in data problems that are difficult to test and painful to debug.

Testing Roles and Profiles

Test cases can help ensure the stability of your profiles and catch common problems prior to the release of your code. Utilizing role-based node configuration facilitates testing without an ENC. It’s fairly straightforward (and implemented automatically by development kits) to apply a role to a test node using the puppet apply command.

We don’t cover how to implement unit and acceptance tests, because they are identical to the ones added to any component module. Instead, the following sections will provide guidance on the specifics of role and profile testing.

Tip

For simplicity of language we refer only to profiles in the remainder of this section. Every statement made about profiles is true of roles (single-purpose metaprofiles), as well.

Validating Profiles by Using Unit Tests

Unit tests verify that each class, defined type, and resource used to build the profile are added to the catalog correctly. It validates that the Puppet catalog will contain resources to do what is expected. This primarily helps identify code mistakes and regression failures—where a profile change stops providing an expected feature.

Profiles should not reimplement all of the test cases created for the profile’s component modules. Instead, it seeks to test that each of those modules is declared correctly.

The following example tests the WordPress profile built in Example 7-2.

context 'with default parameters' do
  # Ensure no parameters are supplied for this test context
  let(:params) do
    {}
  end

  # Declare the dependency classes with the profile's default values
  it { is_expected.to
         contain_class('mysql::bindings').with('php_enable' => true) }
  it { is_expected.to
         contain_apache__vhost(facts['fqdn']).with('port' => '80') }
  it { is_expected.to
         contain_class('wordpress').with('install_dir' => '/var/www/html') }
end

This example tests that a profile would invoke the component modules correctly, without recreating any of the tests of those modules. It can do this without knowing the internal structure of the modules that comprise the profile. Unit tests validate that the catalog will be built without error and ensure the stability of your profile interfaces.

Confirming Profile Implementation with Acceptance Tests

Acceptance tests apply the profile to the node and then verify that the expected changes are made. These tests measure the state before and after the test to confirm that the real-life expected results are visible on the node after Puppet has applied the profile.

You should supply each profile with an acceptance test specific to that profile. The goal of a profile acceptance test is not to reimplement all the tests created for the component modules. Instead, it should test the combined impact of the modules working together as a whole. The acceptance test provides end-to-end validation ensuring that the profile creates the model it was designed to implement.

At a minimum, test that the service is running as expected and that it responds as expected by creating a Beaker acceptance test for the profile.

The following example tests the WordPress profile built in Example 7-2.

  # test that the profile applies idempotently
  it 'should apply a second time without changes' do
    @result = apply_manifest(manifest)
    expect(@result.exit_code).to be_zero
  end

  # Make sure Apache is running with configured port
  describe service('httpd') do
    it { is_expected.to be_running }
    it { is_expected.to be_enabled }
  end
  describe port('80') do
    it { is_expected.to be_listening }
  end

This example tests that the httpd process is actively running and listening on the specified TCP port. This validates that the profile’s parameters have been applied correctly, and it can do it without knowing the internal structure of the modules that comprise the profile.

You can refer to the documentation and guides for Beaker acceptance testing for more information.

Summary

The roles and profiles design pattern applies business logic to create site-specific configurations utilizing generic component modules.

The key takeaways from this chapter are as follows:

  • Roles provide complete node configuration by selecting appropriate profiles.

  • Profiles implement and encapsulate business logic.

  • Profiles provide opinionated use cases for unopinionated component modules.

  • Use class ordering and containment in the profile to ensure that modules are applied in the correct order.

  • There is no golden rule for profile design: they should reflect the needs of your specific organization.

  • Use SoC and single responsibility principle as guiding principles for profile design.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.129.13.201