Image
![IT Automation ebook](/sysadmin/sites/default/files/styles/image_cta/public/2021-07/automation-expert-e-book-medium.jpg?itok=X_OfbjdH)
Photo by Kindel Media from Pexels
Policy as code is one of the most popular trends in the cloud-native space. Many discussions about policy as code focus on tools like Kubernetes, web services, and identity and access management (IAM). However, policy as code practices are equally effective for more traditional systems administration tasks.
[ Do you know the difference between Red Hat OpenShift and Kubernetes? ]
This article will show you how to use Conftest to enforce policy-as-code decisions against Ansible Playbooks.
Open Policy Agent (OPA) is a Cloud Native Computing Foundation graduated project that allows you to define and evaluate policy as code. OPA has several deployment models, including an HTTP-based server and a library. While it is commonly used in Kubernetes environments, OPA also provides a command-line interface for directly working with policy.
OPA operates on JSON input. JSON works well for web services, such as Kubernetes admission webhooks. However, policy-as-code patterns are useful for much more than just web services. Many applications rely on various types of structured configurations, such as YAML, TOML, INI, and other file formats. Conftest extends OPA to support different kinds of structured text files.
OPA policy is defined using the Rego policy language. Rego makes it easy to query complex, hierarchical structured data and provides many features to enable reusable and composable policies.
This article assumes a basic understanding of Rego. Review the basic Rego introduction if you have never worked with Rego policies before. The examples in this article are very simple and provide a basic understanding of Rego.
Conftest is available as a binary utility you can download from its releases page. Packages are also available in RPM and DEB formats. Install Conftest by downloading the binary or with a call to your package manager:
# dnf install -y https://github.com/open-policy-agent/conftest/releases/download/v0.38.0/conftest_0.38.0_linux_amd64.rpm
Last metadata expiration check: 0:00:46 ago on Wed 18 Jan 2023 11:03:05 AM EST.
conftest_0.38.0_linux_amd64.rpm 5.8 MB/s | 13 MB 00:02
Dependencies resolved.
=====================================================================================================================================================================================
Package Architecture Version Repository Size
=====================================================================================================================================================================================
Installing:
conftest x86_64 0.38.0-1 @commandline 13 M
Transaction Summary
=====================================================================================================================================================================================
Install 1 Package
Total size: 13 M
Installed size: 36 M
Downloading Packages:
Running transaction check
Transaction check succeeded.
Running transaction test
Transaction test succeeded.
Running transaction
Preparing : 1/1
Installing : conftest-0.38.0-1.x86_64 1/1
Verifying : conftest-0.38.0-1.x86_64 1/1
Installed:
conftest-0.38.0-1.x86_64
Complete!
[root@fedora-1 ~]# conftest -v
Conftest: 0.38.0
OPA: 0.48.0
[ Want to test your sysadmin skills? Take a skills assessment today. ]
For this article, I will develop an installation policy for the MariaDB-server
package. I have added the MariaDB repositories for MariaDB versions 10.8 and 10.9 to a remote server to enable the installation of several different MariaDB versions:
# dnf list --showduplicates Mariadb-server
Last metadata expiration check: 0:12:39 ago on Wed 18 Jan 2023 11:13:37 AM EST.
Available Packages
MariaDB-server.x86_64 10.8.4-1.fc36 mariadb-10.8
MariaDB-server.x86_64 10.8.5-1.fc36 mariadb-10.8
MariaDB-server.x86_64 10.8.6-1.fc36 mariadb-10.8
MariaDB-server.x86_64 10.9.2-1.fc36 mariadb-10.9
MariaDB-server.x86_64 10.9.3-1.fc36 mariadb-10.9
MariaDB-server.x86_64 10.9.4-1.fc36 mariadb-10.9
mariadb-server.x86_64 3:10.5.13-1.fc36 fedora
mariadb-server.x86_64 3:10.5.18-1.fc36 updates
While the repositories expose several versions for installation, I would like to limit the installable versions to the latest version of each minor release (10.8.6 and 10.9.4). I want to ensure that an Ansible Playbook to install MariaDB-server
will install an approved version.
This type of policy can be expressed as code using Rego and evaluated using conftest
. I find it helpful to express the policy in plain language before writing any code:
If a play installs the MariaDB-server package, it should be an approved version.
With this plain-text definition in place, it is possible to translate it into code.
An Ansible Playbook is written in YAML, but OPA operates on JSON input. Conftest takes existing structured configuration files and converts them to JSON. To evaluate a structured configuration file, you must understand how the configuration file is presented to the policy.
The playbook below installs a specific version of the MariaDB-server
package. The first step in understanding how conftest
parses this playbook is understanding how the policy code will be structured:
---
- name: Install MariaDB
hosts: dbservers
tasks:
- name: Install MariaDB-server
ansible.builtin.package:
name: MariaDB-server-10.8.4-1.fc36
state: present
The parse
subcommand processes a file and outputs the resulting JSON input to an OPA policy. The Ansible Playbook to install MariaDB results in the following JSON:
$ conftest parse playbook.yaml
[
{
"hosts": "dbservers",
"name": "Install MariaDB",
"tasks": [
{
"ansible.builtin.package": {
"name": "MariaDB-server-10.8.4-1.fc36",
"state": "present"
},
"name": "Install MariaDB-server"
}
]
}
]
This JSON is presented to a Rego policy as part of the input document. Rego queries this input document to make policy decisions.
[ Learn how to manage your Linux environment for success. ]
Once you understand the desired policy outcome and the input document format, you can begin writing the policy. First, create a new policy/
directory and place a main.rego
file in it. Conftest searches for Rego files in the policy/
subdirectory by default. You can change this behavior with the -p
flag to specify different directories.
$ mkdir policy
$ touch policy/main.rego
$ tree
.
├── inventory.yaml
├── playbook.yaml
└── policy
└── main.rego
The main.rego
file contains the policy. This file can have any name, and you can define policies across multiple files. The first statements in the main.rego
file contain the necessary boilerplate code. A Rego policy must have a package statement. Conftest will process policies only in the main
package by default, but you can modify this behavior by passing the --all-namespaces
flag. Importing certain future keywords make policies easier to read:
package main
import future.keywords.contains
import future.keywords.if
import future.keywords.in
Next, define a list of allowed MariaDB versions. This simple example stores this information alongside the policy code. However, it often makes sense to store data used in decision making, such as a list of allowed application versions, outside the policy code itself. Conftest can consume external data using the -d
flag, but this option is outside the scope of this example.
allowed_mariadb_versions = [
"10.8.6",
"10.9.4"
]
Define a single rule called deny_unapproved_version
. The name of a rule is significant to conftest
:
deny
rule will trigger a policy evaluation failure and a non-zero exit status.warn
rule will trigger a warning, and the policy evaluation will still exit successfully.violation
rule behaves like a deny
rule but allows you to specify additional fields in the returned message.
deny_unapproved_version contains msg if {
some task in input.tasks
package_name := task["ansible.builtin.package"].name
regex.match("MariaDB-server.*", package_name)
version := split(package_name, "-")[2]
not version in allowed_mariadb_versions
msg := sprintf("Version %v of Mariadb-Server is not allowed", [version])
}
Rego is a query language, and this rule searches across the JSON input to find conditions that make the rule evaluate to true. Each statement in the rule body must evaluate to true. If all its statements evaluate to true, this rule generates a virtual document containing a message to the user.
The rule searches across all tasks in the playbook to find any using the ansible.builtin.package
module. It checks whether the package name matches the MariaDB-server
regular expression. If it does, the package name is split on the -
character to obtain the version number. If the version is not in the list of allowed versions, then the not version in allowed_mariadb_versions
statement evaluates to true, and a message is returned to the user.
If any of these conditions fails, the rule evaluation halts, the policy evaluation passes, and no message is returned to the user.
Here are the full contents of the main.rego
file:
package main
import future.keywords.contains
import future.keywords.if
import future.keywords.in
allowed_mariadb_versions = [
"10.8.6",
"10.9.4"
]
deny_unapproved_version contains msg if {
some task in input.tasks
package_name := task["ansible.builtin.package"].name
regex.match("MariaDB-server.*", package_name)
version := split(package_name, "-")[2]
not version in allowed_mariadb_versions
msg := sprintf("Version %v of Mariadb-server is not allowed", [version])
}
The test
subcommand executes conftest
against an input file, such as an Ansible Playbook. Conftest will parse the input file, evaluate the policies in the policy/
subdirectory, and return any messages to the user. The current playbook fails the policy evaluation because MariaDB-server
version 10.8.4 is not included in the list of allowed versions. The command also exits with a non-zero exit status, which is useful in pipelines to signal failure.
$ conftest test playbook.yaml
FAIL - playbook.yaml - main - Version 10.8.4 of Mariadb-server is not allowed
1 test, 0 passed, 0 warnings, 1 failure, 0 exceptions
$ echo $?
1
Adjusting the playbook to use an approved version of MariaDB-server
, such as version 10.8.6, allows the playbook to pass policy evaluation, and the conftest
command exits with a zero exit status:
# Replace the MariaDB-server version 10.8.4 with 10.8.6 (an approved version)
$ sed -ie 's/MariaDB-server-10.8.4-1.fc36/MariaDB-server-10.8.6-1.fc36/g' playbook.yaml
$ conftest test playbook.yaml
1 test, 1 passed, 0 warnings, 0 failures, 0 exceptions
$ echo $?
0
[ Get started with IT automation with the Ansible Automation Platform beginner's guide. ]
This article introduced conftest
, a tool that evaluates OPA policies against structured configuration files. You saw a simple example of how a policy can be expressed as code and evaluated against an Ansible Playbook. While this is a basic example, conftest
provides many features that make it an ideal tool for extending policy as code practices to other types of system configuration. I encourage you to visit the Conftest website to learn more and develop your own ideas for leveraging policy as code in your automation workflows.
I also encourage you to build on this example by extending the policy to match tasks where no version is specified for MariaDB-server
or to match tasks that directly use a specific package manager's module, such as the ansible.builtin.dnf
module.
Anthony Critelli is a Linux systems engineer with interests in automation, containerization, tracing, and performance. He started his professional career as a network engineer and eventually made the switch to the Linux systems side of IT. He holds a B.S. and an M.S. More about me