Iterating on CFEngine policy for pinning packages in APT

Posted by Nick Anderson
July 20, 2023

I was chatting with someone recently about some security maintenance tasks and they were bemoaning that some software updates had turned into a yack shaving1. Updating this required updating that, required updating that on N hosts of varying platforms and flavors. So, they asked me how could they avoid updating a specific package and naturally I said, let’s just prototype some policy.

The incipiency of said yak shaving was updating packages via apt, Debian flavored systems default package manager. apt upgrade is being used to update all packages, but we need to exclude a specific package from the updates until the dependency chain has been resolved.

What we are talking about is simply package pinning. This can be done by creating a file /etc/apt/preferences.d/yourpackage.

Package: yourpackage
Pin: version 1.2.3
Pin-Priority: 999

Iteration 0 - A very simple prototype

We can write a very simple policy to achieve this. The most simple solution that I could think of looked like this:

bundle agent __main__
{
    files:
      "/etc/apt/preferences.d/zpackage"
        content => "Package: zpackage
Pin: version 1.2.3
Pin-Priority: 999
";

}

It’s not an ideal policy, it’s just a simple prototype. Let’s iterate on it a bit.

Iteration 1 - Specifying the proper file permissions

First, what about permissions? By default CFEngine will create files with mode of 600 (Owner read-write, group nothing, other nothing). I failed to find any canonical reference as to what the permissions for the file should be, but intuitively 644 (owner read-write, group read, other read) with user and group owners being root seems appropriate and comports with the default permissions of a the directory on some spot checked Debian systems.2

bundle agent __main__
{
    files:
      "/etc/apt/preferences.d/zpackage"
        perms => default:mog( "644", "root", "root" ),
        content => "Package: zpackage
Pin: version 1.2.3
Pin-Priority: 999
";

}

I don’t like that way that looks, I find the string wrapping ugly.

Iteration 2 - Use concat() to make the policy more readable

We can use concat() to make things align.

bundle agent __main__
{
    files:
      "/etc/apt/preferences.d/zpackage"
        perms => default:mog( "644", "root", "root" ),
        content => concat( "Package: zpackage", "$(const.n)",
                           "Pin: version 1.2.3", "$(const.n)",
                           "Pin-Priority: 999", "$(const.n)" );

}

We are repeating the package name, that’s not very DRY.3

Iteration 3 - Use with to avoid repetition

We can use the with attribute to avoid a case of repetition.

bundle agent __main__
{
    files:
      "/etc/apt/preferences.d/$(with)"
        perms => default:mog( "644", "root", "root" ),
        with => "zpackage",
        content => concat( "Package: $(with)", "$(const.n)",
                           "Pin: version 1.2.3", "$(const.n)",
                           "Pin-Priority: 999", "$(const.n)" );

}

That’s about as far as I can get at the current level of abstraction. It’s still not reusable, let’s move it to a bundle so that it’s more reusable.

Iteration 4 - Use a parameterized bundle to make the policy reusable

Here we move the promise to it’s own agent bundle apt_package_version_pinned taking parameters for the name of the package and the version we wish to pin.

bundle agent __main__
{
      methods:
        "Ensure apt package is pinned to version"
          usebundle => apt_package_version_pinned( "zpackage", "1.2.3" );
}

bundle agent apt_package_version_pinned( name, version )
# @brief Ensure that `name` is pinned to `version` for the apt package manager
# @param `name` The name of the package to pin
# @param `version` The name of the package to pin
{
  files:
      "/etc/apt/preferences.d/$(name)"
        perms => default:mog( "644", "root", "root" ),
        content => concat( "Package: $(name)", "$(const.n)",
                           "Pin: version $(version)", "$(const.n)",
                           "Pin-Priority: 999", "$(const.n)" );
}

What about when I am ready to clear this pinned configuration? Let’s make it’s pinned state configurable.

Iteration 5 - Adding a parameter to specify the state of the pin

Here we have added the present_absent parameter to the bundle which let’s us specify if we want the package pin configuration to be present or absent. This iteration also added a promise to ensure the configuration directory itself was present with appropriate permissions when we are requesting a present state and the configuration file also gets a header comment indicating CFEngine manages the file.

bundle agent __main__
{
      methods:
        "Ensure apt package is pinned to version"
          usebundle => apt_package_version_pinned( "zpackage", "1.2.3", "present" );
}

bundle agent apt_package_version_pinned( name, version, present_absent )
# @brief Ensure that `name` is pinned to `version` for the apt package manager or not based on the desired state
# @param `name` The name of the package to pin, case matters.
# @param `version` The version of the package to pin
# @param `present_absent` The desired state of the package pin (present|absent), case does not matter. Note: the version does not matter when absent is specified.
{
  files:
      "/etc/apt/preferences.d/."
        perms => default:mog( "755", "root", "root" ),
        if => regcmp( "(?i)present", "$(present_absent)" ),
        comment => concat( "If we are promising that a package",
                           " should be pinned, the configuration",
                           " directory should exist." );

      "/etc/apt/preferences.d/$(name)"
        perms => default:mog( "644", "root", "root" ),
        content => concat( "# Managed by CFEngine", "$(const.n)",
                           "Package: $(name)", "$(const.n)",
                           "Pin: version $(version)", "$(const.n)",
                           "Pin-Priority: 999", "$(const.n)" ),
        if => regcmp( "(?i)present", "$(present_absent)" );

      "/etc/apt/preferences.d/$(name)"
        delete => default:tidy,
        if => regcmp( "(?i)absent", "$(present_absent)" );
}

Now we have a re-usable bundle which can be used to specify a packages that should be pinned to a specific version or not.

The next logical step would be to elevate this policy to a CFEngine Build4 module so that it’s easier for others to access and use. Will someone beat me to the punch?


  1. Yack shaving, a task that leads you to perform another related task and so on, and so on – distracting from the original goal. https://americanexpress.io/yak-shaving/ ↩︎

  2. I would appreciate it greatly if you would point me to a canonical answer for what the proper permissions for these configuration files should be. I was unable to find any specific mention across Debian bug trackers and man pages. ↩︎

  3. DRY: Don’t Replete Yourself https://en.wikipedia.org/wiki/Don%27t_repeat_yourself ↩︎

  4. The CFEngine Build website and main index of modules https://build.cfengine.com ↩︎