Repository: falcosecurity/falco
Pull request #3538: new(userspace,unit_tests)!: add a way to specify merge-strategy for `config_files`
Author: login: FedeDP
Labels: name: kind/feature
name: area/engine
name: size/XL
name: release-note
name: dco-signoff: yes
name: lgtm
name: approved
name: area/tests
name: kind/release
Created: 2025-04-24T10:17:47Z | Merged: 2025-04-29T14:17:06Z

**What type of PR is this?**

/kind release

**Any specific area of the project related to this PR?**

/area engine
/area tests

**What this PR does / why we need it**:

By default we now use the `append` merge-strategy:
* existing sequence keys will be appended
* existing scalar keys will be overridden
* non-existing keys will be added

We also have an `override` merge-strategy:
* existing keys will be overridden
* non-existing keys will be added

Finally, there is an `add-only` merge-strategy:
* existing keys will be ignored
* non-existing keys will be added

~~To switch between strategies, one must prepend special characters to `config_files` entries: `@` to enable `append` strategy, or `+` to enable `add-only` strategy.~~

This is a breaking change because the old default behavior was `override` one. But, since we now ship a `falco.container_plugin.yaml` to load the `container` plugin, we want that config to append to `load_plugins`, not to override, otherwise it would kill any loaded plugins by the user.
Also, it seems a more sensible default.

**Which issue(s) this PR fixes**:


Fixes #

**Special notes for your reviewer**:

**Does this PR introduce a user-facing change?**:

<!--
If NO, just write "NONE" in the release-note block below.

If YES, a release note is required, enter your release note in the block below. 
The convention is the same as for commit messages: https://github.com/falcosecurity/.github/blob/main/CONTRIBUTING.md#commit-convention
If the PR introduces non-backward compatible changes, please add a line starting with "BREAKING CHANGE:" and describe what changed.
For example, `BREAKING CHANGE: the API interface of the rule engine has changed`.
Your note will be included in the changelog.
-->

```release-note
new(userspace,unit_tests)!: add a way to specify merge-strategy for `config_files`
```



Diff:
diff --git a/falco.yaml b/falco.yaml
index ce274b968ee..8325fa3627c 100644
--- a/falco.yaml
+++ b/falco.yaml
@@ -143,8 +143,31 @@
 # Also, nested include is not allowed, ie: included config files won't be able to include other config files.
 #
 # Like for 'rules_files', specifying a folder will load all the configs files present in it in a lexicographical order.
+#
+# 3 merge-strategies are available:
+# `append` (default):
+#   * existing sequence keys will be appended
+#   * existing scalar keys will be overridden
+#   * non-existing keys will be added
+# `override`:
+#   * existing keys will be overridden
+#   * non-existing keys will be added
+# `add-only`:
+#   * existing keys will be ignored
+#   * non-existing keys will be added
+#
+# Each item on the list can be either a yaml map or a simple string.
+# The simple string will be interpreted as the config file path, and the `append` merge-strategy will be enforced.
+# When the item is a yaml map instead, it will be of the form: `   path: foo\n   strategy: X`.
+# When `strategy` is omitted, once again `append` is used.
+#
+# When a merge-strategy is enabled for a folder entry, all the included config files will use that merge-strategy.
 config_files:
   - /etc/falco/config.d
+  # Example of config file specified as yaml map with strategy made explicit.
+  #- path: $HOME/falco_local_configs/
+  #  strategy: add-only
+
 
 # [Stable] `watch_config_files`
 #
diff --git a/unit_tests/falco/test_configuration_config_files.cpp b/unit_tests/falco/test_configuration_config_files.cpp
index cfb9019c408..025a89b3120 100644
--- a/unit_tests/falco/test_configuration_config_files.cpp
+++ b/unit_tests/falco/test_configuration_config_files.cpp
@@ -245,6 +245,316 @@ TEST(Configuration, configuration_config_files_override) {
 	std::filesystem::remove("conf_3.yaml");
 }
 
+TEST(Configuration, configuration_config_files_sequence_strategy_default) {
+	const std::string main_conf_yaml = yaml_helper::configs_key +
+	                                   ":\n"
+	                                   "  - conf_2.yaml\n"  // default merge-strategy: append
+	                                   "  - conf_3.yaml\n"
+	                                   "foo: [ bar ]\n"
+	                                   "base_value:\n"
+	                                   "    id: 1\n"
+	                                   "    name: foo\n";
+	const std::string conf_yaml_2 =
+	        "foo: [ bar2 ]\n"  // append to foo sequence
+	        "base_value_2:\n"
+	        "    id: 2\n";
+	const std::string conf_yaml_3 =
+	        "base_value:\n"  // override base_value
+	        "    id: 3\n";
+
+	std::ofstream outfile("main.yaml");
+	outfile << main_conf_yaml;
+	outfile.close();
+
+	outfile.open("conf_2.yaml");
+	outfile << conf_yaml_2;
+	outfile.close();
+
+	outfile.open("conf_3.yaml");
+	outfile << conf_yaml_3;
+	outfile.close();
+
+	std::vector<std::string> cmdline_config_options;
+	falco_configuration falco_config;
+	config_loaded_res res;
+	ASSERT_NO_THROW(res = falco_config.init_from_file("main.yaml", cmdline_config_options));
+
+	// main + conf_2 + conf_3
+	ASSERT_EQ(res.size(), 3);
+
+	ASSERT_TRUE(falco_config.m_config.is_defined("foo"));
+	std::vector<std::string> foos;
+	auto expected_foos = std::vector<std::string>{"bar", "bar2"};
+	ASSERT_NO_THROW(falco_config.m_config.get_sequence<std::vector<std::string>>(foos, "foo"));
+	ASSERT_EQ(foos.size(), 2);  // 2 elements in `foo` sequence because we appended to it
+	for(size_t i = 0; i < foos.size(); ++i) {
+		EXPECT_EQ(foos[i], expected_foos[i])
+		        << "Vectors foo's and expected_foo's differ at index " << i;
+	}
+
+	ASSERT_TRUE(falco_config.m_config.is_defined("base_value.id"));
+	ASSERT_EQ(falco_config.m_config.get_scalar<int>("base_value.id", 0), 3);  // overridden!
+	ASSERT_FALSE(falco_config.m_config.is_defined(
+	        "base_value.name"));  // no more present since entire `base_value` block was overridden
+	ASSERT_TRUE(falco_config.m_config.is_defined("base_value_2.id"));
+	ASSERT_EQ(falco_config.m_config.get_scalar<int>("base_value_2.id", 0), 2);
+	ASSERT_FALSE(falco_config.m_config.is_defined("base_value_3.id"));  // not defined
+
+	std::filesystem::remove("main.yaml");
+	std::filesystem::remove("conf_2.yaml");
+	std::filesystem::remove("conf_3.yaml");
+}
+
+TEST(Configuration, configuration_config_files_sequence_strategy_append) {
+	const std::string main_conf_yaml = yaml_helper::configs_key +
+	                                   ":\n"
+	                                   "  - path: conf_2.yaml\n"
+	                                   "    strategy: append\n"
+	                                   "  - conf_3.yaml\n"
+	                                   "foo: [ bar ]\n"
+	                                   "base_value:\n"
+	                                   "    id: 1\n"
+	                                   "    name: foo\n";
+	const std::string conf_yaml_2 =
+	        "foo: [ bar2 ]\n"  // append to foo sequence
+	        "base_value_2:\n"
+	        "    id: 2\n";
+	const std::string conf_yaml_3 =
+	        "base_value:\n"  // override base_value
+	        "    id: 3\n";
+
+	std::ofstream outfile("main.yaml");
+	outfile << main_conf_yaml;
+	outfile.close();
+
+	outfile.open("conf_2.yaml");
+	outfile << conf_yaml_2;
+	outfile.close();
+
+	outfile.open("conf_3.yaml");
+	outfile << conf_yaml_3;
+	outfile.close();
+
+	std::vector<std::string> cmdline_config_options;
+	falco_configuration falco_config;
+	config_loaded_res res;
+	ASSERT_NO_THROW(res = falco_config.init_from_file("main.yaml", cmdline_config_options));
+
+	// main + conf_2 + conf_3
+	ASSERT_EQ(res.size(), 3);
+
+	ASSERT_TRUE(falco_config.m_config.is_defined("foo"));
+	std::vector<std::string> foos;
+	auto expected_foos = std::vector<std::string>{"bar", "bar2"};
+	ASSERT_NO_THROW(falco_config.m_config.get_sequence<std::vector<std::string>>(foos, "foo"));
+	ASSERT_EQ(foos.size(), 2);  // 2 elements in `foo` sequence because we appended to it
+	for(size_t i = 0; i < foos.size(); ++i) {
+		EXPECT_EQ(foos[i], expected_foos[i])
+		        << "Vectors foo's and expected_foo's differ at index " << i;
+	}
+
+	ASSERT_TRUE(falco_config.m_config.is_defined("base_value.id"));
+	ASSERT_EQ(falco_config.m_config.get_scalar<int>("base_value.id", 0), 3);  // overridden!
+	ASSERT_FALSE(falco_config.m_config.is_defined(
+	        "base_value.name"));  // no more present since entire `base_value` block was overridden
+	ASSERT_TRUE(falco_config.m_config.is_defined("base_value_2.id"));
+	ASSERT_EQ(falco_config.m_config.get_scalar<int>("base_value_2.id", 0), 2);
+	ASSERT_FALSE(falco_config.m_config.is_defined("base_value_3.id"));  // not defined
+
+	std::filesystem::remove("main.yaml");
+	std::filesystem::remove("conf_2.yaml");
+	std::filesystem::remove("conf_3.yaml");
+}
+
+TEST(Configuration, configuration_config_files_sequence_strategy_override) {
+	const std::string main_conf_yaml = yaml_helper::configs_key +
+	                                   ":\n"
+	                                   "  - path: conf_2.yaml\n"
+	                                   "    strategy: override\n"
+	                                   "  - conf_3.yaml\n"
+	                                   "foo: [ bar ]\n"
+	                                   "base_value:\n"
+	                                   "    id: 1\n"
+	                                   "    name: foo\n";
+	const std::string conf_yaml_2 =
+	        "foo: [ bar2 ]\n"  // override foo sequence
+	        "base_value_2:\n"
+	        "    id: 2\n";
+	const std::string conf_yaml_3 =
+	        "base_value:\n"  // override base_value
+	        "    id: 3\n";
+
+	std::ofstream outfile("main.yaml");
+	outfile << main_conf_yaml;
+	outfile.close();
+
+	outfile.open("conf_2.yaml");
+	outfile << conf_yaml_2;
+	outfile.close();
+
+	outfile.open("conf_3.yaml");
+	outfile << conf_yaml_3;
+	outfile.close();
+
+	std::vector<std::string> cmdline_config_options;
+	falco_configuration falco_config;
+	config_loaded_res res;
+	ASSERT_NO_THROW(res = falco_config.init_from_file("main.yaml", cmdline_config_options));
+
+	// main + conf_2 + conf_3
+	ASSERT_EQ(res.size(), 3);
+
+	ASSERT_TRUE(falco_config.m_config.is_defined("foo"));
+	std::vector<std::string> foos;
+	auto expected_foos = std::vector<std::string>{"bar2"};
+	ASSERT_NO_THROW(falco_config.m_config.get_sequence<std::vector<std::string>>(foos, "foo"));
+	ASSERT_EQ(foos.size(), 1);  // one element in `foo` sequence because we overrode it
+	for(size_t i = 0; i < foos.size(); ++i) {
+		EXPECT_EQ(foos[i], expected_foos[i])
+		        << "Vectors foo's and expected_foo's differ at index " << i;
+	}
+
+	ASSERT_TRUE(falco_config.m_config.is_defined("base_value.id"));
+	ASSERT_EQ(falco_config.m_config.get_scalar<int>("base_value.id", 0), 3);  // overridden!
+	ASSERT_FALSE(falco_config.m_config.is_defined(
+	        "base_value.name"));  // no more present since entire `base_value` block was overridden
+	ASSERT_TRUE(falco_config.m_config.is_defined("base_value_2.id"));
+	ASSERT_EQ(falco_config.m_config.get_scalar<int>("base_value_2.id", 0), 2);
+	ASSERT_FALSE(falco_config.m_config.is_defined("base_value_3.id"));  // not defined
+
+	std::filesystem::remove("main.yaml");
+	std::filesystem::remove("conf_2.yaml");
+	std::filesystem::remove("conf_3.yaml");
+}
+
+TEST(Configuration, configuration_config_files_sequence_strategy_addonly) {
+	/* Test that included config files are able to override configs from main file */
+	const std::string main_conf_yaml = yaml_helper::configs_key +
+	                                   ":\n"
+	                                   "  - path: conf_2.yaml\n"
+	                                   "    strategy: add-only\n"
+	                                   "  - conf_3.yaml\n"
+	                                   "foo: [ bar ]\n"
+	                                   "base_value:\n"
+	                                   "    id: 1\n"
+	                                   "    name: foo\n";
+	const std::string conf_yaml_2 =
+	        "foo: [ bar2 ]\n"  // ignored: add-only strategy
+	        "base_value_2:\n"
+	        "    id: 2\n";
+	const std::string conf_yaml_3 =
+	        "base_value:\n"  // override base_value
+	        "    id: 3\n";
+
+	std::ofstream outfile("main.yaml");
+	outfile << main_conf_yaml;
+	outfile.close();
+
+	outfile.open("conf_2.yaml");
+	outfile << conf_yaml_2;
+	outfile.close();
+
+	outfile.open("conf_3.yaml");
+	outfile << conf_yaml_3;
+	outfile.close();
+
+	std::vector<std::string> cmdline_config_options;
+	falco_configuration falco_config;
+	config_loaded_res res;
+	ASSERT_NO_THROW(res = falco_config.init_from_file("main.yaml", cmdline_config_options));
+
+	// main + conf_2 + conf_3
+	ASSERT_EQ(res.size(), 3);
+
+	ASSERT_TRUE(falco_config.m_config.is_defined("foo"));
+	std::vector<std::string> foos;
+	auto expected_foos =
+	        std::vector<std::string>{"bar"};  // bar2 is ignored because of merge-strategy: add-only
+	ASSERT_NO_THROW(falco_config.m_config.get_sequence<std::vector<std::string>>(foos, "foo"));
+	ASSERT_EQ(foos.size(), 1);  // one element in `foo` sequence because we overrode it
+	for(size_t i = 0; i < foos.size(); ++i) {
+		EXPECT_EQ(foos[i], expected_foos[i])
+		        << "Vectors foo's and expected_foo's differ at index " << i;
+	}
+
+	ASSERT_TRUE(falco_config.m_config.is_defined("base_value.id"));
+	ASSERT_EQ(falco_config.m_config.get_scalar<int>("base_value.id", 0), 3);  // overridden!
+	ASSERT_FALSE(falco_config.m_config.is_defined(
+	        "base_value.name"));  // no more present since entire `base_value` block was overridden
+	ASSERT_TRUE(falco_config.m_config.is_defined("base_value_2.id"));
+	ASSERT_EQ(falco_config.m_config.get_scalar<int>("base_value_2.id", 0), 2);
+	ASSERT_FALSE(falco_config.m_config.is_defined("base_value_3.id"));  // not defined
+
+	std::filesystem::remove("main.yaml");
+	std::filesystem::remove("conf_2.yaml");
+	std::filesystem::remove("conf_3.yaml");
+}
+
+TEST(Configuration, configuration_config_files_sequence_wrong_strategy) {
+	const std::string main_conf_yaml = yaml_helper::configs_key +
+	                                   ":\n"
+	                                   "  - path: conf_2.yaml\n"
+	                                   "    strategy: wrong\n"
+	                                   "  - conf_3.yaml\n"
+	                                   "foo: [ bar ]\n"
+	                                   "base_value:\n"
+	                                   "    id: 1\n"
+	                                   "    name: foo\n";
+	const std::string conf_yaml_2 =
+	        "foo: [ bar2 ]\n"  // append to foo sequence
+	        "base_value_2:\n"
+	        "    id: 2\n";
+	const std::string conf_yaml_3 =
+	        "base_value:\n"  // override base_value
+	        "    id: 3\n";
+
+	std::ofstream outfile("main.yaml");
+	outfile << main_conf_yaml;
+	outfile.close();
+
+	outfile.open("conf_2.yaml");
+	outfile << conf_yaml_2;
+	outfile.close();
+
+	outfile.open("conf_3.yaml");
+	outfile << conf_yaml_3;
+	outfile.close();
+
+	std::vector<std::string> cmdline_config_options;
+	falco_configuration falco_config;
+	config_loaded_res res;
+	ASSERT_NO_THROW(res = falco_config.init_from_file("main.yaml", cmdline_config_options));
+
+	// main
+	ASSERT_EQ(res.size(), 3);
+	auto validation = res["main.yaml"];
+	// Since we are using a wrong strategy, the validation should fail
+	// but the enforced strategy should be "append"
+	ASSERT_NE(validation, yaml_helper::validation_ok);
+
+	ASSERT_TRUE(falco_config.m_config.is_defined("foo"));
+	std::vector<std::string> foos;
+	auto expected_foos = std::vector<std::string>{"bar", "bar2"};
+	ASSERT_NO_THROW(falco_config.m_config.get_sequence<std::vector<std::string>>(foos, "foo"));
+	ASSERT_EQ(foos.size(), 2);  // 2 elements in `foo` sequence because we appended to it
+	for(size_t i = 0; i < foos.size(); ++i) {
+		EXPECT_EQ(foos[i], expected_foos[i])
+		        << "Vectors foo's and expected_foo's differ at index " << i;
+	}
+
+	ASSERT_TRUE(falco_config.m_config.is_defined("base_value.id"));
+	ASSERT_EQ(falco_config.m_config.get_scalar<int>("base_value.id", 0), 3);  // overridden!
+	ASSERT_FALSE(falco_config.m_config.is_defined(
+	        "base_value.name"));  // no more present since entire `base_value` block was overridden
+	ASSERT_TRUE(falco_config.m_config.is_defined("base_value_2.id"));
+	ASSERT_EQ(falco_config.m_config.get_scalar<int>("base_value_2.id", 0), 2);
+	ASSERT_FALSE(falco_config.m_config.is_defined("base_value_3.id"));  // not defined
+
+	std::filesystem::remove("main.yaml");
+	std::filesystem::remove("conf_2.yaml");
+	std::filesystem::remove("conf_3.yaml");
+}
+
 TEST(Configuration, configuration_config_files_unexistent) {
 	/* Test that including an unexistent file just skips it */
 	const std::string main_conf_yaml = yaml_helper::configs_key +
diff --git a/userspace/engine/yaml_helper.h b/userspace/engine/yaml_helper.h
index 8f9ed08a09e..f37a7630e5f 100644
--- a/userspace/engine/yaml_helper.h
+++ b/userspace/engine/yaml_helper.h
@@ -85,6 +85,33 @@ class yaml_helper {
 	inline static const std::string validation_failed = "failed";
 	inline static const std::string validation_none = "none";
 
+	enum config_files_strategy {
+		STRATEGY_APPEND,  // append to existing sequence keys, override scalar keys and add new ones
+		STRATEGY_OVERRIDE,  // override existing keys (sequences too) and add new ones
+		STRATEGY_ADDONLY    // only add new keys and ignore existing ones
+	};
+
+	static enum config_files_strategy strategy_from_string(const std::string& strategy) {
+		if(strategy == "override") {
+			return yaml_helper::STRATEGY_OVERRIDE;
+		}
+		if(strategy == "add-only") {
+			return yaml_helper::STRATEGY_ADDONLY;
+		}
+		return yaml_helper::STRATEGY_APPEND;
+	}
+
+	static std::string strategy_to_string(const enum config_files_strategy strategy) {
+		switch(strategy) {
+		case yaml_helper::STRATEGY_OVERRIDE:
+			return "override";
+		case yaml_helper::STRATEGY_ADDONLY:
+			return "add-only";
+		default:
+			return "append";
+		}
+	}
+
 	/**
 	 * Load all the YAML document represented by the input string.
 	 * Since this is used by rule loader, does not process env vars.
@@ -137,6 +164,7 @@ class yaml_helper {
 	}
 
 	void include_config_file(const std::string& include_file_path,
+	                         enum config_files_strategy strategy = STRATEGY_APPEND,
 	                         const nlohmann::json& schema = {},
 	                         std::vector<std::string>* schema_warnings = nullptr) {
 		auto loaded_nodes = load_from_file_int(include_file_path, schema, schema_warnings);
@@ -152,10 +180,24 @@ class yaml_helper {
 				                         "' directive in included config file " +
 				                         include_file_path + ".");
 			}
-			// We allow to override keys.
-			// We don't need to use `get_node()` here,
-			// since key is a top-level one.
-			m_root[key] = n.second;
+			switch(strategy) {
+			case STRATEGY_APPEND:
+				if(n.second.IsSequence()) {
+					for(const auto& item : n.second) {
+						m_root[key].push_back(item);
+					}
+					break;
+				}
+				// fallthrough
+			case STRATEGY_OVERRIDE:
+				m_root[key] = n.second;
+				break;
+			case STRATEGY_ADDONLY:
+				if(!m_root[key].IsDefined()) {
+					m_root[key] = n.second;
+				}
+				break;
+			}
 		}
 	}
 
diff --git a/userspace/falco/config_json_schema.h b/userspace/falco/config_json_schema.h
index 6048c4727dc..3a58faac571 100644
--- a/userspace/falco/config_json_schema.h
+++ b/userspace/falco/config_json_schema.h
@@ -38,7 +38,30 @@ const char config_schema_string[] = LONG_STRING_CONST(
                 "config_files": {
                     "type": "array",
                     "items": {
-                        "type": "string"
+                        "oneOf": [
+                            {
+                                "type": "string"
+                            },
+                            {
+                                "type": "object",
+                                "properties": {
+                                    "path": {
+                                        "type": "string"
+                                    },
+                                    "strategy": {
+                                        "type": "string",
+                                        "enum": [
+                                            "append",
+                                            "override",
+                                            "add-only"
+                                        ]
+                                    }
+                                },
+                                "required": [
+                                    "path"
+                                ]
+                            }
+                        ]
                     }
                 },
                 "watch_config_files": {
diff --git a/userspace/falco/configuration.cpp b/userspace/falco/configuration.cpp
index 8bc7652768c..fb20c6c6dc3 100644
--- a/userspace/falco/configuration.cpp
+++ b/userspace/falco/configuration.cpp
@@ -162,10 +162,12 @@ void falco_configuration::merge_config_files(const std::string &config_name,
 	m_loaded_configs_filenames.push_back(config_name);
 	const auto ppath = std::filesystem::path(config_name);
 	// Parse files to be included
-	std::vector<std::string> include_files;
-	m_config.get_sequence<std::vector<std::string>>(include_files, yaml_helper::configs_key);
-	for(const std::string &include_file : include_files) {
-		auto include_file_path = std::filesystem::path(include_file);
+	std::list<falco_configuration::config_files_config> include_files;
+	m_config.get_sequence<std::list<falco_configuration::config_files_config>>(
+	        include_files,
+	        yaml_helper::configs_key);
+	for(const auto &include_file : include_files) {
+		auto include_file_path = std::filesystem::path(include_file.m_path);
 		if(include_file_path == ppath) {
 			throw std::logic_error("Config error: '" + yaml_helper::configs_key +
 			                       "' directive tried to recursively include main config file: " +
@@ -176,14 +178,15 @@ void falco_configuration::merge_config_files(const std::string &config_name,
 			continue;
 		}
 		if(std::filesystem::is_regular_file(include_file_path)) {
-			m_loaded_configs_filenames.push_back(include_file);
-			m_config.include_config_file(include_file_path.string(),
+			m_loaded_configs_filenames.push_back(include_file.m_path);
+			m_config.include_config_file(include_file.m_path,
+			                             include_file.m_strategy,
 			                             m_config_schema,
 			                             &validation_status);
 			// Only report top most schema validation status
-			res[include_file_path.string()] = validation_status[0];
+			res[include_file.m_path] = validation_status[0];
 		} else if(std::filesystem::is_directory(include_file_path)) {
-			m_loaded_configs_folders.push_back(include_file);
+			m_loaded_configs_folders.push_back(include_file.m_path);
 			std::vector<std::string> v;
 			const auto it_options = std::filesystem::directory_options::follow_directory_symlink |
 			                        std::filesystem::directory_options::skip_permission_denied;
@@ -195,7 +198,10 @@ void falco_configuration::merge_config_files(const std::string &config_name,
 			}
 			std::sort(v.begin(), v.end());
 			for(const auto &f : v) {
-				m_config.include_config_file(f, m_config_schema, &validation_status);
+				m_config.include_config_file(f,
+				                             include_file.m_strategy,
+				                             m_config_schema,
+				                             &validation_status);
 				// Only report top most schema validation status
 				res[f] = validation_status[0];
 			}
diff --git a/userspace/falco/configuration.h b/userspace/falco/configuration.h
index 54516cd512b..0f4d4ab9b72 100644
--- a/userspace/falco/configuration.h
+++ b/userspace/falco/configuration.h
@@ -54,6 +54,11 @@ class falco_configuration {
 		std::string m_open_params;
 	};
 
+	struct config_files_config {
+		std::string m_path;
+		yaml_helper::config_files_strategy m_strategy;
+	};
+
 	struct kmod_config {
 		int16_t m_buf_size_preset;
 		bool m_drop_failed_exit;
@@ -425,4 +430,39 @@ struct convert<falco_configuration::plugin_config> {
 		return true;
 	}
 };
+
+template<>
+struct convert<falco_configuration::config_files_config> {
+	static Node encode(const falco_configuration::config_files_config& rhs) {
+		Node node;
+		node["path"] = rhs.m_path;
+		node["strategy"] = yaml_helper::strategy_to_string(rhs.m_strategy);
+		return node;
+	}
+
+	static bool decode(const Node& node, falco_configuration::config_files_config& rhs) {
+		if(!node.IsMap()) {
+			// Single string mode defaults to append strategy
+			rhs.m_path = node.as<std::string>();
+			rhs.m_strategy = yaml_helper::STRATEGY_APPEND;
+			return true;
+		}
+
+		// Path is required
+		if(!node["path"]) {
+			return false;
+		}
+		rhs.m_path = node["path"].as<std::string>();
+
+		// Strategy is not required
+		if(!node["strategy"]) {
+			rhs.m_strategy = yaml_helper::STRATEGY_APPEND;
+		} else {
+			std::string strategy = node["strategy"].as<std::string>();
+			rhs.m_strategy = yaml_helper::strategy_from_string(strategy);
+		}
+		return true;
+	}
+};
+
 }  // namespace YAML


Reviews:
user: login: sgaist
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T13:03:10Z
user: login: FedeDP
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T13:06:05Z
user: login: FedeDP
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T13:06:26Z
user: login: FedeDP
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T13:08:24Z
user: login: sgaist
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T13:30:07Z
user: login: leogr
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T07:43:51Z
user: login: FedeDP
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T07:54:27Z
user: login: FedeDP
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T08:55:19Z
user: login: sgaist
body: The new format was a very good idea !

Just one question about the enum handling but otherwise looks good.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T09:47:50Z
user: login: FedeDP
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T10:17:37Z
user: login: FedeDP
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T10:21:58Z
user: login: FedeDP
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T10:23:10Z
user: login: sgaist
body: LGTM
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T11:58:16Z
user: login: leogr
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-29T14:16:51Z

Review comments:
diff_hunk: @@ -164,7 +164,10 @@ void falco_configuration::merge_config_files(const std::string &config_name,
 	// Parse files to be included
 	std::vector<std::string> include_files;
 	m_config.get_sequence<std::vector<std::string>>(include_files, yaml_helper::configs_key);
-	for(const std::string &include_file : include_files) {
+	for(auto include_file : include_files) {
path: userspace/falco/configuration.cpp
user: login: sgaist
body: Shouldn't const be applied here ?
created_at: 2025-04-24T12:29:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -194,8 +198,11 @@ void falco_configuration::merge_config_files(const std::string &config_name,
 				}
 			}
 			std::sort(v.begin(), v.end());
-			for(const auto &f : v) {
-				m_config.include_config_file(f, m_config_schema, &validation_status);
+			for(auto f : v) {
path: userspace/falco/configuration.cpp
user: login: sgaist
body: Why remove the constness ?
created_at: 2025-04-24T12:35:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -164,7 +164,10 @@ void falco_configuration::merge_config_files(const std::string &config_name,
 	// Parse files to be included
 	std::vector<std::string> include_files;
 	m_config.get_sequence<std::vector<std::string>>(include_files, yaml_helper::configs_key);
-	for(const std::string &include_file : include_files) {
+	for(auto include_file : include_files) {
path: userspace/falco/configuration.cpp
user: login: FedeDP
body: Nope because as the following line states, `yaml_helper::get_include_file_strategy` can modify che include_file if needed (to drop the strategy prefix).
created_at: 2025-04-24T13:06:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -194,8 +198,11 @@ void falco_configuration::merge_config_files(const std::string &config_name,
 				}
 			}
 			std::sort(v.begin(), v.end());
-			for(const auto &f : v) {
-				m_config.include_config_file(f, m_config_schema, &validation_status);
+			for(auto f : v) {
path: userspace/falco/configuration.cpp
user: login: FedeDP
body: Oh you are right, this was done while i was scratching. Will re-add it!
created_at: 2025-04-24T13:06:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -194,8 +198,11 @@ void falco_configuration::merge_config_files(const std::string &config_name,
 				}
 			}
 			std::sort(v.begin(), v.end());
-			for(const auto &f : v) {
-				m_config.include_config_file(f, m_config_schema, &validation_status);
+			for(auto f : v) {
path: userspace/falco/configuration.cpp
user: login: FedeDP
body: Done
created_at: 2025-04-24T13:08:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -164,7 +164,10 @@ void falco_configuration::merge_config_files(const std::string &config_name,
 	// Parse files to be included
 	std::vector<std::string> include_files;
 	m_config.get_sequence<std::vector<std::string>>(include_files, yaml_helper::configs_key);
-	for(const std::string &include_file : include_files) {
+	for(auto include_file : include_files) {
path: userspace/falco/configuration.cpp
user: login: sgaist
body: My bad ! I misread the implementation.
created_at: 2025-04-24T13:29:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -143,6 +143,21 @@
 # Also, nested include is not allowed, ie: included config files won't be able to include other config files.
 #
 # Like for 'rules_files', specifying a folder will load all the configs files present in it in a lexicographical order.
+#
+# 3 merge-strategies are available: `append`, `override` and `add-only`.
+# The default merge-strategy is `append`:
+#   * existing sequence keys will be appended
+#   * existing scalar keys will be overridden
+#   * non-existing keys will be added
+# To enable the `override` merge-strategy, `@` must be prepended to the entry, eg: "@/$HOME/override.yaml".
+#   * existing keys will be overridden
+#   * non-existing keys will be added
+#   Please note that since `@` is a reserved character by yaml spec, you need to quote the whole string.
+# To enable the `add-only` merge-strategy, `+` must be prepended to the entry, eg: "+/$HOME/append.yaml".
+#   * existing keys will be ignored
+#   * non-existing keys will be added
+#
+# When a merge-strategy is enabled for a folder entry, all of the included config files will use that merge-strategy.
path: falco.yaml
user: login: leogr
body: Although I love the idea of having several merge strategies and this lean solution, `@` and `+` are allowed characters in filenames (across all common OSes, i.e. Linux, Mac, Windows). Since the path can be relative, filenames like the following are legit:
- `@file.txt`
- `+file.txt`

For this reason, I think that prepending symbols is not a viable solution :sweat: 

A more structured (and cumbersome - I know) solution would be something like:





```yaml
config_files:
  # default merge strategy (i.e., `append`)
  - /etc/falco/config.d
  # `append` strategy (explicit)
  - path: /etc/falco/config.d/file1.yaml
    strategy: append
  # `override` strategy
  - path: /etc/falco/config.d/file2.yaml
    strategy: override
  # `add-only` strategy
  - path: /etc/falco/config.d/file3.yaml 
    strategy: add-only
```

wdyt? :thinking: 


created_at: 2025-04-29T07:39:17Z
author_association: MEMBER
diff_hunk: @@ -143,6 +143,21 @@
 # Also, nested include is not allowed, ie: included config files won't be able to include other config files.
 #
 # Like for 'rules_files', specifying a folder will load all the configs files present in it in a lexicographical order.
+#
+# 3 merge-strategies are available: `append`, `override` and `add-only`.
+# The default merge-strategy is `append`:
+#   * existing sequence keys will be appended
+#   * existing scalar keys will be overridden
+#   * non-existing keys will be added
+# To enable the `override` merge-strategy, `@` must be prepended to the entry, eg: "@/$HOME/override.yaml".
+#   * existing keys will be overridden
+#   * non-existing keys will be added
+#   Please note that since `@` is a reserved character by yaml spec, you need to quote the whole string.
+# To enable the `add-only` merge-strategy, `+` must be prepended to the entry, eg: "+/$HOME/append.yaml".
+#   * existing keys will be ignored
+#   * non-existing keys will be added
+#
+# When a merge-strategy is enabled for a folder entry, all of the included config files will use that merge-strategy.
path: falco.yaml
user: login: leogr
body: PS
JSON schema allow to have different schemeas depending on which type the key is, for example:

```json
"oneOf": [
        { "type": "string" },
        {
          "type": "object",
          "properties": {
            "path": { "type": "string" },
            "strategy": { "type": "string" },
          },
```
created_at: 2025-04-29T07:43:42Z
author_association: MEMBER
diff_hunk: @@ -143,6 +143,21 @@
 # Also, nested include is not allowed, ie: included config files won't be able to include other config files.
 #
 # Like for 'rules_files', specifying a folder will load all the configs files present in it in a lexicographical order.
+#
+# 3 merge-strategies are available: `append`, `override` and `add-only`.
+# The default merge-strategy is `append`:
+#   * existing sequence keys will be appended
+#   * existing scalar keys will be overridden
+#   * non-existing keys will be added
+# To enable the `override` merge-strategy, `@` must be prepended to the entry, eg: "@/$HOME/override.yaml".
+#   * existing keys will be overridden
+#   * non-existing keys will be added
+#   Please note that since `@` is a reserved character by yaml spec, you need to quote the whole string.
+# To enable the `add-only` merge-strategy, `+` must be prepended to the entry, eg: "+/$HOME/append.yaml".
+#   * existing keys will be ignored
+#   * non-existing keys will be added
+#
+# When a merge-strategy is enabled for a folder entry, all of the included config files will use that merge-strategy.
path: falco.yaml
user: login: FedeDP
body: We can do that too! Will work on it asap
created_at: 2025-04-29T07:54:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -143,6 +143,21 @@
 # Also, nested include is not allowed, ie: included config files won't be able to include other config files.
 #
 # Like for 'rules_files', specifying a folder will load all the configs files present in it in a lexicographical order.
+#
+# 3 merge-strategies are available: `append`, `override` and `add-only`.
+# The default merge-strategy is `append`:
+#   * existing sequence keys will be appended
+#   * existing scalar keys will be overridden
+#   * non-existing keys will be added
+# To enable the `override` merge-strategy, `@` must be prepended to the entry, eg: "@/$HOME/override.yaml".
+#   * existing keys will be overridden
+#   * non-existing keys will be added
+#   Please note that since `@` is a reserved character by yaml spec, you need to quote the whole string.
+# To enable the `add-only` merge-strategy, `+` must be prepended to the entry, eg: "+/$HOME/append.yaml".
+#   * existing keys will be ignored
+#   * non-existing keys will be added
+#
+# When a merge-strategy is enabled for a folder entry, all of the included config files will use that merge-strategy.
path: falco.yaml
user: login: FedeDP
body: Done. ~~Note that most of lines changed comes from config json schema being re-formatted.~~
created_at: 2025-04-29T08:55:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -85,6 +85,12 @@ class yaml_helper {
 	inline static const std::string validation_failed = "failed";
 	inline static const std::string validation_none = "none";
 
+	enum config_files_strategy {
+		STRATEGY_APPEND,  // append to existing sequence keys, override scalar keys and add new ones
+		STRATEGY_OVERRIDE,  // override existing keys (sequences too) and add new ones
+		STRATEGY_ADDONLY    // only add new keys and ignore existing ones
+	};
+
path: userspace/engine/yaml_helper.h
user: login: sgaist
body: It might be overkill but shouldn't we have something here that allows to convert this enum from/to string ?
This would keep all the conversions in one place close to the enum.
created_at: 2025-04-29T09:42:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -85,6 +85,12 @@ class yaml_helper {
 	inline static const std::string validation_failed = "failed";
 	inline static const std::string validation_none = "none";
 
+	enum config_files_strategy {
+		STRATEGY_APPEND,  // append to existing sequence keys, override scalar keys and add new ones
+		STRATEGY_OVERRIDE,  // override existing keys (sequences too) and add new ones
+		STRATEGY_ADDONLY    // only add new keys and ignore existing ones
+	};
+
path: userspace/engine/yaml_helper.h
user: login: FedeDP
body: > It might be overkill but shouldn't we have something here that allows to convert this enum from/to string

Yep i agree! Will do that in a moment!
created_at: 2025-04-29T10:17:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -85,6 +85,12 @@ class yaml_helper {
 	inline static const std::string validation_failed = "failed";
 	inline static const std::string validation_none = "none";
 
+	enum config_files_strategy {
+		STRATEGY_APPEND,  // append to existing sequence keys, override scalar keys and add new ones
+		STRATEGY_OVERRIDE,  // override existing keys (sequences too) and add new ones
+		STRATEGY_ADDONLY    // only add new keys and ignore existing ones
+	};
+
path: userspace/engine/yaml_helper.h
user: login: FedeDP
body: Done!
created_at: 2025-04-29T10:21:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -38,7 +38,30 @@ const char config_schema_string[] = LONG_STRING_CONST(
                 "config_files": {
                     "type": "array",
                     "items": {
-                        "type": "string"
+                        "oneOf": [
path: userspace/falco/config_json_schema.h
user: login: FedeDP
body: Either string or an object.
In case of object, `path` is required, while `strategy` must be an enum between `append,override,add-only`.
created_at: 2025-04-29T10:23:10Z
author_association: CONTRIBUTOR

PR comments:
user: login: poiana
created_at: 2025-04-24T10:17:51Z
body: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/falcosecurity/falco/pull/3538#" title="Author self-approved">FedeDP</a>*

The full list of commands accepted by this bot can be found [here](https://prow.falco.org/command-help?repo=falcosecurity%2Ffalco).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/falcosecurity/falco/blob/master/OWNERS)~~ [FedeDP]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-24T10:18:06Z
body: <!-- add-pr-comment:add-pr-comment -->

This PR may bring feature or behavior changes in the Falco engine and may require the engine version to be bumped.

Please double check **userspace/engine/falco_engine_version.h** file. See [versioning for FALCO_ENGINE_VERSION](https://github.com/falcosecurity/falco/blob/master/RELEASE.md#falco-repo-this-repo).

/hold
author_association: NONE
user: login: FedeDP
created_at: 2025-04-24T10:18:23Z
body: /cc @leogr @sgaist 
author_association: CONTRIBUTOR
user: login: leogr
created_at: 2025-04-28T09:10:37Z
body: /kind feature
author_association: MEMBER
user: login: FedeDP
created_at: 2025-04-29T10:12:09Z
body: Rebased on top of new master.
author_association: CONTRIBUTOR
user: login: FedeDP
created_at: 2025-04-29T12:31:51Z
body: /unhold
author_association: CONTRIBUTOR