From fcf88e5f8f27d9aa9e6fc6187c6de7dfbaa417d5 Mon Sep 17 00:00:00 2001 From: Ole Herman Schumacher Elgesem Date: Thu, 16 Apr 2026 20:58:58 +0200 Subject: [PATCH 1/6] Tests: Removed outdated duplicate valgrind test Signed-off-by: Ole Herman Schumacher Elgesem (cherry picked from commit 369df1ab46bb963064746c627b13b96eee26ac46) --- .github/workflows/ci.yml | 3 - .github/workflows/valgrind.sh | 200 --------------------------------- .github/workflows/valgrind.yml | 53 --------- 3 files changed, 256 deletions(-) delete mode 100644 .github/workflows/valgrind.sh delete mode 100644 .github/workflows/valgrind.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 820d51404e..3510615faf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,9 +20,6 @@ jobs: macos_unit_tests: needs: unit_tests uses: ./.github/workflows/macos_unit_tests.yml - valgrind_tests: - needs: unit_tests - uses: ./.github/workflows/valgrind.yml static_check: needs: unit_tests uses: ./.github/workflows/job-static-check.yml diff --git a/.github/workflows/valgrind.sh b/.github/workflows/valgrind.sh deleted file mode 100644 index 73e7a48dd0..0000000000 --- a/.github/workflows/valgrind.sh +++ /dev/null @@ -1,200 +0,0 @@ -#!/usr/bin/env bash -set -e - -function print_ps { - set +e - echo "CFEngine processes:" - ps aux | grep [c]f- - - echo "Valgrind processes:" - ps aux | grep [v]algrind - set -e -} - -function no_errors { - set +e - grep -i "error" $1 - grep -i "error" $1 && exit 1 - set -e -} - -function check_daemon_output { - echo "Examining $1:" - no_errors $1 -} - -function check_output { - set -e - if [ ! -f "$1" ]; then - echo "$1 does not exists!" - exit 1 - fi - echo "Looking for problems in $1:" - grep -i "ERROR SUMMARY: 0 errors" "$1" - cat $1 | sed -e "/ 0 errors/d" -e "/and suppressed error/d" -e "/a memory error detector/d" -e "/This database contains unknown binary data/d" > filtered.txt - no_errors filtered.txt - set +e - grep -i "at 0x" filtered.txt - grep -i "at 0x" filtered.txt && exit 1 - grep -i "by 0x" filtered.txt - grep -i "by 0x" filtered.txt && exit 1 - grep -i "Failed to connect" filtered.txt - grep -i "Failed to connect" filtered.txt && exit 1 - set -e -} - -function check_serverd_valgrind_output { - if [ ! -f "$1" ]; then - echo "$1 does not exists!" - exit 1 - fi - set -e - echo "Serverd has 1 expected valgrind error because of old glibc" - echo "Because of this we use special assertions on output" - echo "Looking for problems in $1:" - grep -i "definitely lost" $1 - grep -i "indirectly lost" $1 - grep -i "ERROR SUMMARY" $1 - grep -i "definitely lost: 0 bytes in 0 blocks" $1 - grep -i "indirectly lost: 0 bytes in 0 blocks" $1 - grep -i "ERROR SUMMARY: 0 errors" "$1" - - cat $1 | sed -e "/ERROR SUMMARY/d" -e "/and suppressed error/d" -e "/a memory error detector/d" > filtered.txt - - no_errors filtered.txt - set +e - grep -i "at 0x" filtered.txt - grep -i "at 0x" filtered.txt && exit 1 - grep -i "by 0x" filtered.txt - grep -i "by 0x" filtered.txt && exit 1 - set -e -} - -function check_masterfiles_and_inputs { - set -e - echo "Comparing promises.cf from inputs and masterfiles:" - diff /var/cfengine/inputs/promises.cf /var/cfengine/masterfiles/promises.cf -} - -VG_OPTS="--leak-check=full --track-origins=yes --error-exitcode=1" -BOOTSTRAP_IP="$(ifconfig | grep -C1 Ethernet | sed 's/.*inet \([0-9.]*\).*/\1/;t;d')" - -valgrind $VG_OPTS /var/cfengine/bin/cf-key 2>&1 | tee cf-key.txt -check_output cf-key.txt -valgrind $VG_OPTS /var/cfengine/bin/cf-agent -B $BOOTSTRAP_IP 2>&1 | tee bootstrap.txt -check_output bootstrap.txt - -# Validate all databases here, because later, we cannot validate -# cf_lastseen.lmdb: -echo "Running cf-check diagnose --validate on all databases:" -valgrind $VG_OPTS /var/cfengine/bin/cf-check diagnose --validate 2>&1 | tee cf_check_validate_all.txt -check_output cf_check_validate_all.txt - -check_masterfiles_and_inputs - -print_ps - -echo "Stopping service to relaunch under valgrind" -systemctl stop cfengine3 -sleep 10 -print_ps - -# The IP we bootstrapped to cannot actually be used for communication. -# This ensures that cf-serverd binds to loopback interface, and cf-net -# connects to it: -echo "127.0.0.1" > /var/cfengine/policy_server.dat - -echo "Starting cf-serverd with valgrind in background:" -valgrind $VG_OPTS --log-file=serverd.txt /var/cfengine/bin/cf-serverd --no-fork 2>&1 > serverd_output.txt & -server_pid="$!" -sleep 20 - -echo "Starting cf-execd with valgrind in background:" -valgrind $VG_OPTS --log-file=execd.txt /var/cfengine/bin/cf-execd --no-fork 2>&1 > execd_output.txt & -exec_pid="$!" -sleep 10 - -print_ps - -echo "Running cf-net:" -valgrind $VG_OPTS /var/cfengine/bin/cf-net GET /var/cfengine/masterfiles/promises.cf 2>&1 | tee get.txt -check_output get.txt - -echo "Checking promises.cf diff (from cf-net GET):" -diff ./promises.cf /var/cfengine/masterfiles/promises.cf - -echo "Running update.cf:" -valgrind $VG_OPTS /var/cfengine/bin/cf-agent -K -f update.cf 2>&1 | tee update.txt -check_output update.txt -check_masterfiles_and_inputs -echo "Running update.cf without local copy:" -valgrind $VG_OPTS /var/cfengine/bin/cf-agent -K -f update.cf -D mpf_skip_local_copy_optimization 2>&1 | tee update2.txt -check_output update2.txt -check_masterfiles_and_inputs -echo "Running promises.cf:" -valgrind $VG_OPTS /var/cfengine/bin/cf-agent -K -f promises.cf 2>&1 | tee promises.txt -check_output promises.txt - -# Dump all databases, use grep to filter the JSON lines -# (optional whitespace then double quote or curly brackets). -# Some of the databases have strings containing "error" -# which check_output greps for. -echo "Running cf-check dump:" -valgrind $VG_OPTS /var/cfengine/bin/cf-check dump 2>&1 | grep -E '\s*[{}"]' --invert-match | tee cf_check_dump.txt -check_output cf_check_dump.txt - -echo "Running cf-check diagnose on all databases" -valgrind $VG_OPTS /var/cfengine/bin/cf-check diagnose 2>&1 | tee cf_check_diagnose.txt -check_output cf_check_diagnose.txt - -# Because of the hack with bootstrap IP / policy_server.dat above -# lastseen would not pass validation: -echo "Running cf-check diagnose --validate on all databases except cf_lastseen.lmdb:" -find /var/cfengine/state -name '*.lmdb' ! -name 'cf_lastseen.lmdb' -exec \ - valgrind $VG_OPTS /var/cfengine/bin/cf-check diagnose --validate {} + 2>&1 \ - | tee cf_check_validate_no_lastseen.txt -check_output cf_check_validate_no_lastseen.txt - -echo "Checking that bootstrap ID doesn't change" -/var/cfengine/bin/cf-agent --show-evaluated-vars | grep bootstrap_id > id_a -/var/cfengine/bin/cf-agent -K --show-evaluated-vars | grep bootstrap_id > id_b -cat id_a -diff id_a id_b - -echo "Checking that bootstrap ID has expected length" -[ `cat id_a | awk '{print $2}' | wc -c` -eq 41 ] - -print_ps - -echo "Checking that serverd and execd PIDs are still correct/alive:" -ps -p $exec_pid -ps -p $server_pid - -echo "Killing valgrind cf-execd" -kill $exec_pid -echo "Killing valgrind cf-serverd" -kill $server_pid -sleep 30 - -echo "Output from cf-execd in valgrind:" -cat execd.txt -check_output execd.txt -check_daemon_output execd_output.txt - -echo "Output from cf-serverd in valgrind:" -cat serverd.txt -check_serverd_valgrind_output serverd.txt -check_daemon_output serverd_output.txt - -echo "Stopping cfengine3 service" -systemctl stop cfengine3 - -echo "Done killing" -sleep 10 -print_ps - -echo "Check that bootstrap was successful" -grep "This host assumes the role of policy server" bootstrap.txt -grep "completed successfully!" bootstrap.txt - -echo "valgrind_health_check successful! (valgrind.sh)" diff --git a/.github/workflows/valgrind.yml b/.github/workflows/valgrind.yml deleted file mode 100644 index 38b0e71b34..0000000000 --- a/.github/workflows/valgrind.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: Valgrind Tests - -on: - workflow_call - -jobs: - valgrind_tests: - runs-on: ubuntu-24.04 - defaults: - run: - working-directory: core - steps: - - uses: actions/checkout@v3 - with: - path: core - submodules: recursive - - name: Get Togethers - uses: cfengine/together-javascript-action@main - id: together - with: - myToken: ${{ secrets.GITHUB_TOKEN }} - - name: Clone masterfiles (master) - uses: actions/checkout@v3 - with: - repository: cfengine/masterfiles - ref: ${{steps.together.outputs.masterfiles || github.base_ref || github.ref}} - path: masterfiles - submodules: recursive - - name: Install dependencies - run: | - ./ci/dependencies.sh - sudo apt install -y valgrind - - name: Run autotools / configure - run: ./autogen.sh --enable-debug --with-systemd-service - - name: Compile and link (make) - run: make -j8 CFLAGS="-Werror -Wall" - - name: Install CFEngine - run: sudo make install - - name: Generate masterfiles - run: ./autogen.sh - working-directory: masterfiles - - name: Install masterfiles - run: sudo make install - working-directory: masterfiles - - name: Reload systemd - run: sudo systemctl daemon-reload - - - name: See if cf-agent runs at all - run: /var/cfengine/bin/cf-agent --version - - - name: Run valgrind.sh - run: sudo bash .github/workflows/valgrind.sh - From fa9631aef9c6877347b11ed89f00df3a849c3d98 Mon Sep 17 00:00:00 2001 From: Ole Herman Schumacher Elgesem Date: Thu, 16 Apr 2026 21:43:47 +0200 Subject: [PATCH 2/6] valgrind.sh: Added metadata / instructions Signed-off-by: Ole Herman Schumacher Elgesem (cherry picked from commit 9434229f1b0ac130e383b61b43687c160f856ff5) --- tests/valgrind-check/valgrind.sh | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/valgrind-check/valgrind.sh b/tests/valgrind-check/valgrind.sh index 62d02a7fb1..d39b2a4d74 100644 --- a/tests/valgrind-check/valgrind.sh +++ b/tests/valgrind-check/valgrind.sh @@ -1,5 +1,24 @@ #!/bin/bash +# This test script builds and installs CFEngine, and runs our binaries +# with valgrind to look for memory leaks, and similar. +# +# It is normally run inside a container (see Containerfile next to it). +# If you want to run it without a container, you can. +# It assumes a few things: +# 1. Must be run from inside core (CWD) +# 2. Masterfiles must be at ../masterfiles +# 3. Normal build tools must be installed, and valgrind and diff +# 4. Must be run using sudo / as root, or have necessary permissions +# 5. CFEngine should not already be installed +# +# In order to run this in an Ubuntu VM, you can do something like this: +# sudo apt-get install -y build-essential git libtool autoconf automake bison flex libssl-dev libpcre2-dev libbison-dev libacl1 libacl1-dev lmdb-utils liblmdb-dev libpam0g-dev libtool libyaml-dev libxml2-dev librsync-dev valgrind +# git clone https://github.com/cfengine/masterfiles +# git clone --recursive https://github.com/cfengine/core +# cd core +# sudo bash tests/valgrind-check/valgrind.sh + function print_ps { set +e echo "CFEngine processes:" From aa9376cc6980eaf21a3fbc562fef1460d1bdd3df Mon Sep 17 00:00:00 2001 From: Ole Herman Schumacher Elgesem Date: Thu, 16 Apr 2026 20:51:37 +0200 Subject: [PATCH 3/6] Updated plucked.cf.sub using make pluck Signed-off-by: Ole Herman Schumacher Elgesem (cherry picked from commit e6afbba784ca765f4f48f8ee4b318413a3eb7d34) --- tests/acceptance/plucked.cf.sub | 651 ++++++++++++++++++++++++++------ 1 file changed, 528 insertions(+), 123 deletions(-) diff --git a/tests/acceptance/plucked.cf.sub b/tests/acceptance/plucked.cf.sub index 042642e5fe..1d13802d9b 100644 --- a/tests/acceptance/plucked.cf.sub +++ b/tests/acceptance/plucked.cf.sub @@ -24,14 +24,33 @@ bundle agent run_ifdefined(namespace, mybundle) methods: "any" - usebundle => $(bundlesfound), - ifvarclass => strcmp(1, $(count)); + usebundle => $(bundlesfound), + if => strcmp(1, $(count)); reports: verbose_mode:: "$(this.bundle): found matching bundles $(bundlesfound) for namespace '$(namespace)' and bundle '$(mybundle)'"; } +body contain powershell +# @brief Run command with powershell (windows only) +# +# **Example:** +# +# ```cf3 +# commands: +# windows:: +# 'schtasks /DELETE /TN "$(_taskname)" /F' +# contain => powershell; +# ``` +# +# **History:** +# +# * Introduced in 3.17.0 +{ + useshell => "powershell"; +} + body contain in_dir_shell(dir) # @brief run command after switching to directory "dir" with full shell # @param dir directory to change into @@ -115,7 +134,7 @@ body action if_elapsed(x) } body action if_elapsed_day -# @brief Evalute the promise once every 24 hours +# @brief Evaluate the promise once every 24 hours { ifelapsed => "1440"; # 60 x 24 expireafter => "1400"; @@ -143,7 +162,7 @@ body classes if_repaired(x) promise_repaired => { "$(x)" }; } -body classes if_else(yes,no) +body classes if_else(yes, no) # @brief Define the classes `yes` or `no` depending on promise outcome # @param yes The name of the class that should be defined if the promise is kept or repaired # @param no The name of the class that should be defined if the promise could not be repaired @@ -165,7 +184,7 @@ body classes if_notkept(x) } body classes if_ok(x) -# @brief Define the class `x` if the promise is kept or could be repaired +# @brief Define the class `x` if the promise is kept or repaired # @param x The name of the class that should be defined { promise_repaired => { "$(x)" }; @@ -173,7 +192,7 @@ body classes if_ok(x) } body classes if_ok_cancel(x) -# @brief Cancel the class `x` if the promise ks kept or repaired +# @brief Cancel the class `x` if the promise is kept or repaired # @param x The name of the class that should be cancelled { cancel_repaired => { "$(x)" }; @@ -193,8 +212,8 @@ body classes classes_generic(x) body classes results(scope, class_prefix) # @brief Define classes prefixed with `class_prefix` and suffixed with -# appropriate outcomes: _kept, _repaired, _not_kept, _error, _failed, -# _denied, _timeout, _reached +# appropriate outcomes: `_kept`, `_repaired`, `_not_kept`, `_error`, `_failed`, +# `_denied`, `_timeout`, `_reached` # # @param scope The scope in which the class should be defined (`bundle` or `namespace`) # @param class_prefix The prefix for the classes defined @@ -214,7 +233,7 @@ body classes results(scope, class_prefix) # This body is a simpler, more consistent version of the body # `scoped_classes_generic`, which see. The key difference is that # fewer classes are defined, and only for outcomes that we can know. -# For example this body does not define "OK/not OK" outcome classes, +# For example this body does not define "OK"/"not OK" outcome classes, # since a promise can be both kept and failed at the same time. # # It's important to understand that promises may do multiple things, @@ -287,6 +306,42 @@ body classes results(scope, class_prefix) "$(class_prefix)_timeout" }; } +body classes diff_results(scope, x) +# @brief Define `x` prefixed/suffixed with promise outcome with command return codes adjusted to align with `diff`. +# @param scope The scope the class should be defined with (`bundle` or `namespace`). +# @param x The unique part of the classes to be defined. +# +# From man diff: +# Exit status is 0 if inputs are the same, 1 if +# different, 2 if trouble. +# +# **Example:** +# +# ```cf3 +# bundle agent example +# { +# commands: +# "/usr/bin/diff" +# args => "/tmp/file1 /tmp/file2", +# classes => diff_results("diff"); +# +# vars: +# "c" slist => classesmatching("diff_.*"); +# +# reports: +# "Found class '$(c)'"; +# "Files Differ!" +# if => "diff_failed|diff_error|diff_not_kept"; +# "Files are the same." +# if => "diff_kept"; +# } +# ``` +{ + inherit_from => results( $(scope), $(x) ); + kept_returncodes => { "0" }; + failed_returncodes => { "1", "2" }; +} + body classes scoped_classes_generic(scope, x) # @brief Define `x` prefixed/suffixed with promise outcome # **See also:** `scope` @@ -332,19 +387,6 @@ bundle edit_line insert_before_if_no_line(before, string) comment => "Prepend a line to the file if it doesn't already exist"; } -bundle edit_line insert_lines(lines) -# @brief Append `lines` if they don't exist in the file -# @param lines The lines to be appended -# -# **See also:** [`insert_lines`][insert_lines] in -# [`edit_line`][bundle edit_line] -{ - insert_lines: - - "$(lines)" - comment => "Append lines if they don't exist"; -} - bundle edit_line insert_file(templatefile) # @brief Reads the lines from `templatefile` and inserts those into the # file being edited. @@ -357,6 +399,60 @@ bundle edit_line insert_file(templatefile) insert_type => "file"; } +bundle edit_line lines_present(lines) +# @brief Ensure `lines` are present in the file. Lines that do not exist are appended to the file +# @param lines List or string that should be present in the file +# +# **Example:** +# +# ```cf3 +# bundle agent example +# { +# vars: +# "nameservers" slist => { "8.8.8.8", "8.8.4.4" }; +# +# files: +# "/etc/resolv.conf" edit_line => lines_present( @(nameservers) ); +# "/etc/ssh/sshd_config" edit_line => lines_present( "PermitRootLogin no" ); +# } +# ``` +{ + insert_lines: + + "$(lines)" + comment => "Append lines if they don't exist"; +} + +bundle edit_line insert_lines(lines) +# @brief Alias for `lines_present` +# @param lines List or string that should be present in the file +{ + insert_lines: + + "$(lines)" + comment => "Append lines if they don't exist"; +} + +bundle edit_line append_if_no_line(lines) +# @brief Alias for `lines_present` +# @param lines List or string that should be present in the file +{ + insert_lines: + + "$(lines)" + comment => "Append lines if they don't exist"; +} + +bundle edit_line append_if_no_lines(lines) +# @brief Alias for `lines_present` +# @param lines List or string that should be present in the file +{ + insert_lines: + + "$(lines)" + comment => "Append lines if they don't exist"; +} + bundle edit_line comment_lines_matching(regex,comment) # @brief Comment lines in the file that matching an [anchored] regex # @param regex Anchored regex that the entire line needs to match @@ -370,6 +466,21 @@ bundle edit_line comment_lines_matching(regex,comment) comment => "Search and replace string"; } +bundle edit_line contains_literal_string(string) +# @brief Ensure the literal string is present in the promised file +# @description If the string is not found in the file it is inserted according +# to CFEngine defaults. +# @param string The string (potentially multiline) to ensure exists in the +# promised file. +{ + + insert_lines: + "$(string)" + insert_type => "preserve_block", + expand_scalars => "false", + whitespace_policy => { "exact_match" }; +} + bundle edit_line uncomment_lines_matching(regex,comment) # @brief Uncomment lines of the file where the regex matches # the entire text after the comment string @@ -438,8 +549,10 @@ bundle edit_line prepend_if_no_line(string) # @brief Prepend `string` if it doesn't exist in the file # @param string The string to be prepended # -# **See also:** [`insert_lines`][insert_lines] in -# [`edit_line`][bundle edit_line] +# **See also:** +# +# * [`insert_lines` promise type][insert_lines] +# * [`edit_line` bundles][edit_line] { insert_lines: "$(string)" @@ -447,28 +560,6 @@ bundle edit_line prepend_if_no_line(string) comment => "Prepend a line to the file if it doesn't already exist"; } -bundle edit_line append_if_no_line(str) -# @ignore -# This duplicates the insert_lines bundle -{ - insert_lines: - - "$(str)" - - comment => "Append a line to the file if it doesn't already exist"; -} - -bundle edit_line append_if_no_lines(list) -# @ignore -# This duplicates the insert_lines bundle -{ - insert_lines: - - "$(list)" - - comment => "Append lines to the file if they don't already exist"; -} - bundle edit_line replace_line_end(start,end) # @brief Give lines starting with `start` the ending given in `end` # @@ -491,6 +582,41 @@ bundle edit_line replace_line_end(start,end) edit_field => line("(^|\s)$(start)\s*", "2", "$(end)","set"); } +bundle edit_line replace_uncommented_substrings( _comment, _find, _replace ) +# @brief Replace all occurrences of `_find` with `_replace` on lines that do not follow a `_comment` +# @param _comment Sequence of characters, each indicating the start of a comment. +# @param _find String matching substring to replace +# @param _replace String to substitute `_find` with +# +# **Example:** +# +# ```cf3 +# bundle agent example_replace_uncommented_substrings +# { +# files: +# "/tmp/file.txt" +# edit_line => replace_uncommented_substrings( "#", "ME", "YOU"); +# } +# ``` +# +# **Notes:** +# +# * Only single character comments are supported as `_comment` is used in the PCRE character group (`[^...]`). +# * `-` in `_comment` is interpreted as a range unless it's used as the first or last character. For example, setting `_comment` to `0-9` means any digit starts a comment. +# +# **History:** +# +# * Introduced 3.17.0, 3.15.3 +{ + vars: + "_reg_match_uncommented_lines_containing_find" + string => "^([^$(_comment)]*)\Q$(_find)\E(.*$)"; + + replace_patterns: + "$(_reg_match_uncommented_lines_containing_find)" + replace_with => text_between_match1_and_match2( $(_replace) ); +} + bundle edit_line append_to_line_end(start,end) # @brief Append `end` to any lines beginning with `start` # @@ -601,28 +727,6 @@ bundle edit_line manage_variable_values_ini(tab, sectionName) vars: "index" slist => getindices("$(tab)[$(sectionName)]"); - # Be careful if the index string contains funny chars - "cindex[$(index)]" string => canonify("$(index)"); - - classes: - "edit_$(cindex[$(index)])" not => strcmp("$($(tab)[$(sectionName)][$(index)])","dontchange"), - comment => "Create conditions to make changes"; - - field_edits: - - # If the line is there, but commented out, first uncomment it - "#+\s*$(index)\s*=.*" - select_region => INI_section(escape("$(sectionName)")), - edit_field => col("=","1","$(index)","set"), - ifvarclass => "edit_$(cindex[$(index)])"; - - # match a line starting like the key something - "$(index)\s*=.*" - edit_field => col("=","2","$($(tab)[$(sectionName)][$(index)])","set"), - select_region => INI_section(escape("$(sectionName)")), - classes => results("bundle", "manage_variable_values_ini_not_$(cindex[$(index)])"), - ifvarclass => "edit_$(cindex[$(index)])"; - delete_lines: ".*" select_region => INI_section(escape("$(sectionName)")), @@ -634,9 +738,7 @@ bundle edit_line manage_variable_values_ini(tab, sectionName) comment => "Insert lines"; "$(index)=$($(tab)[$(sectionName)][$(index)])" - select_region => INI_section(escape("$(sectionName)")), - ifvarclass => "!(manage_variable_values_ini_not_$(cindex[$(index)])_kept|manage_variable_values_ini_not_$(cindex[$(index)])_repaired).edit_$(cindex[$(index)])"; - + select_region => INI_section(escape("$(sectionName)")); } bundle edit_line set_variable_values_ini(tab, sectionName) @@ -695,7 +797,7 @@ bundle edit_line insert_ini_section(name, config) # ``` # # given an array "barray" # files: -# "myfile.ini" edit_line => insert_innit_section("foo", "barray"); +# "myfile.ini" edit_line => insert_ini_section("foo", "barray"); # ``` # # Inserts a section in an INI file with the given configuration @@ -705,14 +807,17 @@ bundle edit_line insert_ini_section(name, config) # @param config The fully-qualified name of an associative array containing `v[LHS]="rhs"` { vars: - "k" slist => getindices($(config)); + # TODO: refactor once 3.7.x is EOL + "indices" slist => getindices($(config)); + "k" slist => sort("indices", lex); insert_lines: "[$(name)]" location => start, - comment => "Prepend a line to the file if it doesn't already exist"; + comment => "Insert an ini section with values if not present"; - "$(k)=$($(config)[$(k)])"; + "$(k)=$($(config)[$(k)])" + location => after("[$(name)]"); } bundle edit_line set_quoted_values(v) @@ -770,7 +875,7 @@ bundle edit_line set_quoted_values(v) insert_lines: '$(index)="$($(v)[$(index)])"' comment => "Insert a variable definition", - ifvarclass => "!($(cindex[$(index)])_in_file_kept|$(cindex[$(index)])_in_file_repaired)"; + if => "!($(cindex[$(index)])_in_file_kept|$(cindex[$(index)])_in_file_repaired)"; } bundle edit_line set_variable_values(v) @@ -832,7 +937,7 @@ bundle edit_line set_variable_values(v) "$(index)=$($(v)[$(index)])" comment => "Insert a variable definition", - ifvarclass => "!($(cv)_$(cindex[$(index)])_in_file_kept|$(cv)_$(cindex[$(index)])_in_file_repaired)"; + if => "!($(cv)_$(cindex[$(index)])_in_file_kept|$(cv)_$(cindex[$(index)])_in_file_repaired)"; } bundle edit_line set_config_values(v) @@ -893,7 +998,7 @@ bundle edit_line set_config_values(v) commented occurrence of $(index).", handle => "set_config_values_replace_commented_line", replace_with => value("$(index) $($(v)[$(index)])"), - ifvarclass => "!line_exists_$(cindex[$(index)]).!replace_attempted_$(cindex[$(index)])_reached.!multiple_comments_$(cindex[$(index)])", + if => "!line_exists_$(cindex[$(index)]).!replace_attempted_$(cindex[$(index)])_reached.!multiple_comments_$(cindex[$(index)])", classes => results("bundle", "uncommented_$(cindex[$(index)])"); # If the line is there with the wrong value, replace with @@ -910,13 +1015,13 @@ bundle edit_line set_config_values(v) "$(index) $($(v)[$(index)])" comment => "Insert the value, marker exists $(index)", location => after("^\s*#\s*($(index)\s+.*|$(index))$"), - ifvarclass => "replace_attempted_$(cindex[$(index)])_reached.multiple_comments_$(cindex[$(index)])"; + if => "replace_attempted_$(cindex[$(index)])_reached.multiple_comments_$(cindex[$(index)])"; # If the line doesn't exist and there are no occurrences # of the LHS commented out, insert a new line at the eof "$(index) $($(v)[$(index)])" comment => "Insert the value, marker doesn't exist $(index)", - ifvarclass => "replace_attempted_$(cindex[$(index)])_reached.!multiple_comments_$(cindex[$(index)])"; + if => "replace_attempted_$(cindex[$(index)])_reached.!multiple_comments_$(cindex[$(index)])"; } @@ -946,9 +1051,12 @@ bundle edit_line set_line_based(v, sep, bp, kp, cp) # Adds a new line if none exists or if more than one commented-out # possible matches exist. # +# +# **Note:** If the data structure being used for the first parameter is in the current bundle, you can use `$(this.bundle).variable`. +# # Originally `set_config_values` by Ed King. # -# @param v The fully-qualified name of an associative array containing `v[LHS]="rhs"` +# @param v The fully-qualified name (`bundlename.variable`) of an associative array containing `v[LHS]="rhs"` # @param sep The separator to insert, e.g. ` ` for space-separated # @param bp The key-value separation regex, e.g. `\s+` for space-separated # @param kp The keys to select from v, use `.*` for all @@ -982,10 +1090,33 @@ bundle edit_line set_line_based(v, sep, bp, kp, cp) classes: + # 3.21.0 and greater know about a file being emptied before editing and + # skip this check since it does not make sense. +@if minimum_version(3.21) # Check to see if this line exists "exists_$(ci[$(i)])" expression => regline("^\s*($(i)$(bp).*|$(i))$", - $(edit.filename)); + $(edit.filename)), + unless => strcmp( "true", $(edit.empty_before_use) ); +@endif + +@if minimum_version(3.18) + !(cfengine_3_18_0|cfengine_3_18_1|cfengine_3_18_2):: + "exists_$(ci[$(i)])" + expression => regline("^\s*($(i)$(bp).*|$(i))$", + $(edit.filename)), + unless => strcmp( "true", $(edit.empty_before_use) ); +@endif + + (cfengine_3_15|cfengine_3_16|cfengine_3_17|cfengine_3_18_0|cfengine_3_18_1|cfengine_3_18_2|cfengine_3_19|cfengine_3_20):: + # Version 3.15.0 does not know about the before_version macro, so we keep the same behavior + # TODO Remove after 3.21 is no longer supported. (3.15.0 was supported when 3.21 was released) + # Check to see if this line exists + "exists_$(ci[$(i)])" + expression => regline("^\s*($(i)$(bp).*|$(i))$", + $(edit.filename)); + + any:: # if there's more than one comment, just add new (don't know who to use) "multiple_comments_$(ci[$(i)])" @@ -999,7 +1130,7 @@ bundle edit_line set_line_based(v, sep, bp, kp, cp) "^$(cp)($(i)$(bp).*|$(i))$" comment => "Uncommented the value '$(i)'", replace_with => value("$(i)$(sep)$($(v)[$(i)])"), - ifvarclass => "!exists_$(ci[$(i)]).!replace_attempted_$(ci[$(i)])_reached.!multiple_comments_$(ci[$(i)])", + if => "!exists_$(ci[$(i)]).!replace_attempted_$(ci[$(i)])_reached.!multiple_comments_$(ci[$(i)])", classes => results("bundle", "uncommented_$(ci[$(i)])"); # If the line is there with the wrong value, replace with @@ -1016,18 +1147,18 @@ bundle edit_line set_line_based(v, sep, bp, kp, cp) "$(i)$(sep)$($(v)[$(i)])" comment => "Insert the value, marker '$(i)' exists", location => after("^$(cp)($(i)$(bp).*|$(i))$"), - ifvarclass => "replace_attempted_$(ci[$(i)])_reached.multiple_comments_$(ci[$(i)])"; + if => "replace_attempted_$(ci[$(i)])_reached.multiple_comments_$(ci[$(i)])"; # If the line doesn't exist and there are no occurrences # of the LHS commented out, insert a new line at the eof "$(i)$(sep)$($(v)[$(i)])" comment => "Insert the value, marker '$(i)' doesn't exist", - ifvarclass => "replace_attempted_$(ci[$(i)])_reached.!multiple_comments_$(ci[$(i)]).!exists_$(ci[$(i)])"; + if => "replace_attempted_$(ci[$(i)])_reached.!multiple_comments_$(ci[$(i)]).!exists_$(ci[$(i)])"; reports: verbose_mode|EXTRA:: - "$(this.bundle): Line for '$(i)' exists" ifvarclass => "exists_$(ci[$(i)])"; - "$(this.bundle): Line for '$(i)' does not exist" ifvarclass => "!exists_$(ci[$(i)])"; + "$(this.bundle): Line for '$(i)' exists" if => "exists_$(ci[$(i)])"; + "$(this.bundle): Line for '$(i)' does not exist" if => "!exists_$(ci[$(i)])"; } bundle edit_line set_config_values_matching(v,pat) @@ -1069,12 +1200,11 @@ bundle edit_line set_config_values_matching(v,pat) insert_lines: "$(index) $($(v)[$(index)])" - ifvarclass => "replace_attempted_$(cindex[$(index)])_reached"; + if => "replace_attempted_$(cindex[$(index)])_reached"; } bundle edit_line maintain_key_values(v,sep) -# @ignore # @brief Sets the RHS of configuration items with an giving separator # # Contributed by David Lee @@ -1113,7 +1243,7 @@ bundle edit_line maintain_key_values(v,sep) insert_lines: "$(index)$(sep)$($(v)[$(index)])" comment => "Insert definition of $(index)", - ifvarclass => "!$(cindex[$(index)])_key_in_file"; + if => "!$(cindex[$(index)])_key_in_file"; } bundle edit_line append_users_starting(v) @@ -1137,7 +1267,7 @@ bundle edit_line append_users_starting(v) "$($(v)[$(index)])" comment => "Append users into a password file format", - ifvarclass => "add_$(index)"; + if => "add_$(index)"; } bundle edit_line append_groups_starting(v) @@ -1161,7 +1291,7 @@ bundle edit_line append_groups_starting(v) "$($(v)[$(index)])" comment => "Append users into a group file format", - ifvarclass => "add_$(index)"; + if => "add_$(index)"; } @@ -1183,19 +1313,51 @@ bundle edit_line set_colon_field(key,field,val) bundle edit_line set_user_field(user,field,val) # @brief Set the value of field number "field" in a `:-field` # formatted file like `/etc/passwd` -# @param user The user to be modified +# @param user A regular expression matching the user(s) to be modified # @param field The field that should be modified # @param val The value for `field` # # **Note:** To manage local users with CFEngine 3.6 and later, # consider making `users` promises instead of modifying system files. +# +# **See also:** +# +# * [bundle edit_line set_escaped_user_field][lib/files.cf#set_escaped_user_field] +# * [edit_line field_edits][field_edits] { field_edits: "$(user):.*" - comment => "Edit a user attribute in the password file", - edit_field => col(":","$(field)","$(val)","set"); + comment => "Edit a user attribute in the password file", + edit_field => col(":","$(field)","$(val)","set"); +} + +bundle edit_line set_escaped_user_field(user,field,val) +# @brief Set the value of field number "field" in a `:-field` +# formatted file like `/etc/passwd` +# @param user The user to be modified +# @param field The field that should be modified +# @param val The value for `field` +# +# **Note:** To manage local users with CFEngine 3.6 and later, +# consider making `users` promises instead of modifying system files. +# +# **See also:** +# +# * [bundle edit_line set_user_field][lib/files.cf#set_user_field] +# * [edit_line field_edits][field_edits] +{ + vars: + "escaped_user" + string => escape( "$(user)" ); + + field_edits: + + "$(escaped_user):.*" + + comment => "Edit a user attribute in the password file", + edit_field => col(":","$(field)","$(val)","set"); } bundle edit_line append_user_field(group,field,allusers) @@ -1208,16 +1370,12 @@ bundle edit_line append_user_field(group,field,allusers) # **Note:** To manage local users with CFEngine 3.6 and later, # consider making `users` promises instead of modifying system files. { - vars: - - "val" slist => { @(allusers) }; - field_edits: "$(group):.*" comment => "Append users into a password file format", - edit_field => col(":","$(field)","$(val)","alphanum"); + edit_field => col(":","$(field)","$(allusers)","alphanum"); } bundle edit_line expand_template(templatefile) @@ -1256,7 +1414,7 @@ bundle edit_line replace_or_add(pattern,line) insert_lines: "$(line)" - ifvarclass => "replace_$(cline)_reached"; + if => "replace_$(cline)_reached"; } bundle edit_line converge(marker, lines) @@ -1267,16 +1425,72 @@ bundle edit_line converge(marker, lines) # # @param marker The marker (not a regular expression; will be escaped) # @param lines The lines to insert; all must contain `marker` +# +# **Example:** +# +# ```cf3 +# bundle agent pam_d_su_include +# #@brief Ensure /etc/pam.d/su has includes configured properly +# { +# files: +# ubuntu:: +# "/etc/pam.d/su" +# edit_line => converge( "@include", "@include common-auth +# @include common-account +# @include common-session"); +# } +# ``` +# +# **History:** +# +# * Introduced in 3.6.0 { vars: "regex" string => escape($(marker)); delete_lines: - "$(regex)" comment => "Delete lines matching the marker"; + ".*$(regex).*" comment => "Delete lines matching the marker"; insert_lines: "$(lines)" comment => "Insert the given lines"; } +bundle edit_line converge_prepend(marker, lines) +# @brief Converge `lines` marked with `marker` to start of content +# +# Any content marked with `marker` is removed, then `lines` are +# inserted at *start* of content. Every `line` should contain `marker`. +# +# @param marker The marker (not a regular expression; will be escaped) +# @param lines The lines to insert; all must contain `marker` +# +# **Example:** +# +# ```cf3 +# bundle agent pam_d_su_session +# #@brief Ensure /etc/pam.d/su has session configured properly +# { +# files: +# ubuntu:: +# "/etc/pam.d/su" +# edit_line => converge_prepend( "session", "session required pam_env.so readenv=1 envfile=/etc/default/locale +# session optional pam_mail.so nopen +# session required pam_limits.so" ); +# } +# ``` +# +# **History:** +# +# * Introduced in 3.17.0, 3.15.3, 3.12.6 +{ + vars: + "regex" string => escape($(marker)); + + delete_lines: + ".*$(regex).*" comment => "Delete lines matching the marker"; + insert_lines: + "$(lines)" location => start, comment => "Insert the given lines"; +} + bundle edit_line fstab_option_editor(method, mount, option) # @brief Add or remove `/etc/fstab` options for a mount # @@ -1329,7 +1543,8 @@ body edit_field fstab_options(newval, method) body edit_field quoted_var(newval,method) # @brief Edit the quoted value of the matching line # @param newval The new value -# @param method The method by which to edit the field +# @param method The method by which to edit the field (append|prepend|alphanum|set|delete) +# Ref https://docs.cfengine.com/latest/reference-promise-types-files-edit_line-field_edits.html#field_operation { field_separator => "\""; select_field => "2"; @@ -1372,6 +1587,14 @@ body edit_field line(split,col,newval,method) allow_blank_fields => "true"; } +body replace_with text_between_match1_and_match2( _text ) +# @brief Replace matched line with substituted string +# @param _text String to substitute between first and second match +{ + replace_value => "$(match.1)$(_text)$(match.2)"; + occurrences => "all"; +} + body replace_with value(x) # @brief Replace matching lines # @param x The replacement string @@ -1472,6 +1695,8 @@ body copy_from remote_dcp(from,server) # # @param from The location of the file on the remote server # @param server The hostname or IP of the server from which to download +# +# **See Also:** `local_dcp()` { servers => { "$(server)" }; source => "$(from)"; @@ -1479,17 +1704,42 @@ body copy_from remote_dcp(from,server) } body copy_from local_cp(from) -# @brief Copy a local file. -# +# @brief Copy a file if the modification time or creation time of the source +# file is newer (the default comparison mechanism). # @param from The path to the source file. +# +# **Example:** +# +# ```cf3 +# bundle agent example +# { +# files: +# "/tmp/file.bak" +# copy_from => local_cp("/tmp/file"); +# } +# ``` +# +# **See Also:** `local_dcp()` { source => "$(from)"; } body copy_from local_dcp(from) -# @brief Copy a local file if it is different from the existing copy. -# +# @brief Copy a local file if the hash on the source file differs. # @param from The path to the source file. +# +# **Example:** +# +# ```cf3 +# bundle agent example +# { +# files: +# "/tmp/file.bak" +# copy_from => local_dcp("/tmp/file"); +# } +# ``` +# +# **See Also:** `local_cp()`, `remote_dcp()` { source => "$(from)"; compare => "digest"; @@ -1525,19 +1775,52 @@ body copy_from backup_local_cp(from) } body copy_from seed_cp(from) -# @brief Copy a local file if the file does not already exist, i.e. seed the placement -# +# @brief Copy a local file if the file does not already exist, i.e. seed the +# placement # @param from The path to the source file. +# +# **Example:** +# +# ```cf3 +# bundle agent home_dir_init +# { +# files: +# "/home/mark.burgess/." +# copy_from => seed_cp("/etc/skel"), +# depth_search => recurse(inf), +# file_select => all, +# comment => "We want to be sure that the home directory has files that are +# present in the skeleton."; +# } +# ``` { source => "$(from)"; compare => "exists"; } body copy_from sync_cp(from,server) -# @brief Download a file if the local copy does not already exist, i.e. seed the placement +# @brief Synchronize a file with a remote server. +# +# * If the file does not exist on the remote server then it should be purged. +# * Allow types to change (directories to files and vice versa). +# * The mode of the remote file should be preserved. +# * Files are compared using the default comparison (mtime or ctime). # # @param from The location of the file on the remote server # @param server The hostname or IP of the server from which to download +# +# **Example**: +# +# ```cf3 +# files: +# "/tmp/masterfiles/." +# copy_from => sync_cp( "/var/cfengine/masterfiles", $(sys.policy_server) ), +# depth_search => recurse(inf), +# file_select => all, +# comment => "Mirror masterfiles from the hub to a temporary directory"; +# ``` +# +# **See Also:** `dir_sync()`, `copyfrom_sync()` { servers => { "$(server)" }; source => "$(from)"; @@ -1555,6 +1838,17 @@ body copy_from no_backup_cp(from) copy_backup => "false"; } +body copy_from no_backup_cp_compare(from, comparison) +# @brief Copy a local file (`from`) based on comparison (`comparison`) and don't make any backup of the previous version +# +# @param from The path to the source file. +# @param comparison The comparison to use. (mtime|ctime|atime|exists|binary|hash|digest) +{ + source => "$(from)"; + copy_backup => "false"; + compare => "$(comparison)"; +} + body copy_from no_backup_dcp(from) # @brief Copy a local file if contents have changed, and don't make any backup # of the previous version @@ -1580,7 +1874,14 @@ body perms m(mode) # @param mode The new mode { mode => "$(mode)"; - rxdirs => "true"; + +#+begin_ENT-951 +# Remove after 3.20 is not supported + rxdirs => "true"; +@if minimum_version(3.20) + rxdirs => "false"; +@endif +#+end } body perms mo(mode,user) @@ -1590,7 +1891,14 @@ body perms mo(mode,user) { owners => { "$(user)" }; mode => "$(mode)"; - rxdirs => "true"; + +#+begin_ENT-951 +# Remove after 3.20 is not supported + rxdirs => "true"; +@if minimum_version(3.20) + rxdirs => "false"; +@endif +#+end } body perms mog(mode,user,group) @@ -1602,7 +1910,14 @@ body perms mog(mode,user,group) owners => { "$(user)" }; groups => { "$(group)" }; mode => "$(mode)"; - rxdirs => "true"; + +#+begin_ENT-951 +# Remove after 3.20 is not supported + rxdirs => "true"; +@if minimum_version(3.20) + rxdirs => "false"; +@endif +#+end } body perms og(u,g) @@ -1633,15 +1948,44 @@ body perms system_owned(mode) # ``` { mode => "$(mode)"; - owners => { "root" }; - groups => { "$(sys.user_data[gid])" }; - rxdirs => "true"; -} +#+begin_ENT-951 +# Remove after 3.20 is not supported + rxdirs => "true"; +@if minimum_version(3.20) + rxdirs => "false"; +@endif +#+end + + !windows:: + owners => { "root" }; + + windows:: + + # NOTE: Setting owners will generate an error if the policy is not being + # executed as the user who's ownership is being targeted. While it seems + # that should typically be Administrator or SYSTEM, both are reported to + # result in errors by users, thus owners is currently omitted for Windows. + + # ENT-9778 + groups => { "Administrators" }; + + freebsd|openbsd|netbsd|darwin:: + groups => { "wheel" }; + + linux:: + groups => { "root" }; + + solaris:: + groups => { "sys" }; + + aix:: + groups => { "system" }; +} body depth_search recurse(d) # @brief Search files and direcories recursively, up to the specified depth -# Directories on different devices are included. +# Directories on different devices are excluded. # # @param d The maximum search depth { @@ -1662,7 +2006,7 @@ body depth_search recurse_ignore(d,list) body depth_search recurse_with_base(d) # @brief Search files and directories recursively up to the specified -# depth, starting from the base directory and including directories on +# depth, starting from the base directory excluding directories on # other devices. # # @param d The maximum search depth @@ -1844,10 +2188,10 @@ bundle agent file_make(file, str) reports: "DEBUG|DEBUG_$(this.bundle)":: "DEBUG $(this.bundle): creating $(file) with contents '$(str)'" - ifvarclass => "!summarize"; + if => "!summarize"; "DEBUG $(this.bundle): creating $(file) with contents '$(summary)'" - ifvarclass => "summarize"; + if => "summarize"; } bundle agent file_make_mog(file, str, mode, owner, group) @@ -1885,10 +2229,71 @@ bundle agent file_make_mog(file, str, mode, owner, group) reports: "DEBUG|DEBUG_$(this.bundle)":: "DEBUG $(this.bundle): creating $(file) with contents '$(str)', mode '$(mode)', owner '$(owner)' and group '$(group)'" - ifvarclass => "!summarize"; + if => "!summarize"; "DEBUG $(this.bundle): creating $(file) with contents '$(summary)', mode '$(mode)', owner '$(owner)' and group '$(group)'" - ifvarclass => "summarize"; + if => "summarize"; +} + +bundle agent file_make_mustache(file, template, data) +# @brief Make a file from a mustache template +# @param file Target file to render +# @param template Path to mustache template +# @param data Data container to use +# +# **Example:** +# +# ```cf3 +# vars: +# "state" data => datastate(); +# +# methods: +# "" usebundle => file_make_mustache( "/tmp/z.txt", "/tmp/z.mustache", @(state) ); +# ``` +{ + files: + "$(file)" + create => "true", + edit_template => "$(template)", + template_method => "mustache", + template_data => @(data); + + reports: + "DEBUG|DEBUG_$(this.bundle)":: + "DEBUG $(this.bundle): rendering $(file) with template '$(template)'"; +} + +bundle agent file_make_mustache_with_perms(file, template, data, mode, owner, group) +# @brief Make a file from a mustache template +# @param file Target file to render +# @param template Path to mustache template +# @param data Data container to use +# @param mode File permissions +# @param owner Target file owner +# @param group Target file group +# +# **Example:** +# +# ```cf3 +# vars: +# "state" data => datastate(); +# +# methods: +# "" usebundle => file_make_mustache( "/tmp/z.txt", "/tmp/z.mustache", @(state), +# 600, "root", "root" ); +# ``` +{ + files: + "$(file)" + create => "true", + edit_template => "$(template)", + template_method => "mustache", + perms => mog( $(mode), $(owner), $(group) ), + template_data => @(data); + + reports: + "DEBUG|DEBUG_$(this.bundle)":: + "DEBUG $(this.bundle): rendering $(file) with template '$(template)'"; } bundle agent file_empty(file) From d4eb73bf0c668af7ef2a2f78b7566d94d1b677d2 Mon Sep 17 00:00:00 2001 From: Ole Herman Schumacher Elgesem Date: Thu, 16 Apr 2026 21:51:38 +0200 Subject: [PATCH 4/6] Removed duplicate definitions of lines_present bundle Signed-off-by: Ole Herman Schumacher Elgesem (cherry picked from commit 4c0a170b2f7a4ce9232f7fcdf1a9ab5ceac4b340) --- ...using_copy_from_without_create_true.cf.sub | 8 ------- ...edit_template_string_vs_string_mustache.cf | 23 ------------------- 2 files changed, 31 deletions(-) diff --git a/tests/acceptance/10_files/no_error_archiving_previous_backup_using_copy_from_without_create_true.cf.sub b/tests/acceptance/10_files/no_error_archiving_previous_backup_using_copy_from_without_create_true.cf.sub index 304fd40013..acc778ac50 100644 --- a/tests/acceptance/10_files/no_error_archiving_previous_backup_using_copy_from_without_create_true.cf.sub +++ b/tests/acceptance/10_files/no_error_archiving_previous_backup_using_copy_from_without_create_true.cf.sub @@ -95,11 +95,3 @@ body classes outcomes(p) repair_timeout => { '$(p)_timeout', '$(p)_reached', '$(p)_error' }; scope => 'bundle'; } -bundle edit_line lines_present(lines) -{ - insert_lines: - - "$(lines)" - comment => "Append lines if they don't exist"; -} - diff --git a/tests/acceptance/10_files/templating/edit_template_string/mustache_edit_template_string_vs_string_mustache.cf b/tests/acceptance/10_files/templating/edit_template_string/mustache_edit_template_string_vs_string_mustache.cf index 510a706865..571424eb95 100644 --- a/tests/acceptance/10_files/templating/edit_template_string/mustache_edit_template_string_vs_string_mustache.cf +++ b/tests/acceptance/10_files/templating/edit_template_string/mustache_edit_template_string_vs_string_mustache.cf @@ -102,26 +102,3 @@ body printfile cat(file) file_to_print => "$(file)"; number_of_lines => "inf"; } -bundle edit_line lines_present(lines) -# @brief Ensure `lines` are present in the file. Lines that do not exist are appended to the file -# @param List or string that should be present in the file -# -# **Example:** -# -# ```cf3 -# bundle agent example -# { -# vars: -# "nameservers" slist => { "8.8.8.8", "8.8.4.4" }; -# -# files: -# "/etc/resolv.conf" edit_line => lines_present( @(nameservers) ); -# "/etc/ssh/sshd_config" edit_line => lines_present( "PermitRootLogin no" ); -# } -# ``` -{ - insert_lines: - - "$(lines)" - comment => "Append lines if they don't exist"; -} From b103d21dae2a7dae20dd7e5794a2703cea3c14ff Mon Sep 17 00:00:00 2001 From: Nick Anderson Date: Thu, 16 Apr 2026 17:01:06 -0500 Subject: [PATCH 5/6] Added test for select_region convergence across passes This test validates that select_region can converge across multiple edit_line passes when the region is created in an earlier pass. The test creates a file with a [section] header in the first promise, then uses select_region to insert content within that section in the second promise. This demonstrates that select_region failures are retried across multiple passes until they succeed. Ticket: CFE-3866 Changelog: None (cherry picked from commit 0565051d94e6c26507dfecd040c9037609d695c5) --- tests/acceptance/31_tickets/CFE-3866/test.cf | 93 ++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 tests/acceptance/31_tickets/CFE-3866/test.cf diff --git a/tests/acceptance/31_tickets/CFE-3866/test.cf b/tests/acceptance/31_tickets/CFE-3866/test.cf new file mode 100644 index 0000000000..513a941f44 --- /dev/null +++ b/tests/acceptance/31_tickets/CFE-3866/test.cf @@ -0,0 +1,93 @@ +body file control +{ + inputs => { + "../../default.cf.sub", + }; +} + +bundle agent __main__ +{ + methods: + "bundlesequence" usebundle => default("$(this.promise_filename)"); +} + +bundle agent init +{ + files: + "$(G.testfile)" + delete => init_delete; +} + +body delete init_delete +{ + dirlinks => "delete"; + rmdirs => "true"; +} + +bundle agent test +{ + meta: + "description" + string => "Test that select_region converges across multiple edit_line passes when the region is created in an earlier pass", + meta => { "CFE-3866" }; + + files: + "$(G.testfile)" + create => "true", + edit_line => insert_section_then_add_content; +} + +bundle edit_line insert_section_then_add_content +{ + # First promise: Create the section header + insert_lines: + "[section]" + location => start; + + # Second promise: Add content within the section using select_region + # This should converge even though the section didn't exist when + # select_region was first evaluated (pass 1) + + insert_lines: + "key=value" + location => append, + select_region => section_region; +} + +body location append +{ + before_after => "after"; +} + +body select_region section_region +{ + select_start => "^\[section\]"; + include_start_delimiter => "true"; + select_end => "^\[.*\]"; + select_end_match_eof => "true"; +} + +bundle agent check +{ + vars: + "expected" + string => "[section] +key=value +"; + + "actual" + string => readfile("$(G.testfile)", "1000"); + + classes: + "ok" expression => strcmp("$(expected)", "$(actual)"); + + reports: + DEBUG:: + "Expected: '$(expected)'"; + "Actual: '$(actual)'"; + + ok:: + "$(this.promise_filename) Pass"; + !ok:: + "$(this.promise_filename) FAIL"; +} From 9ae3c7f27a8a82352492fdb09fa1781713e440f4 Mon Sep 17 00:00:00 2001 From: Nick Anderson Date: Thu, 16 Apr 2026 17:01:30 -0500 Subject: [PATCH 6/6] Enabled select_region to converge across multiple passes Modified edit_line operations to allow select_region to succeed across multiple convergence passes. Previously, if a promise inserted a section header in pass 1, promises using select_region for that section would fail with INTERRUPTED in the same run, preventing convergence. Changes: - Added EDIT_CONTEXT_IS_FINAL_PASS macro to files_edit.h for checking if we're on the final convergence pass - Extracted HandleSelectRegionFailure() to centralize error handling for SelectRegion failures across VerifyLineDeletions, VerifyColumnEdits, VerifyPatterns, and VerifyLineInsertions - On non-final passes: log verbose message and return NOOP to allow retry - On final pass: report error with detailed context - Log messages now show remaining passes (e.g., "pass 1/3, 2 more passes to try") This eliminates the need for workarounds in set_variable_values_ini() and similar functions that create sections and immediately edit them. Ticket: CFE-3866 Changelog: Title (cherry picked from commit 1a411a0307427a066717526f41c8f8e018982ed8) --- cf-agent/files_edit.h | 4 ++ cf-agent/files_editline.c | 91 ++++++++++++++++++++++++++------------- 2 files changed, 64 insertions(+), 31 deletions(-) diff --git a/cf-agent/files_edit.h b/cf-agent/files_edit.h index 9c19c39786..eb3a243ca6 100644 --- a/cf-agent/files_edit.h +++ b/cf-agent/files_edit.h @@ -47,12 +47,16 @@ typedef struct char *changes_filename; Item *file_start; int num_edits; + int pass; // Current convergence pass (1 to CF_DONEPASSES-1) #ifdef HAVE_LIBXML2 xmlDocPtr xmldoc; #endif NewLineMode new_line_mode; } EditContext; +// Check if we're on the final convergence pass where errors should be reported +#define EDIT_CONTEXT_IS_FINAL_PASS(ec) ((ec)->pass >= CF_DONEPASSES - 1) + // filename must not be freed until FinishEditContext. EditContext *NewEditContext(char *filename, const Attributes *a); void FinishEditContext(EvalContext *ctx, EditContext *ec, diff --git a/cf-agent/files_editline.c b/cf-agent/files_editline.c index 1b004eda72..1e25c96c42 100644 --- a/cf-agent/files_editline.c +++ b/cf-agent/files_editline.c @@ -96,6 +96,7 @@ static bool SanityCheckDeletions(const Attributes *a, const Promise *pp); static bool SelectLine(EvalContext *ctx, const char *line, const Attributes *a); static bool NotAnchored(char *s); static bool SelectRegion(EvalContext *ctx, Item *start, Item **begin_ptr, Item **end_ptr, const Attributes *a, EditContext *edcontext); +static PromiseResult HandleSelectRegionFailure(EvalContext *ctx, const Promise *pp, const Attributes *a, EditContext *edcontext, const char *operation_type); static bool MultiLineString(char *s); static bool InsertFileAtLocation(EvalContext *ctx, Item **start, Item *begin_ptr, Item *end_ptr, Item *location, Item *prev, const Attributes *a, const Promise *pp, EditContext *edcontext, PromiseResult *result); @@ -131,6 +132,7 @@ bool ScheduleEditLineOperations(EvalContext *ctx, const Bundle *bp, const Attrib for (pass = 1; pass < CF_DONEPASSES; pass++) { + edcontext->pass = pass; // Track current pass for convergence for (type = 0; EDITLINETYPESEQUENCE[type] != NULL; type++) { const BundleSection *sp = BundleGetSection(bp, EDITLINETYPESEQUENCE[type]); @@ -390,22 +392,7 @@ static PromiseResult VerifyLineDeletions(EvalContext *ctx, const Promise *pp, Ed } else if (!SelectRegion(ctx, *start, &begin_ptr, &end_ptr, &a, edcontext)) { - if (a.region.include_end || a.region.include_start) - { - cfPS(ctx, LOG_LEVEL_ERR, PROMISE_RESULT_INTERRUPTED, pp, &a, - "The promised line deletion '%s' could not select an edit region in '%s'" - " (this is a good thing, as policy suggests deleting the markers)", - pp->promiser, edcontext->filename); - } - else - { - cfPS(ctx, LOG_LEVEL_ERR, PROMISE_RESULT_INTERRUPTED, pp, &a, - "The promised line deletion '%s' could not select an edit region in '%s'" - " (but the delimiters were expected in the file)", - pp->promiser, edcontext->filename); - } - result = PromiseResultUpdate(result, PROMISE_RESULT_INTERRUPTED); - return result; + return HandleSelectRegionFailure(ctx, pp, &a, edcontext, "line deletion"); } if (!end_ptr && a.region.select_end && !a.region.select_end_match_eof) { @@ -502,11 +489,7 @@ static PromiseResult VerifyColumnEdits(EvalContext *ctx, const Promise *pp, Edit } else if (!SelectRegion(ctx, *start, &begin_ptr, &end_ptr, &a, edcontext)) { - cfPS(ctx, LOG_LEVEL_ERR, PROMISE_RESULT_INTERRUPTED, pp, &a, - "The promised column edit '%s' could not select an edit region in '%s'", - pp->promiser, edcontext->filename); - result = PromiseResultUpdate(result, PROMISE_RESULT_INTERRUPTED); - return result; + return HandleSelectRegionFailure(ctx, pp, &a, edcontext, "column edit"); } /* locate and split line */ @@ -581,11 +564,7 @@ static PromiseResult VerifyPatterns(EvalContext *ctx, const Promise *pp, EditCon } else if (!SelectRegion(ctx, *start, &begin_ptr, &end_ptr, &a, edcontext)) { - cfPS(ctx, LOG_LEVEL_ERR, PROMISE_RESULT_INTERRUPTED, pp, &a, - "The promised pattern replace '%s' could not select an edit region in '%s'", - pp->promiser, edcontext->filename); - result = PromiseResultUpdate(result, PROMISE_RESULT_INTERRUPTED); - return result; + return HandleSelectRegionFailure(ctx, pp, &a, edcontext, "pattern replace"); } snprintf(lockname, CF_BUFSIZE - 1, "replace-%s-%s", pp->promiser, edcontext->filename); @@ -774,11 +753,7 @@ static PromiseResult VerifyLineInsertions(EvalContext *ctx, const Promise *pp, E } else if (!SelectRegion(ctx, *start, &begin_ptr, &end_ptr, &a, edcontext)) { - cfPS(ctx, LOG_LEVEL_ERR, PROMISE_RESULT_INTERRUPTED, pp, &a, - "The promised line insertion '%s' could not select an edit region in '%s'", - pp->promiser, edcontext->filename); - result = PromiseResultUpdate(result, PROMISE_RESULT_INTERRUPTED); - return result; + return HandleSelectRegionFailure(ctx, pp, &a, edcontext, "line insertion"); } if (!end_ptr && a.region.select_end && !a.region.select_end_match_eof) @@ -939,6 +914,60 @@ If no such region matches, begin_ptr and end_ptr should point to NULL /*****************************************************************************/ +static PromiseResult HandleSelectRegionFailure(EvalContext *ctx, const Promise *pp, + const Attributes *a, EditContext *edcontext, + const char *operation_type) +/* + Common error handling for SelectRegion failures across multiple edit operations. + Returns appropriate PromiseResult based on whether we're in final pass or not. + Special handling for line deletions where missing region markers may be intended. +*/ +{ + assert(pp != NULL); + assert(a != NULL); + assert(edcontext != NULL); + assert(operation_type != NULL); + + if (!EDIT_CONTEXT_IS_FINAL_PASS(edcontext)) + { + int remaining_passes = (CF_DONEPASSES - 1) - edcontext->pass; + Log(LOG_LEVEL_VERBOSE, + "The promised %s '%s' could not select edit region in '%s' (pass %d/%d, %d more %s to try)", + operation_type, pp->promiser, edcontext->filename, + edcontext->pass, CF_DONEPASSES - 1, remaining_passes, + remaining_passes == 1 ? "pass" : "passes"); + return PROMISE_RESULT_NOOP; // Allow retry in next pass + } + + // Special case for line deletions: missing markers might be intentional + if (StringEqual(operation_type, "line deletion")) + { + if (a->region.include_end || a->region.include_start) + { + cfPS(ctx, LOG_LEVEL_ERR, PROMISE_RESULT_INTERRUPTED, pp, a, + "The promised %s '%s' could not select an edit region in '%s' after %d passes" + " (this is a good thing, as policy suggests deleting the markers)", + operation_type, pp->promiser, edcontext->filename, CF_DONEPASSES - 1); + } + else + { + cfPS(ctx, LOG_LEVEL_ERR, PROMISE_RESULT_INTERRUPTED, pp, a, + "The promised %s '%s' could not select an edit region in '%s' after %d passes" + " (but the delimiters were expected in the file)", + operation_type, pp->promiser, edcontext->filename, CF_DONEPASSES - 1); + } + return PROMISE_RESULT_INTERRUPTED; + } + + // Standard error for final pass + cfPS(ctx, LOG_LEVEL_ERR, PROMISE_RESULT_INTERRUPTED, pp, a, + "The promised %s '%s' could not select an edit region in '%s' after %d passes", + operation_type, pp->promiser, edcontext->filename, CF_DONEPASSES - 1); + return PROMISE_RESULT_INTERRUPTED; +} + +/*****************************************************************************/ + static int MatchRegion(EvalContext *ctx, const char *chunk, const Item *begin, const Item *end, bool regex) /* Match a region in between the selection delimiters. It is