diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb8036d..edc09e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,20 +23,19 @@ jobs: strategy: matrix: os: - - "debian-9" - - "debian-10" - - "ubuntu-1604" - - "ubuntu-1804" - - "centos-7" + - "debian-12" + - "ubuntu-2404" suite: - "default" + - "entry" + - "rule" fail-fast: false steps: - name: Check out code - uses: actions/checkout@v5 - - name: Install Chef - uses: actionshub/chef-install@main + uses: actions/checkout@v6 + - name: Install Cinc Workstation + uses: sous-chefs/.github/.github/actions/install-workstation@main - name: Dokken uses: actionshub/test-kitchen@main env: diff --git a/.github/workflows/conventional-commits.yml b/.github/workflows/conventional-commits.yml index 839260b..21ce779 100644 --- a/.github/workflows/conventional-commits.yml +++ b/.github/workflows/conventional-commits.yml @@ -12,3 +12,5 @@ name: conventional-commits jobs: conventional-commits: uses: sous-chefs/.github/.github/workflows/conventional-commits.yml@5.0.8 + permissions: + pull-requests: write diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 3fa3ae3..dba4a46 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -1,5 +1,5 @@ --- -name: 'Copilot Setup Steps' +name: "Copilot Setup Steps" "on": workflow_dispatch: @@ -17,7 +17,7 @@ jobs: contents: read steps: - name: Check out code - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install Chef uses: actionshub/chef-install@main - name: Install cookbooks diff --git a/.github/workflows/prevent-file-change.yml b/.github/workflows/prevent-file-change.yml index abbb46d..c4e46b9 100644 --- a/.github/workflows/prevent-file-change.yml +++ b/.github/workflows/prevent-file-change.yml @@ -12,5 +12,7 @@ name: prevent-file-change jobs: prevent-file-change: uses: sous-chefs/.github/.github/workflows/prevent-file-change.yml@5.0.8 + permissions: + pull-requests: write secrets: token: ${{ secrets.GITHUB_TOKEN }} diff --git a/Berksfile b/Berksfile index 34fea21..4c37302 100644 --- a/Berksfile +++ b/Berksfile @@ -1,3 +1,9 @@ +# frozen_string_literal: true + source 'https://supermarket.chef.io' metadata + +group :integration do + cookbook 'test', path: 'test/cookbooks/test' +end diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a531f4..1678a39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,14 +2,12 @@ ## [0.2.16](https://github.com/sous-chefs/control_groups/compare/v0.2.15...v0.2.16) (2026-01-06) - ### Bug Fixes * **ci:** Update workflows to use release pipeline ([#78](https://github.com/sous-chefs/control_groups/issues/78)) ([063087d](https://github.com/sous-chefs/control_groups/commit/063087dc2f1ca05a13ad5d7f71580314ee68418c)) ## [0.2.15](https://github.com/sous-chefs/control_groups/compare/0.2.14...v0.2.15) (2025-10-15) - ### Bug Fixes * **ci:** Update workflows to use release pipeline ([#78](https://github.com/sous-chefs/control_groups/issues/78)) ([063087d](https://github.com/sous-chefs/control_groups/commit/063087dc2f1ca05a13ad5d7f71580314ee68418c)) diff --git a/LIMITATIONS.md b/LIMITATIONS.md new file mode 100644 index 0000000..102434f --- /dev/null +++ b/LIMITATIONS.md @@ -0,0 +1,27 @@ +# Limitations + +This cookbook now targets the resource-oriented API only and is validated against a narrow, explicit support matrix. + +## Supported platforms + +- Debian 12: `packages.debian.org` publishes `cgroup-tools`, `libcgroup2`, and `libpam-cgroup` for Bookworm, and this repository now runs Kitchen coverage for Debian 12. +- Ubuntu 24.04: `packages.ubuntu.com` lists `libpam-cgroup` in Noble's `admin` section, confirming the libcgroup userspace packages are still published for the current LTS. + +## Researched but not supported + +- Amazon Linux 2023: AWS documents `libcgroup-tools` on AL2023, but the same documentation states AL2023 uses cgroup v2 and recommends `systemd` resource control instead. This cookbook still renders classic `cgconfig.conf` and `cgrules.conf` files, so AL2023 is documented as a limitation rather than an advertised target. +- openSUSE Leap: the modern `software.opensuse.org` results for related cgroup packages are either absent or community/experimental, so this cookbook does not claim support. +- RHEL-family clones: the repository no longer advertises CentOS or clone support without current package and runtime validation. +- Dokken / cgroup-v2 containers: a direct `kitchen converge` on Debian 12 and Ubuntu 24.04 fails when `cgconfigparser` attempts to mount controller hierarchies from `cgconfig.conf` and receives `Operation not permitted`. The Kitchen suites therefore run with `manage_runtime false`, which verifies package installation, config generation, and systemd unit creation without attempting to start the libcgroup daemons in a cgroup-v2 container. + +## Architecture notes + +- Debian and Ubuntu publish the libcgroup packages for multiple architectures through their normal package repositories. +- This cookbook does not attempt source builds or vendor repositories; it relies on distro-packaged libcgroup utilities only. + +## Source URLs + +- Debian package index: +- Ubuntu package index: +- Amazon Linux 2023 cgroups guidance: +- Amazon Linux 2023 cgroup v2 note: diff --git a/README.md b/README.md index a636ef9..df544a3 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,15 @@ Manage control groups (cgroups) via chef! +## Supported platforms + +- Debian 12 +- Ubuntu 24.04 + +See [`LIMITATIONS.md`](LIMITATIONS.md) for the researched support policy and unsupported platforms. + +Current unit verification passes on this repository. The Kitchen suites run on Debian 12 and Ubuntu 24.04 with `manage_runtime false` because Dokken/cgroup-v2 containers cannot start the legacy libcgroup mount workflow. See [`LIMITATIONS.md`](LIMITATIONS.md) for the runtime caveat. + ## Maintainers This cookbook is maintained by the Sous Chefs. The Sous Chefs are a community of Chef cookbook maintainers working together to maintain important cookbooks. If you’d like to know more please visit [sous-chefs.org](https://sous-chefs.org/) or come chat with us on the Chef Community Slack in [#sous-chefs](https://chefcommunity.slack.com/messages/C2V7B88SF). @@ -15,13 +24,15 @@ This cookbook is maintained by the Sous Chefs. The Sous Chefs are a community of ## Example usage ```ruby +control_groups_install 'default' + control_groups_entry 'lackresources' do - memory('memory.limit_in_bytes' => '1M') - cpu('cpu.shares' => 1) + memory('memory.max' => '1048576') + cpu('cpu.max' => '10000 100000') end control_groups_rule 'someuser' do - controllers [:cpu, :memory] + controllers %w(cpu memory) destination 'lackresources' end ``` diff --git a/attributes/default.rb b/attributes/default.rb deleted file mode 100644 index b2597a0..0000000 --- a/attributes/default.rb +++ /dev/null @@ -1,8 +0,0 @@ -default['control_groups']['mounts'] = { - cpu: '/sys/fs/cgroup/cpu', - cpuacct: '/sys/fs/cgroup/cpuacct', - cpuset: '/sys/fs/cgroup/cpuset', - devices: '/sys/fs/cgroup/devices', - memory: '/sys/fs/cgroup/memory', - freezer: '/sys/fs/cgroup/freezer', -} diff --git a/documentation/control_groups_entry.md b/documentation/control_groups_entry.md new file mode 100644 index 0000000..f5d70e1 --- /dev/null +++ b/documentation/control_groups_entry.md @@ -0,0 +1,49 @@ +# control_groups_entry + +Define or remove a cgroup entry in `/etc/cgconfig.conf`. + +## Actions + +| Action | Description | +| ---------- | -------------------------------------------------- | +| `:create` | Adds or updates the named cgroup entry (default) | +| `:delete` | Removes the named cgroup entry | + +## Properties + +- `group`: `String`, defaults to the `name` property. Name of the cgroup entry. +- `perm_task_uid`: `String`, defaults to `nil`. Task owner UID. +- `perm_task_gid`: `String`, defaults to `nil`. Task owner GID. +- `perm_admin_uid`: `String`, defaults to `nil`. Admin owner UID. +- `perm_admin_gid`: `String`, defaults to `nil`. Admin owner GID. +- `cpu`: `Hash`, defaults to `nil`. CPU controller settings. +- `cpuacct`: `Hash`, defaults to `nil`. CPU accounting controller settings. +- `devices`: `Hash`, defaults to `nil`. Device controller settings. +- `freezer`: `Hash`, defaults to `nil`. Freezer controller settings. +- `memory`: `Hash`, defaults to `nil`. Memory controller settings. +- `extra_config`: `Hash`, defaults to `{}`. Additional key/value pairs rendered inside the group. +- `mounts`: `Hash`, defaults to `ControlGroups.default_mounts`. Mount map written into `/etc/cgconfig.conf` before the group stanza. +- `manage_runtime`: `Boolean`, defaults to `true`. When `true`, enables and starts the libcgroup systemd units. Set to `false` in Dokken or other cgroup-v2 test environments. + +## Examples + +```ruby +control_groups_entry 'limited' do + cpu('cpu.max' => '10000 100000') + memory('memory.max' => '1048576') +end +``` + +```ruby +control_groups_entry 'limited' do + perm_task_uid 'root' + extra_config('notify_on_release' => '1') +end +``` + +```ruby +control_groups_entry 'limited' do + cpu('cpu.max' => '10000 100000') + manage_runtime false +end +``` diff --git a/documentation/control_groups_install.md b/documentation/control_groups_install.md new file mode 100644 index 0000000..c37ec0b --- /dev/null +++ b/documentation/control_groups_install.md @@ -0,0 +1,37 @@ +# control_groups_install + +Install and remove the libcgroup packages, service units, and generated configuration files used by this cookbook. + +## Actions + +| Action | Description | +| ---------- | ----------------------------------------------------------------------------------------------------- | +| `:install` | Installs packages, writes the config files, and enables the `cgconfig` and `cgred` services (default) | +| `:remove` | Stops the services, deletes the config files, and removes installed packages | + +## Properties + +- `name`: `String`, defaults to `name`. Resource identity. +- `mounts`: `Hash`, defaults to `ControlGroups.default_mounts`. Mount map written into `/etc/cgconfig.conf`. +- `manage_runtime`: `Boolean`, defaults to `true`. When `true`, enables and starts the libcgroup systemd units. Set to `false` in cgroup-v2 test environments that cannot mount legacy controller hierarchies. + +## Examples + +```ruby +control_groups_install 'default' +``` + +```ruby +control_groups_install 'default' do + mounts( + cpu: '/sys/fs/cgroup/cpu', + memory: '/sys/fs/cgroup/memory' + ) +end +``` + +```ruby +control_groups_install 'default' do + manage_runtime false +end +``` diff --git a/documentation/control_groups_rule.md b/documentation/control_groups_rule.md new file mode 100644 index 0000000..66e7aed --- /dev/null +++ b/documentation/control_groups_rule.md @@ -0,0 +1,45 @@ +# control_groups_rule + +Define or remove an entry in `/etc/cgrules.conf`. + +## Actions + +| Action | Description | +| ---------- | --------------------------------------------------- | +| `:create` | Adds or updates the rule (default) | +| `:delete` | Removes the rule target from `/etc/cgrules.conf` | + +## Properties + +- `user`: `String`, defaults to the `name` property. User segment of the cgrules target. +- `command`: `String`, defaults to `nil`. Optional command segment of the target. +- `controllers`: `Array`, required. Controllers bound to the rule. +- `destination`: `String`, required. Destination group name. +- `mounts`: `Hash`, defaults to `ControlGroups.default_mounts`. Mount map written into `/etc/cgconfig.conf` before validating destinations. +- `manage_runtime`: `Boolean`, defaults to `true`. When `true`, enables and starts the libcgroup systemd units. Set to `false` in Dokken or other cgroup-v2 test environments. + +## Examples + +```ruby +control_groups_rule 'alice' do + controllers %w(cpu memory) + destination 'limited' +end +``` + +```ruby +control_groups_rule 'alice' do + command 'stress-ng' + controllers ['cpu'] + destination 'limited' +end +``` + +```ruby +control_groups_rule 'alice' do + command 'stress-ng' + controllers %w(cpu memory) + destination 'limited' + manage_runtime false +end +``` diff --git a/kitchen.dokken.yml b/kitchen.dokken.yml index 47eff95..9cfb26b 100644 --- a/kitchen.dokken.yml +++ b/kitchen.dokken.yml @@ -1,113 +1,22 @@ +--- driver: name: dokken privileged: true + chef_image: chef/chef chef_version: <%= ENV['CHEF_VERSION'] || 'current' %> -transport: { name: dokken } -provisioner: { name: dokken } - -platforms: - - name: almalinux-8 - driver: - image: dokken/almalinux-8 - pid_one_command: /usr/lib/systemd/systemd - - - name: almalinux-9 - driver: - image: dokken/almalinux-9 - pid_one_command: /usr/lib/systemd/systemd - - - name: amazonlinux-2023 - driver: - image: dokken/amazonlinux-2023 - pid_one_command: /usr/lib/systemd/systemd - - - name: centos-7 - driver: - image: dokken/centos-7 - pid_one_command: /usr/lib/systemd/systemd - - - name: centos-stream-8 - driver: - image: dokken/centos-stream-8 - pid_one_command: /usr/lib/systemd/systemd - - - name: centos-stream-9 - driver: - image: dokken/centos-stream-9 - pid_one_command: /usr/lib/systemd/systemd - - - name: debian-9 - driver: - image: dokken/debian-9 - pid_one_command: /bin/systemd - - - name: debian-10 - driver: - image: dokken/debian-10 - pid_one_command: /bin/systemd +transport: + name: dokken - - name: debian-11 - driver: - image: dokken/debian-11 - pid_one_command: /bin/systemd +provisioner: + name: dokken +platforms: - name: debian-12 driver: image: dokken/debian-12 pid_one_command: /bin/systemd - - - name: fedora-latest - driver: - image: dokken/fedora-latest - pid_one_command: /usr/lib/systemd/systemd - - - name: opensuse-leap-15 - driver: - image: dokken/opensuse-leap-15 - pid_one_command: /usr/lib/systemd/systemd - - - name: oraclelinux-7 - driver: - image: dokken/oraclelinux-7 - pid_one_command: /usr/lib/systemd/systemd - - - name: oraclelinux-8 - driver: - image: dokken/oraclelinux-8 - pid_one_command: /usr/lib/systemd/systemd - - - name: oraclelinux-9 - driver: - image: dokken/oraclelinux-9 - pid_one_command: /usr/lib/systemd/systemd - - - name: rockylinux-8 - driver: - image: dokken/rockylinux-8 - pid_one_command: /usr/lib/systemd/systemd - - - name: rockylinux-9 - driver: - image: dokken/rockylinux-9 - pid_one_command: /usr/lib/systemd/systemd - - - name: ubuntu-18.04 - driver: - image: dokken/ubuntu-18.04 - pid_one_command: /bin/systemd - - - name: ubuntu-20.04 - driver: - image: dokken/ubuntu-20.04 - pid_one_command: /bin/systemd - - - name: ubuntu-22.04 - driver: - image: dokken/ubuntu-22.04 - pid_one_command: /bin/systemd - - - name: ubuntu-23.04 + - name: ubuntu-24.04 driver: - image: dokken/ubuntu-23.04 + image: dokken/ubuntu-24.04 pid_one_command: /bin/systemd diff --git a/kitchen.global.yml b/kitchen.global.yml index a382fcd..5fddc8a 100644 --- a/kitchen.global.yml +++ b/kitchen.global.yml @@ -15,24 +15,5 @@ verifier: name: inspec platforms: - - name: almalinux-8 - - name: almalinux-9 - - name: amazonlinux-2023 - - name: centos-7 - - name: centos-stream-8 - - name: centos-stream-9 - - name: debian-9 - - name: debian-10 - - name: debian-11 - name: debian-12 - - name: fedora-latest - - name: opensuse-leap-15 - - name: oraclelinux-7 - - name: oraclelinux-8 - - name: oraclelinux-9 - - name: rockylinux-8 - - name: rockylinux-9 - - name: ubuntu-18.04 - - name: ubuntu-20.04 - - name: ubuntu-22.04 - - name: ubuntu-23.04 + - name: ubuntu-24.04 diff --git a/kitchen.yml b/kitchen.yml index b9382c0..e461d3c 100644 --- a/kitchen.yml +++ b/kitchen.yml @@ -1,29 +1,55 @@ +--- driver: name: vagrant +transport: + max_wait_until_ready: 600 + connection_retries: 10 + connection_retry_sleep: 10 + provisioner: - name: chef_zero + name: chef_infra product_name: chef product_version: <%= ENV['CHEF_VERSION'] || 'latest' %> + channel: stable install_strategy: once deprecations_as_errors: true - chef_license: accept-no-persist + chef_license: accept verifier: name: inspec + sudo: true platforms: - - name: amazonlinux-2 - - name: centos-7 - - name: centos-8 - - name: debian-9 - - name: debian-10 - - name: fedora-latest - - name: opensuse-leap-15 - - name: ubuntu-16.04 - - name: ubuntu-18.04 + - name: debian-12 + - name: ubuntu-24.04 + +x-run_lists: + default: &default_run_list + - recipe[test::default] + entry: &entry_run_list + - recipe[test::entry] + rule: &rule_run_list + - recipe[test::rule] + +x-verifiers: + default: &default_verifier + inspec_tests: + - path: test/integration/default + entry: &entry_verifier + inspec_tests: + - path: test/integration/entry + rule: &rule_verifier + inspec_tests: + - path: test/integration/rule suites: - name: default - run_list: - - recipe[control_groups::default] + run_list: *default_run_list + verifier: *default_verifier + - name: entry + run_list: *entry_run_list + verifier: *entry_verifier + - name: rule + run_list: *rule_run_list + verifier: *rule_verifier diff --git a/libraries/control_groups.rb b/libraries/control_groups.rb index 346572c..3cbed74 100644 --- a/libraries/control_groups.rb +++ b/libraries/control_groups.rb @@ -1,5 +1,20 @@ +# frozen_string_literal: true + module ControlGroups + DEFAULT_MOUNTS = { + cpu: '/sys/fs/cgroup/cpu', + cpuacct: '/sys/fs/cgroup/cpuacct', + cpuset: '/sys/fs/cgroup/cpuset', + devices: '/sys/fs/cgroup/devices', + memory: '/sys/fs/cgroup/memory', + freezer: '/sys/fs/cgroup/freezer', + }.freeze unless const_defined?(:DEFAULT_MOUNTS) + class << self + def default_mounts + normalize_hash(DEFAULT_MOUNTS) + end + def rules_struct_init(node) node.run_state[:control_groups] ||= Mash.new node.run_state[:control_groups][:rules] ||= Mash.new( @@ -7,15 +22,21 @@ def rules_struct_init(node) ) end - def config_struct_init(node) + def config_struct_init(node, mounts = default_mounts) node.run_state[:control_groups] ||= Mash.new node.run_state[:control_groups][:config] ||= Mash.new( structure: Mash.new, - mounts: Mash.new(node['control_groups']['mounts'].to_hash) + mounts: Mash.new(normalize_hash(mounts)) ) end - def build_rules(hash) + def build_target(user, command = nil) + [user, command].compact.join(':') + end + + def build_rules(hash, structure) + validate_rules!(hash, structure) + output = ['# This file created by Chef!'] unless hash.nil? || hash.empty? hash.to_hash.each_pair do |user, args| @@ -32,18 +53,45 @@ def build_config(hash) output.join("\n") << "\n" end + def validate_rules!(rules, structure) + return if rules.nil? || rules.empty? + + rules.to_hash.each_value do |rule| + destination = structure[rule[:destination]] || structure[rule['destination']] + raise "Invalid destination provided for rule (dest: #{rule[:destination] || rule['destination']})" unless destination + + Array(rule[:controllers] || rule['controllers']).each do |controller| + next if destination[controller] || destination[controller.to_s] + + raise "Invalid controller provided for rule (controller: #{controller})" + end + end + end + + def normalize_hash(hash) + hash.each_with_object({}) do |(key, value), acc| + acc[key.to_s] = + if value.respond_to?(:to_hash) + normalize_hash(value.to_hash) + else + value + end + end + end + + private + def builder(hash, array, indent = 0, prefix = nil) prefix = "#{prefix} " if prefix - hash.to_hash.each_pair do |k, v| - if v.is_a?(Hash) - array << "#{' ' * indent}#{prefix}#{k} {" - builder(v, array, indent + 2) + hash.to_hash.each_pair do |key, value| + if value.is_a?(Hash) + array << "#{' ' * indent}#{prefix}#{key} {" + builder(value, array, indent + 2) array << "#{' ' * indent}}" else - array << "#{' ' * indent}#{prefix}#{k} = #{v};" + array << "#{' ' * indent}#{prefix}#{key} = #{value};" end end - array.join("\n") << "\n" end end end diff --git a/libraries/helpers.rb b/libraries/helpers.rb index 2ad250a..7290882 100644 --- a/libraries/helpers.rb +++ b/libraries/helpers.rb @@ -1,18 +1,92 @@ -class Chef - module ControlGroups - module Helpers - def control_group_packages - case node['platform_family'] - when 'debian' - %w(cgroup-bin libcgroup1) - when 'rhel', 'fedora', 'amazon' - %w(libcgroup libcgroup-tools) - else - raise "Unsupported platform family encountered: #{node['platform_family']}" +# frozen_string_literal: true + +module ControlGroups + module Helpers + def control_group_packages + case node['platform_family'] + when 'debian' + %w(cgroup-tools libcgroup2 libpam-cgroup) + when 'rhel', 'fedora', 'amazon' + %w(libcgroup libcgroup-tools) + else + raise "Unsupported platform family encountered: #{node['platform_family']}" + end + end + + def initialize_control_group_state(mounts: ControlGroups.default_mounts) + ControlGroups.config_struct_init(node, mounts) + ControlGroups.rules_struct_init(node) + end + + def ensure_control_group_base_resources(manage_runtime: true) + config_file = edit_resource(:file, '/etc/cgconfig.conf') do + content lazy { ControlGroups.build_config(node.run_state[:control_groups][:config]) } + owner 'root' + group 'root' + mode '0644' + action :create + notifies :restart, 'systemd_unit[cgconfig.service]', :delayed if manage_runtime + end + + rules_file = edit_resource(:file, '/etc/cgrules.conf') do + content lazy { ControlGroups.build_rules(node.run_state[:control_groups][:rules][:active], node.run_state[:control_groups][:config][:structure]) } + owner 'root' + group 'root' + mode '0644' + action :create + notifies :restart, 'systemd_unit[cgred.service]', :delayed if manage_runtime + end + + control_group_packages.each do |package_name| + edit_resource(:package, package_name) do + action :install end end + + edit_resource(:systemd_unit, 'cgconfig.service') do + content( + Unit: { + Description: 'Configure libcgroup hierarchies', + ConditionPathExists: '/etc/cgconfig.conf', + After: 'local-fs.target', + }, + Service: { + Type: 'oneshot', + RemainAfterExit: true, + ExecStart: '/usr/sbin/cgconfigparser -l /etc/cgconfig.conf', + ExecStop: '/usr/sbin/cgclear -l /etc/cgconfig.conf', + }, + Install: { + WantedBy: 'multi-user.target', + } + ) + triggers_reload true + action manage_runtime ? %i(create enable start) : %i(create enable) + end + + edit_resource(:systemd_unit, 'cgred.service') do + content( + Unit: { + Description: 'Run libcgroup rules engine', + ConditionPathExists: '/etc/cgrules.conf', + After: 'cgconfig.service', + Requires: 'cgconfig.service', + }, + Service: { + Type: 'simple', + ExecStart: '/usr/sbin/cgrulesengd -n', + ExecReload: '/bin/kill -USR2 $MAINPID', + Restart: 'on-failure', + }, + Install: { + WantedBy: 'multi-user.target', + } + ) + triggers_reload true + action manage_runtime ? %i(create enable start) : %i(create enable) + end + + { config_file: config_file, rules_file: rules_file } end end end - -# enable `modprobe netprio_cgroup` diff --git a/metadata.rb b/metadata.rb index f835ab9..5b3b469 100644 --- a/metadata.rb +++ b/metadata.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + name 'control_groups' maintainer 'Sous Chefs' maintainer_email 'help@sous-chefs.org' @@ -6,8 +8,7 @@ version '0.2.16' source_url 'https://github.com/sous-chefs/control_groups' issues_url 'https://github.com/sous-chefs/control_groups/issues' -chef_version '>= 13' +chef_version '>= 15.3' -%w(ubuntu debian redhat centos suse opensuseleap scientific oracle amazon).each do |os| - supports os -end +supports 'debian', '>= 12.0' +supports 'ubuntu', '>= 24.04' diff --git a/recipes/default.rb b/recipes/default.rb deleted file mode 100644 index 902e465..0000000 --- a/recipes/default.rb +++ /dev/null @@ -1,35 +0,0 @@ -cgred_resource = service 'cgred' do - provider Chef::Provider::Service::Upstart - supports status: true, start: true, stop: true, reload: true - action :nothing -end - -cgconfig_resource = service 'cgconfig' do - provider Chef::Provider::Service::Upstart - supports status: true, start: true, stop: true, reload: true - ignore_failure true - action :nothing -end - -ruby_block 'control_groups[write configs]' do - block do - ::ControlGroups.config_struct_init(node) - ::ControlGroups.rules_struct_init(node) - c = Chef::Resource::File.new('/etc/cgconfig.conf', run_context) - c.content ::ControlGroups.build_config(node.run_state[:control_groups][:config]) - c.notifies :restart, cgconfig_resource, :immediately - c.run_action(:create) - r = Chef::Resource::File.new('/etc/cgrules.conf', run_context) - r.content ::ControlGroups.build_rules(node.run_state[:control_groups][:rules][:active]) - r.notifies :restart, cgred_resource, :immediately - r.run_action(:create) - end - action :nothing -end - -ruby_block 'control_group_configs[notifier]' do - block do - Chef::Log.debug 'Sending delayed notification to cgroup config files' - end - notifies :run, 'ruby_block[control_groups[write configs]]', :delayed -end diff --git a/resources/_partial/_config.rb b/resources/_partial/_config.rb new file mode 100644 index 0000000..332dbfe --- /dev/null +++ b/resources/_partial/_config.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +property :mounts, Hash, + desired_state: false, + default: lazy { ControlGroups.default_mounts } + +property :manage_runtime, [true, false], + desired_state: false, + default: true diff --git a/resources/entry.rb b/resources/entry.rb index c78a1c9..9df6064 100644 --- a/resources/entry.rb +++ b/resources/entry.rb @@ -1,3 +1,10 @@ +# frozen_string_literal: true + +provides :control_groups_entry +unified_mode true + +use '_partial/_config' + property :group, String, name_property: true property :perm_task_uid, String property :perm_task_gid, String @@ -8,35 +15,45 @@ property :devices, Hash property :freezer, Hash property :memory, Hash -property :extra_config, Hash +property :extra_config, Hash, default: {} -def load_current_resource - ::ControlGroups.config_struct_init(node) -end +default_action :create -action :create do - run_context.include_recipe 'control_groups' - - group_name = new_resource.group.to_s - group_struct = Mash.new(node.run_state[:control_groups][:config][:structure]) - perm = {} - %w(task admin).each do |type| - %w(uid gid).each do |idx| - if (val = new_resource.send("perm_#{type}_#{idx}")) - perm[type] ||= {} - perm[type][idx] = val +action_class do + include ControlGroups::Helpers + + def group_payload + permissions = {} + %w(task admin).each do |type| + %w(uid gid).each do |id| + next unless (value = new_resource.send("perm_#{type}_#{id}")) + + permissions[type] ||= {} + permissions[type][id] = value end end - end - grp_hsh = {} - grp_hsh['perm'] = perm unless perm.empty? + payload = ControlGroups.normalize_hash(new_resource.extra_config) + payload['perm'] = permissions unless permissions.empty? - %w(cpu cpuacct devices freezer memory).each do |idx| - if (val = new_resource.send(idx)) - grp_hsh[idx] = val + %w(cpu cpuacct devices freezer memory).each do |controller| + next unless (value = new_resource.send(controller)) + + payload[controller] = ControlGroups.normalize_hash(value) end + + payload end - group_struct[group_name] = grp_hsh - node.run_state[:control_groups][:config][:structure] = group_struct +end + +action :create do + initialize_control_group_state(mounts: new_resource.mounts) + node.run_state[:control_groups][:config][:structure][new_resource.group] = group_payload + ensure_control_group_base_resources(manage_runtime: new_resource.manage_runtime) +end + +action :delete do + initialize_control_group_state(mounts: new_resource.mounts) + node.run_state[:control_groups][:config][:structure].delete(new_resource.group) + ensure_control_group_base_resources(manage_runtime: new_resource.manage_runtime) end diff --git a/resources/install.rb b/resources/install.rb index e922235..e3a09cb 100644 --- a/resources/install.rb +++ b/resources/install.rb @@ -1,18 +1,43 @@ +# frozen_string_literal: true + +provides :control_groups_install +unified_mode true + +use '_partial/_config' + +default_action :install + +action_class do + include ControlGroups::Helpers +end + action :install do - package control_group_packages + initialize_control_group_state(mounts: new_resource.mounts) + ensure_control_group_base_resources(manage_runtime: new_resource.manage_runtime) +end - service 'cgred' do - supports status: true, start: true, stop: true, reload: true - action :nothing +action :remove do + systemd_unit 'cgred.service' do + action %i(stop disable delete) + ignore_failure true end - service 'cgconfig' do - supports status: true, start: true, stop: true, reload: true + systemd_unit 'cgconfig.service' do + action %i(stop disable delete) ignore_failure true - action :nothing end -end -action_class do - include Chef::ControlGroups::Helpers + file '/etc/cgrules.conf' do + action :delete + end + + file '/etc/cgconfig.conf' do + action :delete + end + + control_group_packages.each do |package_name| + package package_name do + action :remove + end + end end diff --git a/resources/rule.rb b/resources/rule.rb index 27c5f47..4739f73 100644 --- a/resources/rule.rb +++ b/resources/rule.rb @@ -1,36 +1,36 @@ +# frozen_string_literal: true + +provides :control_groups_rule +unified_mode true + +use '_partial/_config' + property :user, String, name_property: true property :command, String -property :controllers, Array, required: true +property :controllers, Array, required: true, coerce: proc { |value| Array(value).map(&:to_s) } property :destination, String, required: true -def load_current_resource - ::ControlGroups.rules_struct_init(node) +default_action :create + +action_class do + include ControlGroups::Helpers + + def target + ControlGroups.build_target(new_resource.user, new_resource.command) + end end action :create do - run_context.include_recipe 'control_groups::default' - - # create structure - struct = { + initialize_control_group_state(mounts: new_resource.mounts) + node.run_state[:control_groups][:rules][:active][target] = { controllers: new_resource.controllers, destination: new_resource.destination, } - - dest = node.run_state[:control_groups][:config][:structure][struct[:destination]] - raise "Invalid destination provided for rule (dest: #{struct[:destination]})" unless dest - - # check for controllers - struct[:controllers].each do |cont| - unless dest[cont] - raise "Invalid controller provided for rule (controller: #{cont})" - end - end - - target = [new_resource.user, new_resource.command].compact.join(':') - - node.run_state[:control_groups][:rules][:active][target] = struct + ensure_control_group_base_resources(manage_runtime: new_resource.manage_runtime) end action :delete do - # Nothing \o/ + initialize_control_group_state(mounts: new_resource.mounts) + node.run_state[:control_groups][:rules][:active].delete(target) + ensure_control_group_base_resources(manage_runtime: new_resource.manage_runtime) end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 773d557..cbf7181 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'chefspec' require 'chefspec/berkshelf' diff --git a/spec/unit/libraries/control_groups_spec.rb b/spec/unit/libraries/control_groups_spec.rb new file mode 100644 index 0000000..bc56648 --- /dev/null +++ b/spec/unit/libraries/control_groups_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../../libraries/control_groups' unless defined?(ControlGroups::DEFAULT_MOUNTS) + +describe ControlGroups do + describe '.build_target' do + it 'builds a target with a command' do + expect(described_class.build_target('alice', 'stress-ng')).to eq('alice:stress-ng') + end + + it 'builds a target without a command' do + expect(described_class.build_target('alice')).to eq('alice') + end + end + + describe '.build_rules' do + let(:rules) do + Mash.new( + 'alice:stress-ng' => { + controllers: %w(cpu memory), + destination: 'limited', + } + ) + end + + let(:structure) do + Mash.new( + 'limited' => { + 'cpu' => { 'cpu.max' => '10000 100000' }, + 'memory' => { 'memory.max' => '1048576' }, + } + ) + end + + it 'renders the rules file' do + expect(described_class.build_rules(rules, structure)).to eq("# This file created by Chef!\nalice:stress-ng\tcpu,memory\tlimited\n") + end + + it 'raises for an invalid destination' do + expect do + described_class.build_rules(rules, Mash.new) + end.to raise_error(RuntimeError, /Invalid destination/) + end + end +end diff --git a/spec/unit/libraries/helpers_spec.rb b/spec/unit/libraries/helpers_spec.rb new file mode 100644 index 0000000..f7af33d --- /dev/null +++ b/spec/unit/libraries/helpers_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../../../libraries/helpers' unless defined?(ControlGroups::Helpers) + +describe ControlGroups::Helpers do + subject(:helper_object) do + Class.new do + include ControlGroups::Helpers + + attr_reader :node + + def initialize(platform_family) + @node = { 'platform_family' => platform_family } + end + end.new(platform_family) + end + + context 'when on debian' do + let(:platform_family) { 'debian' } + + it 'returns the Debian package list' do + expect(helper_object.control_group_packages).to eq(%w(cgroup-tools libcgroup2 libpam-cgroup)) + end + end + + context 'when on amazon' do + let(:platform_family) { 'amazon' } + + it 'returns the rpm package list' do + expect(helper_object.control_group_packages).to eq(%w(libcgroup libcgroup-tools)) + end + end +end diff --git a/spec/unit/recipes/default_spec.rb b/spec/unit/recipes/default_spec.rb deleted file mode 100644 index c73b4b1..0000000 --- a/spec/unit/recipes/default_spec.rb +++ /dev/null @@ -1,9 +0,0 @@ -require 'spec_helper' - -describe 'Default recipe on Ubuntu 16.04' do - let(:runner) { ChefSpec::ServerRunner.new(platform: 'ubuntu', version: '16.04', step_into: ['vscode_installer']) } - - it 'converges successfully' do - expect { :chef_run }.to_not raise_error - end -end diff --git a/spec/unit/resources/entry_spec.rb b/spec/unit/resources/entry_spec.rb new file mode 100644 index 0000000..57e14ee --- /dev/null +++ b/spec/unit/resources/entry_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'control_groups_entry' do + step_into :control_groups_entry + platform 'ubuntu', '24.04' + + context 'when creating an entry' do + recipe do + control_groups_entry 'limited' do + cpu('cpu.max' => '10000 100000') + memory('memory.max' => '1048576') + perm_task_uid 'root' + extra_config('notify_on_release' => '1') + end + end + + it { is_expected.to install_package('cgroup-tools') } + it { is_expected.to render_file('/etc/cgconfig.conf').with_content(/group limited \{/) } + it { is_expected.to render_file('/etc/cgconfig.conf').with_content(/cpu.max = 10000 100000;/) } + it { is_expected.to render_file('/etc/cgconfig.conf').with_content(/memory.max = 1048576;/) } + it { is_expected.to render_file('/etc/cgconfig.conf').with_content(/notify_on_release = 1;/) } + it { is_expected.to render_file('/etc/cgconfig.conf').with_content(/uid = root;/) } + end + + context 'when deleting an entry' do + recipe do + control_groups_entry 'limited' do + action :delete + end + end + + it { is_expected.to create_file('/etc/cgconfig.conf') } + it { expect(chef_run).to_not render_file('/etc/cgconfig.conf').with_content(/group limited \{/) } + end + + context 'when runtime management is disabled' do + recipe do + control_groups_entry 'limited' do + cpu('cpu.max' => '10000 100000') + manage_runtime false + end + end + + it { is_expected.to create_systemd_unit('cgconfig.service') } + it { is_expected.to enable_systemd_unit('cgconfig.service') } + it { expect(chef_run).to_not start_systemd_unit('cgconfig.service') } + it { is_expected.to create_systemd_unit('cgred.service') } + it { is_expected.to enable_systemd_unit('cgred.service') } + it { expect(chef_run).to_not start_systemd_unit('cgred.service') } + end +end diff --git a/spec/unit/resources/install_spec.rb b/spec/unit/resources/install_spec.rb new file mode 100644 index 0000000..d60c66a --- /dev/null +++ b/spec/unit/resources/install_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'control_groups_install' do + step_into :control_groups_install + + context 'on ubuntu 24.04' do + platform 'ubuntu', '24.04' + + recipe do + control_groups_install 'default' + end + + it { is_expected.to install_package('cgroup-tools') } + it { is_expected.to install_package('libcgroup2') } + it { is_expected.to install_package('libpam-cgroup') } + it { is_expected.to create_file('/etc/cgconfig.conf') } + it { is_expected.to create_file('/etc/cgrules.conf') } + it { is_expected.to create_systemd_unit('cgconfig.service') } + it { is_expected.to enable_systemd_unit('cgconfig.service') } + it { is_expected.to start_systemd_unit('cgconfig.service') } + it { is_expected.to create_systemd_unit('cgred.service') } + it { is_expected.to enable_systemd_unit('cgred.service') } + it { is_expected.to start_systemd_unit('cgred.service') } + it { is_expected.to render_file('/etc/cgconfig.conf').with_content(%r{mount \{\n cpu = /sys/fs/cgroup/cpu;}) } + end + + context 'action :remove' do + platform 'ubuntu', '24.04' + + recipe do + control_groups_install 'default' do + action :remove + end + end + + it { is_expected.to stop_systemd_unit('cgconfig.service') } + it { is_expected.to disable_systemd_unit('cgconfig.service') } + it { is_expected.to delete_systemd_unit('cgconfig.service') } + it { is_expected.to stop_systemd_unit('cgred.service') } + it { is_expected.to disable_systemd_unit('cgred.service') } + it { is_expected.to delete_systemd_unit('cgred.service') } + it { is_expected.to delete_file('/etc/cgconfig.conf') } + it { is_expected.to delete_file('/etc/cgrules.conf') } + it { is_expected.to remove_package('cgroup-tools') } + it { is_expected.to remove_package('libcgroup2') } + it { is_expected.to remove_package('libpam-cgroup') } + end + + context 'when runtime management is disabled' do + platform 'ubuntu', '24.04' + + recipe do + control_groups_install 'default' do + manage_runtime false + end + end + + it { is_expected.to create_systemd_unit('cgconfig.service') } + it { is_expected.to enable_systemd_unit('cgconfig.service') } + it { expect(chef_run).to_not start_systemd_unit('cgconfig.service') } + it { is_expected.to create_systemd_unit('cgred.service') } + it { is_expected.to enable_systemd_unit('cgred.service') } + it { expect(chef_run).to_not start_systemd_unit('cgred.service') } + end +end diff --git a/spec/unit/resources/rule_spec.rb b/spec/unit/resources/rule_spec.rb new file mode 100644 index 0000000..360e031 --- /dev/null +++ b/spec/unit/resources/rule_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'control_groups_rule' do + step_into :control_groups_entry, :control_groups_rule + platform 'ubuntu', '24.04' + + context 'when creating a rule' do + recipe do + control_groups_entry 'limited' do + cpu('cpu.max' => '10000 100000') + memory('memory.max' => '1048576') + end + + control_groups_rule 'alice' do + command 'stress-ng' + controllers %w(cpu memory) + destination 'limited' + end + end + + it { is_expected.to create_file('/etc/cgrules.conf') } + it { is_expected.to render_file('/etc/cgrules.conf').with_content(/alice:stress-ng\tcpu,memory\tlimited/) } + end + + context 'when deleting a rule' do + recipe do + control_groups_entry 'limited' do + cpu('cpu.max' => '10000 100000') + end + + control_groups_rule 'alice' do + command 'stress-ng' + controllers ['cpu'] + destination 'limited' + action :delete + end + end + + it { is_expected.to create_file('/etc/cgrules.conf') } + it { expect(chef_run).to_not render_file('/etc/cgrules.conf').with_content(/alice:stress-ng/) } + end + + context 'when runtime management is disabled' do + recipe do + control_groups_entry 'limited' do + cpu('cpu.max' => '10000 100000') + manage_runtime false + end + + control_groups_rule 'alice' do + command 'stress-ng' + controllers ['cpu'] + destination 'limited' + manage_runtime false + end + end + + it { is_expected.to create_systemd_unit('cgconfig.service') } + it { is_expected.to enable_systemd_unit('cgconfig.service') } + it { expect(chef_run).to_not start_systemd_unit('cgconfig.service') } + it { is_expected.to create_systemd_unit('cgred.service') } + it { is_expected.to enable_systemd_unit('cgred.service') } + it { expect(chef_run).to_not start_systemd_unit('cgred.service') } + end +end diff --git a/test/cookbooks/test/metadata.rb b/test/cookbooks/test/metadata.rb new file mode 100644 index 0000000..1e9a7be --- /dev/null +++ b/test/cookbooks/test/metadata.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +name 'test' +version '0.1.0' +depends 'control_groups' diff --git a/test/cookbooks/test/recipes/default.rb b/test/cookbooks/test/recipes/default.rb new file mode 100644 index 0000000..8081585 --- /dev/null +++ b/test/cookbooks/test/recipes/default.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +control_groups_install 'default' do + manage_runtime false +end + +control_groups_entry 'limited' do + cpu('cpu.max' => '10000 100000') + memory('memory.max' => '1048576') + extra_config('notify_on_release' => '1') + manage_runtime false +end + +control_groups_rule 'alice' do + command 'stress-ng' + controllers %w(cpu memory) + destination 'limited' + manage_runtime false +end diff --git a/test/cookbooks/test/recipes/entry.rb b/test/cookbooks/test/recipes/entry.rb new file mode 100644 index 0000000..e17ed62 --- /dev/null +++ b/test/cookbooks/test/recipes/entry.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +control_groups_install 'default' do + manage_runtime false +end + +control_groups_entry 'analytics' do + cpu('cpu.max' => '20000 100000') + extra_config('notify_on_release' => '1') + manage_runtime false +end diff --git a/test/cookbooks/test/recipes/rule.rb b/test/cookbooks/test/recipes/rule.rb new file mode 100644 index 0000000..c6db93e --- /dev/null +++ b/test/cookbooks/test/recipes/rule.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +control_groups_install 'default' do + manage_runtime false +end + +control_groups_entry 'limited' do + cpu('cpu.max' => '10000 100000') + memory('memory.max' => '1048576') + manage_runtime false +end + +control_groups_rule 'alice' do + command 'stress-ng' + controllers %w(cpu memory) + destination 'limited' + manage_runtime false +end diff --git a/test/integration/default/controls/default_spec.rb b/test/integration/default/controls/default_spec.rb new file mode 100644 index 0000000..9d9aab2 --- /dev/null +++ b/test/integration/default/controls/default_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +control 'control-groups-packages-01' do + impact 1.0 + title 'Required packages are installed' + + %w(cgroup-tools libcgroup2 libpam-cgroup).each do |package_name| + describe package(package_name) do + it { should be_installed } + end + end +end + +control 'control-groups-config-01' do + impact 1.0 + title 'Configuration files are rendered' + + describe file('/etc/cgconfig.conf') do + it { should exist } + its('content') { should match(/group limited \{/) } + its('content') { should match(/cpu.max = 10000 100000;/) } + its('content') { should match(/memory.max = 1048576;/) } + end + + describe file('/etc/cgrules.conf') do + it { should exist } + its('content') { should match(/alice:stress-ng\tcpu,memory\tlimited/) } + end +end + +control 'control-groups-services-01' do + impact 0.7 + title 'Systemd units are present for runtime management' + + %w(cgconfig cgred).each do |service_name| + describe file("/etc/systemd/system/#{service_name}.service") do + it { should exist } + end + end +end diff --git a/test/integration/default/inspec.yml b/test/integration/default/inspec.yml new file mode 100644 index 0000000..64ac8bc --- /dev/null +++ b/test/integration/default/inspec.yml @@ -0,0 +1,7 @@ +--- +name: default +title: Default integration tests +maintainer: Sous Chefs +license: Apache-2.0 +summary: Validates the default control_groups workflow +version: 1.0.0 diff --git a/test/integration/entry/controls/entry_spec.rb b/test/integration/entry/controls/entry_spec.rb new file mode 100644 index 0000000..6cac635 --- /dev/null +++ b/test/integration/entry/controls/entry_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +control 'control-groups-entry-packages-01' do + impact 1.0 + title 'Required packages are installed' + + %w(cgroup-tools libcgroup2 libpam-cgroup).each do |package_name| + describe package(package_name) do + it { should be_installed } + end + end +end + +control 'control-groups-entry-config-01' do + impact 1.0 + title 'Entry configuration is rendered' + + describe file('/etc/cgconfig.conf') do + it { should exist } + its('content') { should match(/group analytics \{/) } + its('content') { should match(/cpu.max = 20000 100000;/) } + its('content') { should match(/notify_on_release = 1;/) } + end +end + +control 'control-groups-entry-units-01' do + impact 0.7 + title 'Systemd units are created for runtime management' + + describe file('/etc/systemd/system/cgconfig.service') do + it { should exist } + end + + describe file('/etc/systemd/system/cgred.service') do + it { should exist } + end +end diff --git a/test/integration/entry/inspec.yml b/test/integration/entry/inspec.yml new file mode 100644 index 0000000..5cd73cb --- /dev/null +++ b/test/integration/entry/inspec.yml @@ -0,0 +1,7 @@ +--- +name: entry +title: Entry integration tests +maintainer: Sous Chefs +license: Apache-2.0 +summary: Validates control_groups_entry rendering +version: 1.0.0 diff --git a/test/integration/rule/controls/rule_spec.rb b/test/integration/rule/controls/rule_spec.rb new file mode 100644 index 0000000..b1b4799 --- /dev/null +++ b/test/integration/rule/controls/rule_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +control 'control-groups-rule-config-01' do + impact 1.0 + title 'Rule file is rendered' + + describe file('/etc/cgrules.conf') do + it { should exist } + its('content') { should match(/alice:stress-ng\tcpu,memory\tlimited/) } + end +end + +control 'control-groups-rule-config-02' do + impact 0.7 + title 'Destination group exists in cgconfig' + + describe file('/etc/cgconfig.conf') do + it { should exist } + its('content') { should match(/group limited \{/) } + its('content') { should match(/memory.max = 1048576;/) } + end +end diff --git a/test/integration/rule/inspec.yml b/test/integration/rule/inspec.yml new file mode 100644 index 0000000..5151cf3 --- /dev/null +++ b/test/integration/rule/inspec.yml @@ -0,0 +1,7 @@ +--- +name: rule +title: Rule integration tests +maintainer: Sous Chefs +license: Apache-2.0 +summary: Validates control_groups_rule rendering +version: 1.0.0