diff --git a/Cargo.lock b/Cargo.lock index 0115bd646938..cc54fab6d53d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -734,9 +734,11 @@ dependencies = [ "clap", "dd-agent-log", "dd-procmgr-client", + "hyper-util", "libc", "log", "nix", + "saphyr-parser", "serde", "serde_json", "serde_yaml", diff --git a/pkg/config/schema/yaml/core_schema.yaml b/pkg/config/schema/yaml/core_schema.yaml index 7105be461945..78b2eb941de8 100644 --- a/pkg/config/schema/yaml/core_schema.yaml +++ b/pkg/config/schema/yaml/core_schema.yaml @@ -62,6 +62,7 @@ properties: Infrastructure mode The infrastructure mode is used to determine the features that are available to the agent. The possible values are: full, basic, end_user_device, cloud_cost_only, none. + Mirrored in pkg/procmgr/rust/src/config_gate/env_bindings.rs for procmgr config gates. dd_url: node_type: setting type: string @@ -7803,6 +7804,9 @@ properties: node_type: setting type: boolean default: false + comment: |- + Non-default env bindings for keys used by procmgr config gates are mirrored in + pkg/procmgr/rust/src/config_gate/env_bindings.rs. include_ephemeral_containers: node_type: setting type: boolean diff --git a/pkg/config/schema/yaml/process_config.yaml b/pkg/config/schema/yaml/process_config.yaml index ed27739d4b1a..c3fb40847b77 100644 --- a/pkg/config/schema/yaml/process_config.yaml +++ b/pkg/config/schema/yaml/process_config.yaml @@ -326,6 +326,7 @@ properties: - DD_PROCESS_CONFIG_ENABLED - DD_PROCESS_AGENT_ENABLED comment: |- + Env bindings below are mirrored in pkg/procmgr/rust/src/config_gate/env_bindings.rs. "process_config.enabled" is deprecated. We must still be able to detect if it is present, to know if we should use it or container_collection.enabled and process_collection.enabled. diff --git a/pkg/config/setup/BUILD.bazel b/pkg/config/setup/BUILD.bazel index 57871add76f4..cdb38dd975c5 100644 --- a/pkg/config/setup/BUILD.bazel +++ b/pkg/config/setup/BUILD.bazel @@ -87,6 +87,7 @@ dd_agent_go_test( "config_init_test.go", "config_secret_test.go", "config_test.go", + "config_windows_test.go", "privateactionrunner_test.go", "process_test.go", "system_probe_test.go", diff --git a/pkg/config/setup/config_test.go b/pkg/config/setup/config_test.go index eb1dcbd7b09c..8db13f9ce3d8 100644 --- a/pkg/config/setup/config_test.go +++ b/pkg/config/setup/config_test.go @@ -55,6 +55,10 @@ func unsetProxyEnvForTest(t *testing.T) { } } +// testFleetPoliciesDir is a fixed path used in YAML round-trip tests so fleet_policies_dir +// is deterministic across platforms (env is read during InitConfig; FleetConfigOverride skips when set). +const testFleetPoliciesDir = `C:\testdata\fleet\policies` + func TestDefaults(t *testing.T) { config := newTestConf(t) @@ -1394,6 +1398,7 @@ process_config: `) func TestConfigAssignAtPath(t *testing.T) { + t.Setenv("DD_FLEET_POLICIES_DIR", testFleetPoliciesDir) config := newTestConf(t) config.SetInTest("use_proxy_for_cloud_metadata", true) @@ -1420,6 +1425,7 @@ func TestConfigAssignAtPath(t *testing.T) { - changed https://url2.eu: - third +fleet_policies_dir: C:\testdata\fleet\policies process_config: additional_endpoints: https://url1.com: @@ -1485,6 +1491,7 @@ secret_backend_arguments: `) func TestConfigAssignAtPathSimple(t *testing.T) { + t.Setenv("DD_FLEET_POLICIES_DIR", testFleetPoliciesDir) config := newTestConf(t) config.SetInTest("use_proxy_for_cloud_metadata", true) @@ -1498,7 +1505,8 @@ func TestConfigAssignAtPathSimple(t *testing.T) { err = configAssignAtPath(config, []string{"secret_backend_arguments", "0"}, "password1") assert.NoError(t, err) - expectedYaml := `secret_backend_arguments: + expectedYaml := `fleet_policies_dir: C:\testdata\fleet\policies +secret_backend_arguments: - password1 secret_backend_command: some command use_proxy_for_cloud_metadata: true @@ -1510,6 +1518,7 @@ use_proxy_for_cloud_metadata: true } func TestConfigMustMatchOrigin(t *testing.T) { + t.Setenv("DD_FLEET_POLICIES_DIR", testFleetPoliciesDir) testMinimalConf := []byte(`apm_config: apm_dd_url: ENC[some_url] @@ -1525,6 +1534,7 @@ use_proxy_for_cloud_metadata: true expectedYaml := `apm_config: apm_dd_url: first_value +fleet_policies_dir: C:\testdata\fleet\policies secret_backend_command: command use_proxy_for_cloud_metadata: true ` @@ -1532,6 +1542,12 @@ use_proxy_for_cloud_metadata: true apm_dd_url: second_value secret_backend_command: command use_proxy_for_cloud_metadata: true +` + expectedDiffConfigYaml := `apm_config: + apm_dd_url: second_value +fleet_policies_dir: C:\testdata\fleet\policies +secret_backend_command: command +use_proxy_for_cloud_metadata: true ` config := newTestConf(t) @@ -1573,7 +1589,7 @@ use_proxy_for_cloud_metadata: true // now the original config was modified because of the origin match yamlConf, err = yaml.Marshal(config.AllSettingsWithoutDefault()) assert.NoError(t, err) - assert.YAMLEq(t, expectedDiffYaml, string(yamlConf)) + assert.YAMLEq(t, expectedDiffConfigYaml, string(yamlConf)) } func TestConfigAssignAtPathForIntMapKeys(t *testing.T) { @@ -1604,9 +1620,15 @@ additional_endpoints: ) } +func TestServerlessConfigNumComponents(t *testing.T) { + // Enforce the number of config "components" reachable by the serverless agent + // to avoid accidentally adding entire components if it's not needed + require.Len(t, commonConfigComponents, 24) +} + func TestServerlessConfigInit(t *testing.T) { conf := newEmptyMockConf(t) - initCommonBase(conf) + initCommonConfigComponents(conf) // ensure some core configs are declared assert.True(t, conf.IsKnown("api_key")) diff --git a/pkg/config/setup/config_windows.go b/pkg/config/setup/config_windows.go index 8016420fecbc..2f417dc44c69 100644 --- a/pkg/config/setup/config_windows.go +++ b/pkg/config/setup/config_windows.go @@ -6,17 +6,31 @@ package setup import ( + "path/filepath" + pkgconfigmodel "github.com/DataDog/datadog-agent/pkg/config/model" "github.com/DataDog/datadog-agent/pkg/util/winutil" ) -// FleetConfigOverride sets the fleet_policies_dir config value to the value set in the registry. +// FleetConfigOverride sets fleet_policies_dir for every Windows agent binary that loads +// config through pkg/config/setup (registered in fixup_init; system-probe calls it directly). +// +// Resolution order (first non-empty wins): datadog.yaml / DD_FLEET_POLICIES_DIR, then the +// registry experiment path, then defaultStableFleetPoliciesDir. Mirrors dd-procmgr config +// gates in pkg/procmgr/rust/src/config_gate.rs. +// +// The stable ProgramData fallback is intentional global parity with procmgr-managed children +// (process-agent, PAR, DDOT): they no longer carry DD_FLEET_POLICIES_DIR in processes.d, so +// all Windows binaries must resolve the same managed policy directory without per-service env. // -// This value tells the agent to load a config experiment from Fleet Automation. +// Standalone installs are unaffected: comp/core/config and system-probe call MergeFleetPolicy +// only after fleet_policies_dir is set, and MergeFleetPolicy no-ops when the policy YAML file +// is absent (pkg/config/nodetreemodel/config.go). On fleet-managed hosts between experiments +// (registry empty, stable managed datadog.yaml present), the stable layer now merges as +// SourceFleetPolicies — previously nothing merged in that window. // -// Linux sets this option with an environment variable in the experiment's systemd unit file, -// so we need a different approach for Windows. After the viper migration is complete, we can -// consider replacing this override with a Windows Registry config source. +// Linux sets fleet_policies_dir via environment in experiment systemd units; after the viper +// migration we may replace this override with a Windows registry config source. func FleetConfigOverride(config pkgconfigmodel.Config) { // Prioritize the value set in the config file / env var if config.IsConfigured("fleet_policies_dir") { @@ -24,9 +38,27 @@ func FleetConfigOverride(config pkgconfigmodel.Config) { } val := winutil.ReadFleetPoliciesDirFromRegistry() + if val == "" { + val = defaultStableFleetPoliciesDir() + } if val == "" { return } config.Set("fleet_policies_dir", val, pkgconfigmodel.SourceAgentRuntime) } + +// defaultStableFleetPoliciesDir returns the stable managed fleet policies directory under +// ProgramData. Matches pkg/fleet/installer/paths.FleetPoliciesDirForManagedProcess without +// importing fleet/installer (circular dependency with config/setup). +// +// Used as the global FleetConfigOverride fallback, not only for dd-procmgr: every Windows +// binary that merges fleet policy YAML must find the same stable directory when the registry +// experiment path is unset. +func defaultStableFleetPoliciesDir() string { + dataDir, err := winutil.GetProgramDataDirForProduct("Datadog Agent") + if err != nil || dataDir == "" { + return "" + } + return filepath.Join(dataDir, "Installer", "managed", "datadog-agent", "stable") +} diff --git a/pkg/config/setup/config_windows_test.go b/pkg/config/setup/config_windows_test.go new file mode 100644 index 000000000000..16c0a8860065 --- /dev/null +++ b/pkg/config/setup/config_windows_test.go @@ -0,0 +1,40 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2016-present Datadog, Inc. + +//go:build windows && test + +package setup + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +const expectedStableFleetPoliciesRel = "datadog-agent/stable" + +func TestFleetConfigOverride_FallsBackToStableFleetPoliciesDirWhenUnset(t *testing.T) { + t.Setenv("DD_FLEET_POLICIES_DIR", "") + + config := newTestConf(t) + FleetConfigOverride(config) + + dir := config.GetString("fleet_policies_dir") + assert.NotEmpty(t, dir) + normalized := filepath.ToSlash(filepath.Clean(dir)) + assert.True(t, strings.HasSuffix(normalized, expectedStableFleetPoliciesRel), normalized) +} + +func TestFleetConfigOverride_RespectsEnvOverride(t *testing.T) { + const customDir = `C:\custom\fleet\policies` + t.Setenv("DD_FLEET_POLICIES_DIR", customDir) + + config := newTestConf(t) + FleetConfigOverride(config) + + assert.Equal(t, customDir, config.GetString("fleet_policies_dir")) +} diff --git a/pkg/procmgr/rust/Cargo.toml b/pkg/procmgr/rust/Cargo.toml index 2a16506ef951..43f3ce36045b 100644 --- a/pkg/procmgr/rust/Cargo.toml +++ b/pkg/procmgr/rust/Cargo.toml @@ -28,6 +28,7 @@ log.workspace = true serde = { workspace = true, features = ["derive"] } serde_json.workspace = true serde_yaml.workspace = true +saphyr-parser = { workspace = true } dd-agent-log.workspace = true dd-procmgr-client.workspace = true tokio = { workspace = true, features = ["macros", "rt-multi-thread", "signal", "process", "fs", "time", "sync"] } @@ -68,6 +69,7 @@ nix = { workspace = true, features = ["signal", "process", "user"] } [dev-dependencies] tempfile.workspace = true +hyper-util = { workspace = true, features = ["tokio"] } [[test]] name = "e2e" diff --git a/pkg/procmgr/rust/src/config.rs b/pkg/procmgr/rust/src/config.rs index 816868a36098..b65a7b00d94e 100644 --- a/pkg/procmgr/rust/src/config.rs +++ b/pkg/procmgr/rust/src/config.rs @@ -3,6 +3,8 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2026-present Datadog, Inc. + +use crate::config_gate::ConditionConfigFile; use crate::platform; use anyhow::{Context, Result}; use log::{debug, info, warn}; @@ -193,6 +195,8 @@ pub struct ProcessConfig { #[serde(default = "default_true")] pub auto_start: bool, pub condition_path_exists: Option, + #[serde(default)] + pub condition_config_any: Vec, pub stop_timeout: Option, #[serde(default = "default_restart")] pub restart: RestartPolicy, @@ -228,6 +232,7 @@ impl Default for ProcessConfig { stderr: "inherit".to_string(), auto_start: true, condition_path_exists: None, + condition_config_any: Vec::new(), stop_timeout: None, restart: RestartPolicy::Never, restart_sec: None, @@ -484,6 +489,34 @@ condition_path_exists: /usr/bin/sleep assert!(result.is_err()); } + #[test] + fn test_process_agent_config_gate_parsing() { + let dir = tempfile::tempdir().unwrap(); + let yaml = r#" +command: /bin/process-agent +auto_start: true +condition_config_any: + - path: /etc/datadog-agent/datadog.yaml + keys: + - process_config.enabled + - process_config.process_collection.enabled + - path: /etc/datadog-agent/system-probe.yaml + keys: + - network_config.enabled +"#; + fs::write(dir.path().join("proc.yaml"), yaml).unwrap(); + let configs = load_configs(dir.path()).unwrap(); + assert_eq!(configs.len(), 1); + assert_eq!(configs[0].config.condition_config_any.len(), 2); + assert_eq!( + configs[0].config.condition_config_any[0].keys, + vec![ + "process_config.enabled".to_string(), + "process_config.process_collection.enabled".to_string(), + ] + ); + } + #[test] fn test_ddot_example_config() { let dir = tempfile::tempdir().unwrap(); diff --git a/pkg/procmgr/rust/src/config_gate.rs b/pkg/procmgr/rust/src/config_gate.rs new file mode 100644 index 000000000000..a156677bc7ea --- /dev/null +++ b/pkg/procmgr/rust/src/config_gate.rs @@ -0,0 +1,2136 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +mod env_bindings; +mod system_probe; +mod yaml_load; + +use env_bindings::{env_bool_for_config_key, env_configured_for_key, env_string_for_config_key}; + +use crate::env::expand_env_vars; +use serde::Deserialize; +use std::collections::HashMap; +use std::collections::hash_map::Entry; +use std::path::Path; + +/// A YAML file and dotted config keys; any key set to true satisfies the gate. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct ConditionConfigFile { + pub path: String, + #[serde(default)] + pub keys: Vec, +} + +struct GatedKeySpec { + key: &'static str, + default: bool, + /// Basename under `fleet_policies_dir` when fleet policy overrides apply. + fleet_policy_file: Option<&'static str>, +} + +/// Single source of truth for gated keys (mirrors `pkg/config/setup/process_settings.go` +/// and `pkg/config/setup/system_probe.go`). +const GATED_KEY_SPECS: &[GatedKeySpec] = &[ + GatedKeySpec { + key: "process_config.enabled", + default: false, + fleet_policy_file: Some("datadog.yaml"), + }, + GatedKeySpec { + key: "process_config.process_collection.enabled", + default: false, + fleet_policy_file: Some("datadog.yaml"), + }, + GatedKeySpec { + key: "process_config.container_collection.enabled", + default: true, + fleet_policy_file: Some("datadog.yaml"), + }, + GatedKeySpec { + key: "process_config.process_discovery.enabled", + default: true, + fleet_policy_file: Some("datadog.yaml"), + }, + GatedKeySpec { + key: "network_config.enabled", + default: false, + fleet_policy_file: Some("system-probe.yaml"), + }, + GatedKeySpec { + key: "system_probe_config.enabled", + default: false, + fleet_policy_file: Some("system-probe.yaml"), + }, +]; + +/// Legacy `process_config.enabled` values after `loadProcessTransforms`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProcessEnabledMode { + Disabled, + ProcessesOnly, + ContainersOnly, +} + +impl ProcessEnabledMode { + fn process_collection(self) -> bool { + matches!(self, Self::ProcessesOnly) + } + + fn container_collection(self) -> bool { + matches!(self, Self::ContainersOnly) + } +} + +const LEGACY_PROCESS_ENABLED_KEY: &str = "process_config.enabled"; +const LEGACY_FLEET_POLICY_FILE: &str = "datadog.yaml"; + +impl GatedKeySpec { + /// Resolution order (most keys): legacy `process_config.enabled` transform (collection keys only) + /// → fleet policy → env → base YAML → agent default. + /// + /// `system_probe_config.enabled` is special: returns [`system_probe::derived_enabled`] only, + /// mirroring post-`load()`/`Adjust` `GetBool` (module-derived runtime value). + /// + /// Legacy transforms mirror `loadProcessTransforms`. Fleet policy outranks env vars + /// (`SourceFleetPolicies` > `SourceEnvVar`). + fn enabled(&self, base_path: &str, yaml: &mut YamlCache) -> anyhow::Result { + if let Some(enabled) = self.legacy_collection_override(base_path, yaml)? { + return Ok(enabled); + } + if self.key == "system_probe_config.enabled" { + // Mirrors sysprobeConf.GetBool("system_probe_config.enabled") after load()+Adjust: + // runtime enabled is module-derived, not the literal YAML/env knob alone. + return system_probe::derived_enabled(base_path, yaml); + } + if let Some(enabled) = self.fleet_policy_value(base_path, yaml)? { + return Ok(enabled); + } + if let Some(enabled) = self.env_override() { + return Ok(enabled); + } + if let Some(enabled) = yaml.bool_key_if_exists(base_path, self.key)? { + return Ok(enabled); + } + Ok(self.default) + } + + fn uses_legacy_process_enabled(&self) -> bool { + matches!( + self.key, + "process_config.process_collection.enabled" + | "process_config.container_collection.enabled" + ) + } + + fn legacy_collection_override( + &self, + base_path: &str, + yaml: &mut YamlCache, + ) -> anyhow::Result> { + if !self.uses_legacy_process_enabled() { + return Ok(None); + } + let Some(mode) = resolve_legacy_process_enabled_mode(base_path, yaml)? else { + return Ok(None); + }; + let enabled = match self.key { + "process_config.process_collection.enabled" => mode.process_collection(), + "process_config.container_collection.enabled" => mode.container_collection(), + _ => unreachable!(), + }; + Ok(Some(enabled)) + } + + fn fleet_policy_value( + &self, + base_path: &str, + yaml: &mut YamlCache, + ) -> anyhow::Result> { + let Some(filename) = self.fleet_policy_file else { + return Ok(None); + }; + let Some(path) = yaml.fleet_policy_path(filename, base_path)? else { + return Ok(None); + }; + yaml.bool_key_if_exists(&path, self.key) + } + + fn env_override(&self) -> Option { + env_bool_for_config_key(self.key) + } +} + +pub(super) struct YamlCache(HashMap); + +impl YamlCache { + /// Mirrors system-probe/agent fleet policy loading: env → gated config file → registry/default. + /// + /// System-probe gates do not inherit `fleet_policies_dir` from sibling `datadog.yaml` + /// (matches `applyFleetPolicy` on the system-probe config object). + fn fleet_policies_dir(&mut self, config_path: &str) -> anyhow::Result> { + if let Some(dir) = env_bindings::env_var_value_for_name("DD_FLEET_POLICIES_DIR") { + return Ok(Some(dir)); + } + if let Some(dir) = self.fleet_policies_dir_in_yaml(config_path)? { + return Ok(Some(dir)); + } + #[cfg(windows)] + { + Ok(crate::platform::fleet_policies_dir_fallback() + .map(|path| path.to_string_lossy().into_owned())) + } + #[cfg(not(windows))] + { + Ok(None) + } + } + + fn fleet_policies_dir_in_yaml(&mut self, config_path: &str) -> anyhow::Result> { + let Some(value) = self.dotted_key_if_exists(config_path, "fleet_policies_dir")? else { + return Ok(None); + }; + Self::string_value(value) + } + + fn fleet_policy_path( + &mut self, + filename: &str, + config_path: &str, + ) -> anyhow::Result> { + Ok(self.fleet_policies_dir(config_path)?.map(|dir| { + Path::new(&dir) + .join(filename) + .to_string_lossy() + .into_owned() + })) + } + + /// Fleet policy → env bindings → base YAML → `false`. + pub(super) fn resolve_bool( + &mut self, + base_path: &str, + key: &str, + fleet_policy_file: Option<&str>, + ) -> anyhow::Result { + if let Some(filename) = fleet_policy_file + && let Some(path) = self.fleet_policy_path(filename, base_path)? + && let Some(value) = self.bool_key_if_exists(&path, key)? + { + return Ok(value); + } + if let Some(enabled) = env_bool_for_config_key(key) { + return Ok(enabled); + } + Ok(self.bool_key_if_exists(base_path, key)?.unwrap_or(false)) + } + + /// Like [`Self::resolve_bool`] but uses `default` when the key is unset everywhere. + pub(super) fn resolve_bool_with_default( + &mut self, + base_path: &str, + key: &str, + fleet_policy_file: Option<&str>, + default: bool, + ) -> anyhow::Result { + if let Some(filename) = fleet_policy_file + && let Some(path) = self.fleet_policy_path(filename, base_path)? + && let Some(value) = self.bool_key_if_exists(&path, key)? + { + return Ok(value); + } + if let Some(enabled) = env_bool_for_config_key(key) { + return Ok(enabled); + } + Ok(self.bool_key_if_exists(base_path, key)?.unwrap_or(default)) + } + + pub(super) fn resolve_string( + &mut self, + base_path: &str, + key: &str, + fleet_policy_file: Option<&str>, + ) -> anyhow::Result> { + if let Some(filename) = fleet_policy_file + && let Some(path) = self.fleet_policy_path(filename, base_path)? + && let Some(value) = self.dotted_key_if_exists(&path, key)? + { + return Self::string_value(value); + } + if let Some(text) = env_string_for_config_key(key) { + return Ok(Some(text)); + } + match self.dotted_key_if_exists(base_path, key)? { + Some(value) => Self::string_value(value), + None => Ok(None), + } + } + + /// Whether `key` is present in the base YAML file only (not fleet policy or env). + pub(super) fn key_in_yaml(&mut self, path: &str, key: &str) -> anyhow::Result { + Ok(self.dotted_key_if_exists(path, key)?.is_some()) + } + + /// Whether `key` is explicitly set via fleet policy, env, or base YAML. + /// + /// Mirrors Go `IsConfigured` for NPM back-compat (`adjust.go`). + pub(super) fn is_configured( + &mut self, + base_path: &str, + key: &str, + fleet_policy_file: Option<&str>, + ) -> anyhow::Result { + if env_configured_for_key(key) { + return Ok(true); + } + if let Some(filename) = fleet_policy_file + && let Some(path) = self.fleet_policy_path(filename, base_path)? + && self.dotted_key_if_exists(&path, key)?.is_some() + { + return Ok(true); + } + self.key_in_yaml(base_path, key) + } + + fn string_value(value: &serde_yaml::Value) -> anyhow::Result> { + match value { + serde_yaml::Value::String(text) => Ok(Some(text.clone())), + serde_yaml::Value::Bool(_) | serde_yaml::Value::Number(_) => Ok(None), + _ => Ok(None), + } + } + + fn load(&mut self, path: &str) -> anyhow::Result<&serde_yaml::Value> { + match self.0.entry(path.to_owned()) { + Entry::Occupied(entry) => Ok(entry.into_mut()), + Entry::Vacant(entry) => { + let contents = std::fs::read_to_string(path) + .map_err(|err| anyhow::anyhow!("read {path}: {err}"))?; + let root = yaml_load::load_yaml(&contents) + .map_err(|err| anyhow::anyhow!("parse {path}: {err}"))?; + Ok(entry.insert(root)) + } + } + } + + fn bool_key(&mut self, path: &str, key: &str) -> anyhow::Result> { + let Some(value) = self.dotted_key(path, key)? else { + return Ok(None); + }; + value_as_bool(value) + .ok_or_else(|| anyhow::anyhow!("key {key} is not a bool")) + .map(Some) + } + + fn bool_key_if_exists(&mut self, path: &str, key: &str) -> anyhow::Result> { + if !Path::new(path).is_file() { + return Ok(None); + } + self.bool_key(path, key) + } + + fn dotted_key<'a>( + &'a mut self, + path: &str, + key: &str, + ) -> anyhow::Result> { + Ok(lookup_dotted_key(self.load(path)?, key)) + } + + pub(super) fn dotted_key_if_exists<'a>( + &'a mut self, + path: &str, + key: &str, + ) -> anyhow::Result> { + if !Path::new(path).is_file() { + return Ok(None); + } + self.dotted_key(path, key) + } + + #[cfg(test)] + fn loaded_file_count(&self) -> usize { + self.0.len() + } +} + +/// Returns true when `conditions` is empty or any `(path, key)` pair is enabled. +pub fn condition_config_any_met(conditions: &[ConditionConfigFile]) -> bool { + if conditions.is_empty() { + return true; + } + + let mut yaml = YamlCache(HashMap::new()); + conditions.iter().any(|file| { + let path = expand_env_vars(&file.path); + file.keys.iter().any(|key| { + config_key_enabled(&path, key, &mut yaml).unwrap_or_else(|err| { + log::debug!("condition_config_any: {path} key {key}: {err:#}"); + false + }) + }) + }) +} + +fn resolve_legacy_process_enabled_mode( + base_path: &str, + yaml: &mut YamlCache, +) -> anyhow::Result> { + if let Some(path) = yaml.fleet_policy_path(LEGACY_FLEET_POLICY_FILE, base_path)? + && let Some(mode) = legacy_enabled_mode_from_file(yaml, &path)? + { + return Ok(Some(mode)); + } + if let Some(mode) = legacy_enabled_env_mode() { + return Ok(Some(mode)); + } + legacy_enabled_mode_from_file(yaml, base_path) +} + +fn legacy_enabled_mode_from_file( + yaml: &mut YamlCache, + path: &str, +) -> anyhow::Result> { + let Some(value) = yaml.dotted_key_if_exists(path, LEGACY_PROCESS_ENABLED_KEY)? else { + return Ok(None); + }; + Ok(legacy_enabled_mode(value)) +} + +fn legacy_enabled_env_mode() -> Option { + env_string_for_config_key(LEGACY_PROCESS_ENABLED_KEY) + .map(|value| legacy_enabled_mode_from_string(&value)) +} + +fn legacy_enabled_mode(value: &serde_yaml::Value) -> Option { + legacy_enabled_scalar_as_string(value).map(|text| legacy_enabled_mode_from_string(&text)) +} + +/// Mirrors Go `GetString` for legacy `process_config.enabled` scalars (string, bool, number). +fn legacy_enabled_scalar_as_string(value: &serde_yaml::Value) -> Option { + match value { + serde_yaml::Value::String(text) => Some(text.clone()), + serde_yaml::Value::Bool(enabled) => Some(enabled.to_string()), + serde_yaml::Value::Number(number) => { + if let Some(value) = number.as_i64() { + return Some(value.to_string()); + } + if let Some(value) = number.as_u64() { + return Some(value.to_string()); + } + number.as_f64().map(|value| { + if value.fract() == 0.0 && value.is_finite() { + format!("{}", value as i64) + } else { + value.to_string() + } + }) + } + _ => None, + } +} + +fn legacy_enabled_mode_from_string(text: &str) -> ProcessEnabledMode { + // Mirror loadProcessTransforms: ToLower without trim; exact "disabled" match; + // ParseBool for the true branch; everything else is containers-only. + let lower = text.to_ascii_lowercase(); + if lower == "disabled" { + ProcessEnabledMode::Disabled + } else if parse_agent_bool_string(&lower).unwrap_or(false) { + ProcessEnabledMode::ProcessesOnly + } else { + ProcessEnabledMode::ContainersOnly + } +} + +fn config_key_enabled(path: &str, key: &str, yaml: &mut YamlCache) -> anyhow::Result { + GATED_KEY_SPECS + .iter() + .find(|spec| spec.key == key) + .ok_or_else(|| anyhow::anyhow!("unknown config key {key}"))? + .enabled(path, yaml) +} + +fn lookup_mapping_case_insensitive<'a>( + mapping: &'a serde_yaml::Mapping, + key: &str, +) -> Option<&'a serde_yaml::Value> { + if let Some(value) = mapping.get(key) { + return Some(value); + } + mapping.iter().find_map(|(k, v)| { + k.as_str() + .filter(|segment| segment.eq_ignore_ascii_case(key)) + .map(|_| v) + }) +} + +fn lookup_dotted_key<'a>(root: &'a serde_yaml::Value, key: &str) -> Option<&'a serde_yaml::Value> { + lookup_dotted_key_in_mapping(root, key) +} + +/// Whether a YAML node counts as an explicit config value for presence/`IsConfigured`. +/// +/// Mirrors Agent `read_config_file.go`: known nil leaves (`setting_name:` with no value) +/// are ignored and do not mark the key configured. +fn yaml_value_is_set(value: &serde_yaml::Value) -> bool { + !matches!(value, serde_yaml::Value::Null) +} + +/// Resolves a dotted config key in raw YAML, mirroring Agent flattened keys. +/// +/// The Agent expands keys containing `.` into nested maps in +/// `read_config_file.go`; serde_yaml preserves literal dotted keys. At each +/// mapping, try the full remaining key before descending segment-by-segment. +fn lookup_dotted_key_in_mapping<'a>( + current: &'a serde_yaml::Value, + key: &str, +) -> Option<&'a serde_yaml::Value> { + let mapping = current.as_mapping()?; + + if let Some(value) = lookup_mapping_case_insensitive(mapping, key) { + return yaml_value_is_set(value).then_some(value); + } + + let (first, rest) = key.split_once('.')?; + let next = lookup_mapping_case_insensitive(mapping, first)?; + if !yaml_value_is_set(next) { + return None; + } + lookup_dotted_key_in_mapping(next, rest) +} + +fn value_as_bool(value: &serde_yaml::Value) -> Option { + match value { + // Plain YAML 1.1 bools (`yes`/`on`/…) are coerced to bool at load time in [`yaml_load`]. + serde_yaml::Value::Bool(enabled) => Some(*enabled), + serde_yaml::Value::Number(number) => number.as_i64().map(|n| n != 0), + // Quoted scalars and env vars: `strconv.ParseBool` only. + serde_yaml::Value::String(text) => Some(parse_agent_bool_string(text).unwrap_or(false)), + _ => None, + } +} + +/// Mirrors Go `strconv.ParseBool` for env var bindings. +pub(super) fn parse_agent_bool_string(text: &str) -> Option { + match text { + "1" | "t" | "T" => Some(true), + "0" | "f" | "F" => Some(false), + _ if text.eq_ignore_ascii_case("true") => Some(true), + _ if text.eq_ignore_ascii_case("false") => Some(false), + _ => None, + } +} + +/// Human-readable path for logs when a config gate blocks startup. +pub fn condition_config_summary(conditions: &[ConditionConfigFile]) -> String { + conditions + .iter() + .flat_map(|file| { + let path = expand_env_vars(&file.path); + file.keys.iter().map(move |key| format!("{path}:{key}")) + }) + .collect::>() + .join(", ") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use std::path::Path; + use std::sync::Mutex; + + static ENV_TEST_LOCK: Mutex<()> = Mutex::new(()); + + fn write_config(dir: &Path, name: &str, body: &str) -> String { + let path = dir.join(name); + let mut file = std::fs::File::create(&path).unwrap(); + file.write_all(body.as_bytes()).unwrap(); + path.to_string_lossy().into_owned() + } + + /// Agent YAML with every process-agent gate key off (including `container_collection` default). + const ALL_PROCESS_GATES_OFF: &str = "\ +process_config: + process_collection: + enabled: false + container_collection: + enabled: false + process_discovery: + enabled: false +"; + + fn process_agent_conditions(agent_path: String) -> Vec { + vec![ConditionConfigFile { + path: agent_path, + keys: vec![ + "process_config.enabled".into(), + "process_config.process_collection.enabled".into(), + "process_config.container_collection.enabled".into(), + "process_config.process_discovery.enabled".into(), + ], + }] + } + + fn process_agent_windows_conditions( + agent_path: String, + sysprobe_path: String, + ) -> Vec { + vec![ + ConditionConfigFile { + path: agent_path, + keys: vec![ + "process_config.enabled".into(), + "process_config.process_collection.enabled".into(), + "process_config.container_collection.enabled".into(), + "process_config.process_discovery.enabled".into(), + ], + }, + ConditionConfigFile { + path: sysprobe_path, + keys: vec![ + "network_config.enabled".into(), + "system_probe_config.enabled".into(), + ], + }, + ] + } + + fn with_env_lock(test: F) { + let _lock = ENV_TEST_LOCK.lock().unwrap_or_else(|err| err.into_inner()); + test(); + } + + fn clear_gated_env_vars() { + // SAFETY: callers must hold ENV_TEST_LOCK. + unsafe { std::env::remove_var("DD_FLEET_POLICIES_DIR") }; + for env_name in super::env_bindings::all_bound_env_var_names() { + // SAFETY: callers must hold ENV_TEST_LOCK. + unsafe { std::env::remove_var(env_name) }; + } + } + + struct EnvGuard { + name: &'static str, + previous: Option, + } + + impl EnvGuard { + fn set(name: &'static str, value: &str) -> Self { + let previous = std::env::var(name).ok(); + // SAFETY: callers must hold ENV_TEST_LOCK. + unsafe { std::env::set_var(name, value) }; + Self { name, previous } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + match &self.previous { + Some(value) => unsafe { std::env::set_var(self.name, value) }, + None => unsafe { std::env::remove_var(self.name) }, + } + } + } + + #[test] + fn empty_conditions_are_met() { + assert!(condition_config_any_met(&[])); + } + + #[test] + fn any_matching_key_enables_gate() { + with_env_lock(|| { + clear_gated_env_vars(); + + let dir = tempfile::tempdir().unwrap(); + let agent = write_config( + dir.path(), + "datadog.yaml", + "process_config:\n process_collection:\n enabled: false\n process_discovery:\n enabled: true\n", + ); + let conditions = vec![ConditionConfigFile { + path: agent, + keys: vec![ + "process_config.process_collection.enabled".into(), + "process_config.process_discovery.enabled".into(), + ], + }]; + assert!(condition_config_any_met(&conditions)); + }); + } + + #[test] + fn lookup_dotted_key_is_case_insensitive() { + let yaml: serde_yaml::Value = + serde_yaml::from_str("process_config:\n Process_Collection:\n enabled: true\n") + .unwrap(); + assert_eq!( + lookup_dotted_key(&yaml, "process_config.process_collection.enabled"), + Some(&serde_yaml::Value::Bool(true)) + ); + } + + #[test] + fn lookup_dotted_key_supports_flattened_top_level_yaml() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + "process_config.process_collection.enabled: true\nprocess_config.container_collection.enabled: false\nprocess_config.process_discovery.enabled: false\n", + ) + .unwrap(); + assert_eq!( + lookup_dotted_key(&yaml, "process_config.process_collection.enabled"), + Some(&serde_yaml::Value::Bool(true)) + ); + } + + #[test] + fn lookup_dotted_key_treats_null_leaf_as_absent() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + "network_config:\n enabled:\nsystem_probe_config:\n enabled: true\n", + ) + .unwrap(); + assert_eq!(lookup_dotted_key(&yaml, "network_config.enabled"), None); + assert_eq!( + lookup_dotted_key(&yaml, "system_probe_config.enabled"), + Some(&serde_yaml::Value::Bool(true)) + ); + + let flat: serde_yaml::Value = + serde_yaml::from_str("network_config.enabled:\nsystem_probe_config.enabled: true\n") + .unwrap(); + assert_eq!(lookup_dotted_key(&flat, "network_config.enabled"), None); + } + + #[test] + fn lookup_dotted_key_supports_partially_flattened_yaml() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + "process_config:\n process_collection.enabled: true\n container_collection:\n enabled: false\n process_discovery:\n enabled: false\n", + ) + .unwrap(); + assert_eq!( + lookup_dotted_key(&yaml, "process_config.process_collection.enabled"), + Some(&serde_yaml::Value::Bool(true)) + ); + } + + #[test] + fn flattened_yaml_key_enables_gate() { + with_env_lock(|| { + clear_gated_env_vars(); + + let dir = tempfile::tempdir().unwrap(); + let agent = write_config( + dir.path(), + "datadog.yaml", + "process_config.process_collection.enabled: true\nprocess_config.container_collection.enabled: false\nprocess_config.process_discovery.enabled: false\n", + ); + assert!(condition_config_any_met(&process_agent_conditions(agent))); + }); + } + + #[test] + fn duplicate_yaml_key_last_value_wins_for_config_gate() { + with_env_lock(|| { + clear_gated_env_vars(); + + let dir = tempfile::tempdir().unwrap(); + let agent = write_config( + dir.path(), + "datadog.yaml", + "process_config:\n enabled: false\n process_collection:\n enabled: false\n container_collection:\n enabled: false\n process_discovery:\n enabled: false\nprocess_config:\n process_collection:\n enabled: true\n", + ); + assert!(condition_config_any_met(&process_agent_conditions(agent))); + }); + } + + #[test] + fn mixed_case_yaml_key_enables_gate() { + with_env_lock(|| { + clear_gated_env_vars(); + + let dir = tempfile::tempdir().unwrap(); + let agent = write_config( + dir.path(), + "datadog.yaml", + "process_config:\n Process_Collection:\n enabled: true\n container_collection:\n enabled: false\n process_discovery:\n enabled: false\n", + ); + assert!(condition_config_any_met(&process_agent_conditions(agent))); + }); + } + + #[test] + fn stock_config_uses_agent_defaults() { + with_env_lock(|| { + clear_gated_env_vars(); + + let dir = tempfile::tempdir().unwrap(); + let agent = write_config(dir.path(), "datadog.yaml", "# api_key: placeholder\n"); + assert!(condition_config_any_met(&process_agent_conditions(agent))); + }); + } + + #[test] + fn all_false_keys_block_gate() { + with_env_lock(|| { + clear_gated_env_vars(); + + let dir = tempfile::tempdir().unwrap(); + let agent = write_config( + dir.path(), + "datadog.yaml", + "process_config:\n enabled: disabled\n process_discovery:\n enabled: false\n", + ); + assert!(!condition_config_any_met(&process_agent_conditions(agent))); + }); + } + + #[test] + fn legacy_enabled_false_enables_container_collection() { + with_env_lock(|| { + clear_gated_env_vars(); + + let dir = tempfile::tempdir().unwrap(); + let agent = write_config( + dir.path(), + "datadog.yaml", + "process_config:\n enabled: false\n process_discovery:\n enabled: false\n", + ); + assert!(condition_config_any_met(&process_agent_conditions(agent))); + }); + } + + #[test] + fn legacy_enabled_true_enables_process_collection() { + with_env_lock(|| { + clear_gated_env_vars(); + + let dir = tempfile::tempdir().unwrap(); + let agent = write_config( + dir.path(), + "datadog.yaml", + "process_config:\n enabled: true\n process_discovery:\n enabled: false\n", + ); + assert!(condition_config_any_met(&process_agent_conditions(agent))); + }); + } + + #[test] + fn legacy_enabled_numeric_one_enables_process_collection() { + with_env_lock(|| { + clear_gated_env_vars(); + + let dir = tempfile::tempdir().unwrap(); + let agent = write_config( + dir.path(), + "datadog.yaml", + "process_config:\n enabled: 1\n process_collection:\n enabled: false\n container_collection:\n enabled: false\n process_discovery:\n enabled: false\n", + ); + assert!(condition_config_any_met(&process_agent_conditions(agent))); + }); + } + + #[test] + fn legacy_enabled_numeric_zero_enables_container_collection() { + with_env_lock(|| { + clear_gated_env_vars(); + + let dir = tempfile::tempdir().unwrap(); + let agent = write_config( + dir.path(), + "datadog.yaml", + "process_config:\n enabled: 0\n process_collection:\n enabled: false\n process_discovery:\n enabled: false\n", + ); + assert!(condition_config_any_met(&process_agent_conditions(agent))); + }); + } + + #[test] + fn env_override_can_disable_default_enabled_keys() { + with_env_lock(|| { + clear_gated_env_vars(); + let _collection = + EnvGuard::set("DD_PROCESS_CONFIG_CONTAINER_COLLECTION_ENABLED", "false"); + let _discovery = EnvGuard::set("DD_PROCESS_CONFIG_PROCESS_DISCOVERY_ENABLED", "false"); + + let dir = tempfile::tempdir().unwrap(); + let agent = write_config(dir.path(), "datadog.yaml", "# api_key: placeholder\n"); + assert!(!condition_config_any_met(&process_agent_conditions(agent))); + }); + } + + #[test] + fn env_override_can_enable_when_yaml_keys_missing() { + with_env_lock(|| { + clear_gated_env_vars(); + let _collection = + EnvGuard::set("DD_PROCESS_CONFIG_CONTAINER_COLLECTION_ENABLED", "false"); + let _discovery = EnvGuard::set("DD_PROCESS_CONFIG_PROCESS_DISCOVERY_ENABLED", "true"); + + let dir = tempfile::tempdir().unwrap(); + let agent = write_config(dir.path(), "datadog.yaml", "# api_key: placeholder\n"); + assert!(condition_config_any_met(&process_agent_conditions(agent))); + }); + } + + #[test] + fn env_bool_and_configured_ignore_empty_values() { + with_env_lock(|| { + clear_gated_env_vars(); + let _empty = EnvGuard::set("DD_PROCESS_CONFIG_PROCESS_DISCOVERY_ENABLED", ""); + + assert_eq!( + env_bool_for_config_key("process_config.process_discovery.enabled"), + None + ); + assert!(!env_configured_for_key( + "process_config.process_discovery.enabled" + )); + }); + } + + #[test] + fn env_bool_falls_through_empty_to_next_bound_var() { + with_env_lock(|| { + clear_gated_env_vars(); + let _empty = EnvGuard::set("DD_PROCESS_CONFIG_PROCESS_DISCOVERY_ENABLED", ""); + let _legacy = EnvGuard::set("DD_PROCESS_CONFIG_DISCOVERY_ENABLED", "true"); + + assert_eq!( + env_bool_for_config_key("process_config.process_discovery.enabled"), + Some(true) + ); + assert!(env_configured_for_key( + "process_config.process_discovery.enabled" + )); + }); + } + + #[cfg(windows)] + struct CoreAgentScmEnvGuard; + + #[cfg(windows)] + impl Drop for CoreAgentScmEnvGuard { + fn drop(&mut self) { + crate::platform::set_test_core_agent_scm_env(None); + } + } + + #[cfg(windows)] + #[test] + fn env_bool_reads_core_agent_scm_when_process_env_unset() { + use std::collections::HashMap; + + with_env_lock(|| { + clear_gated_env_vars(); + crate::platform::set_test_core_agent_scm_env(Some(HashMap::from([ + ( + "DD_PROCESS_CONFIG_CONTAINER_COLLECTION_ENABLED".to_string(), + "false".to_string(), + ), + ( + "DD_PROCESS_CONFIG_PROCESS_DISCOVERY_ENABLED".to_string(), + "false".to_string(), + ), + ]))); + let _scm = CoreAgentScmEnvGuard; + + let dir = tempfile::tempdir().unwrap(); + let agent = write_config(dir.path(), "datadog.yaml", "# api_key: placeholder\n"); + assert!(!condition_config_any_met(&process_agent_conditions(agent))); + }); + } + + #[cfg(windows)] + #[test] + fn env_bool_prefers_core_agent_scm_over_process_env() { + use std::collections::HashMap; + + with_env_lock(|| { + clear_gated_env_vars(); + crate::platform::set_test_core_agent_scm_env(Some(HashMap::from([( + "DD_PROCESS_CONFIG_CONTAINER_COLLECTION_ENABLED".to_string(), + "false".to_string(), + )]))); + let _scm = CoreAgentScmEnvGuard; + let _process = EnvGuard::set("DD_PROCESS_CONFIG_CONTAINER_COLLECTION_ENABLED", "true"); + + assert_eq!( + env_bool_for_config_key("process_config.container_collection.enabled"), + Some(false) + ); + }); + } + + #[cfg(windows)] + #[test] + fn legacy_enabled_env_reads_core_agent_scm_when_process_env_unset() { + use std::collections::HashMap; + + with_env_lock(|| { + clear_gated_env_vars(); + crate::platform::set_test_core_agent_scm_env(Some(HashMap::from([ + ( + "DD_PROCESS_CONFIG_ENABLED".to_string(), + "disabled".to_string(), + ), + ( + "DD_PROCESS_CONFIG_CONTAINER_COLLECTION_ENABLED".to_string(), + "false".to_string(), + ), + ( + "DD_PROCESS_CONFIG_PROCESS_DISCOVERY_ENABLED".to_string(), + "false".to_string(), + ), + ]))); + let _scm = CoreAgentScmEnvGuard; + + let dir = tempfile::tempdir().unwrap(); + let agent = write_config(dir.path(), "datadog.yaml", "# api_key: placeholder\n"); + assert!(!condition_config_any_met(&process_agent_conditions(agent))); + }); + } + + #[test] + fn legacy_enabled_env_ignores_empty_values() { + with_env_lock(|| { + clear_gated_env_vars(); + let _empty = EnvGuard::set("DD_PROCESS_CONFIG_ENABLED", ""); + + let dir = tempfile::tempdir().unwrap(); + let agent = write_config( + dir.path(), + "datadog.yaml", + "process_config:\n enabled: disabled\n process_discovery:\n enabled: false\n", + ); + assert!(!condition_config_any_met(&process_agent_conditions(agent))); + }); + } + + #[test] + fn legacy_enabled_env_falls_through_empty_to_next_bound_var() { + with_env_lock(|| { + clear_gated_env_vars(); + let _empty = EnvGuard::set("DD_PROCESS_CONFIG_ENABLED", ""); + let _agent = EnvGuard::set("DD_PROCESS_AGENT_ENABLED", "disabled"); + + let dir = tempfile::tempdir().unwrap(); + let agent = write_config( + dir.path(), + "datadog.yaml", + "process_config:\n enabled: false\n process_discovery:\n enabled: false\n", + ); + assert!(!condition_config_any_met(&process_agent_conditions(agent))); + }); + } + + #[cfg(windows)] + #[test] + fn fleet_policies_dir_reads_core_agent_scm_env() { + use std::collections::HashMap; + + with_env_lock(|| { + clear_gated_env_vars(); + + let dir = tempfile::tempdir().unwrap(); + let fleet_dir = dir.path().join("fleet"); + std::fs::create_dir(&fleet_dir).unwrap(); + write_config( + &fleet_dir, + "datadog.yaml", + "process_config:\n process_collection:\n enabled: true\n", + ); + let agent = write_config( + dir.path(), + "datadog.yaml", + "process_config:\n enabled: false\n process_collection:\n enabled: false\n container_collection:\n enabled: false\n process_discovery:\n enabled: false\n", + ); + crate::platform::set_test_core_agent_scm_env(Some(HashMap::from([( + "DD_FLEET_POLICIES_DIR".to_string(), + fleet_dir.to_string_lossy().into_owned(), + )]))); + let _scm = CoreAgentScmEnvGuard; + + assert!(condition_config_any_met(&process_agent_conditions(agent))); + }); + } + + #[test] + fn fleet_policy_disables_default_enabled_keys() { + with_env_lock(|| { + clear_gated_env_vars(); + + let dir = tempfile::tempdir().unwrap(); + let fleet_dir = dir.path().join("fleet"); + std::fs::create_dir(&fleet_dir).unwrap(); + write_config( + &fleet_dir, + "datadog.yaml", + "process_config:\n container_collection:\n enabled: false\n process_discovery:\n enabled: false\n", + ); + let agent = write_config(dir.path(), "datadog.yaml", "# api_key: placeholder\n"); + let _fleet = EnvGuard::set( + "DD_FLEET_POLICIES_DIR", + fleet_dir.to_string_lossy().as_ref(), + ); + assert!(!condition_config_any_met(&process_agent_conditions(agent))); + }); + } + + #[test] + fn fleet_policy_enables_when_base_config_is_all_false() { + with_env_lock(|| { + clear_gated_env_vars(); + + let dir = tempfile::tempdir().unwrap(); + let fleet_dir = dir.path().join("fleet"); + std::fs::create_dir(&fleet_dir).unwrap(); + write_config( + &fleet_dir, + "datadog.yaml", + "process_config:\n process_collection:\n enabled: true\n", + ); + let agent = write_config( + dir.path(), + "datadog.yaml", + "process_config:\n enabled: false\n process_collection:\n enabled: false\n container_collection:\n enabled: false\n process_discovery:\n enabled: false\n", + ); + let _fleet = EnvGuard::set( + "DD_FLEET_POLICIES_DIR", + fleet_dir.to_string_lossy().as_ref(), + ); + assert!(condition_config_any_met(&process_agent_conditions(agent))); + }); + } + + #[test] + fn fleet_policies_dir_from_agent_yaml_enables_gate() { + with_env_lock(|| { + clear_gated_env_vars(); + + let dir = tempfile::tempdir().unwrap(); + let fleet_dir = dir.path().join("fleet"); + std::fs::create_dir(&fleet_dir).unwrap(); + write_config( + &fleet_dir, + "datadog.yaml", + "process_config:\n process_collection:\n enabled: true\n", + ); + let fleet_dir_str = fleet_dir.to_string_lossy(); + let agent = write_config( + dir.path(), + "datadog.yaml", + &format!( + "fleet_policies_dir: {fleet_dir_str}\nprocess_config:\n enabled: false\n process_collection:\n enabled: false\n container_collection:\n enabled: false\n process_discovery:\n enabled: false\n" + ), + ); + assert!(condition_config_any_met(&process_agent_conditions(agent))); + }); + } + + #[test] + fn fleet_policies_dir_from_system_probe_yaml_enables_gate() { + with_env_lock(|| { + clear_gated_env_vars(); + + let dir = tempfile::tempdir().unwrap(); + let fleet_dir = dir.path().join("fleet"); + std::fs::create_dir(&fleet_dir).unwrap(); + write_config( + &fleet_dir, + "system-probe.yaml", + "network_config:\n enabled: true\n", + ); + let fleet_dir_str = fleet_dir.to_string_lossy(); + write_config(dir.path(), "datadog.yaml", "# empty\n"); + let sysprobe = write_config( + dir.path(), + "system-probe.yaml", + &format!( + "fleet_policies_dir: {fleet_dir_str}\nnetwork_config:\n enabled: false\n" + ), + ); + let conditions = vec![ConditionConfigFile { + path: sysprobe, + keys: vec!["network_config.enabled".into()], + }]; + assert!(condition_config_any_met(&conditions)); + }); + } + + #[test] + fn fleet_policies_dir_in_datadog_yaml_ignored_for_system_probe_gate() { + with_env_lock(|| { + clear_gated_env_vars(); + + let dir = tempfile::tempdir().unwrap(); + let fleet_dir = dir.path().join("fleet"); + let other_fleet_dir = dir.path().join("other-fleet"); + std::fs::create_dir(&fleet_dir).unwrap(); + std::fs::create_dir(&other_fleet_dir).unwrap(); + write_config( + &fleet_dir, + "system-probe.yaml", + "network_config:\n enabled: true\n", + ); + write_config( + &other_fleet_dir, + "system-probe.yaml", + "network_config:\n enabled: false\n", + ); + let fleet_dir_str = fleet_dir.to_string_lossy(); + let other_fleet_dir_str = other_fleet_dir.to_string_lossy(); + write_config( + dir.path(), + "datadog.yaml", + &format!("fleet_policies_dir: {other_fleet_dir_str}\n"), + ); + let sysprobe = write_config( + dir.path(), + "system-probe.yaml", + &format!( + "fleet_policies_dir: {fleet_dir_str}\nnetwork_config:\n enabled: false\n" + ), + ); + let conditions = vec![ConditionConfigFile { + path: sysprobe, + keys: vec!["network_config.enabled".into()], + }]; + assert!(condition_config_any_met(&conditions)); + }); + } + + /// Linux/non-Windows: no registry fallback, so datadog-only fleet dir must not apply. + #[cfg(not(windows))] + #[test] + fn fleet_policies_dir_only_in_datadog_yaml_ignored_for_system_probe_gate() { + with_env_lock(|| { + clear_gated_env_vars(); + + let dir = tempfile::tempdir().unwrap(); + let fleet_dir = dir.path().join("fleet"); + std::fs::create_dir(&fleet_dir).unwrap(); + write_config( + &fleet_dir, + "system-probe.yaml", + "network_config:\n enabled: true\n", + ); + let fleet_dir_str = fleet_dir.to_string_lossy(); + write_config( + dir.path(), + "datadog.yaml", + &format!("fleet_policies_dir: {fleet_dir_str}\n"), + ); + let sysprobe = write_config( + dir.path(), + "system-probe.yaml", + "network_config:\n enabled: false\n", + ); + let conditions = vec![ConditionConfigFile { + path: sysprobe, + keys: vec!["network_config.enabled".into()], + }]; + assert!(!condition_config_any_met(&conditions)); + }); + } + + #[test] + fn fleet_system_probe_policy_enables_when_local_file_missing() { + with_env_lock(|| { + clear_gated_env_vars(); + + let dir = tempfile::tempdir().unwrap(); + let fleet_dir = dir.path().join("fleet"); + std::fs::create_dir(&fleet_dir).unwrap(); + write_config( + &fleet_dir, + "system-probe.yaml", + "network_config:\n enabled: true\n", + ); + let agent = write_config( + dir.path(), + "datadog.yaml", + "process_config:\n process_collection:\n enabled: false\n process_discovery:\n enabled: false\n", + ); + let sysprobe = dir.path().join("system-probe.yaml"); + let _fleet = EnvGuard::set( + "DD_FLEET_POLICIES_DIR", + fleet_dir.to_string_lossy().as_ref(), + ); + let conditions = vec![ + ConditionConfigFile { + path: agent, + keys: vec![ + "process_config.process_collection.enabled".into(), + "process_config.process_discovery.enabled".into(), + ], + }, + ConditionConfigFile { + path: sysprobe.to_string_lossy().into_owned(), + keys: vec!["network_config.enabled".into()], + }, + ]; + assert!(condition_config_any_met(&conditions)); + }); + } + + #[test] + fn fleet_system_probe_config_policy_enables_when_local_file_missing() { + with_env_lock(|| { + clear_gated_env_vars(); + + let dir = tempfile::tempdir().unwrap(); + let fleet_dir = dir.path().join("fleet"); + std::fs::create_dir(&fleet_dir).unwrap(); + write_config( + &fleet_dir, + "system-probe.yaml", + "system_probe_config:\n enabled: true\n", + ); + let agent = write_config( + dir.path(), + "datadog.yaml", + "process_config:\n process_collection:\n enabled: false\n process_discovery:\n enabled: false\n", + ); + let sysprobe = dir.path().join("system-probe.yaml"); + let _fleet = EnvGuard::set( + "DD_FLEET_POLICIES_DIR", + fleet_dir.to_string_lossy().as_ref(), + ); + let conditions = vec![ + ConditionConfigFile { + path: agent, + keys: vec![ + "process_config.process_collection.enabled".into(), + "process_config.process_discovery.enabled".into(), + ], + }, + ConditionConfigFile { + path: sysprobe.to_string_lossy().into_owned(), + keys: vec!["system_probe_config.enabled".into()], + }, + ]; + assert!(condition_config_any_met(&conditions)); + }); + } + + #[test] + fn fleet_legacy_enabled_transforms_collection_keys() { + with_env_lock(|| { + clear_gated_env_vars(); + + let dir = tempfile::tempdir().unwrap(); + let fleet_dir = dir.path().join("fleet"); + std::fs::create_dir(&fleet_dir).unwrap(); + write_config( + &fleet_dir, + "datadog.yaml", + "process_config:\n enabled: false\n", + ); + let agent = write_config( + dir.path(), + "datadog.yaml", + "process_config:\n process_collection:\n enabled: false\n process_discovery:\n enabled: false\n", + ); + let _fleet = EnvGuard::set( + "DD_FLEET_POLICIES_DIR", + fleet_dir.to_string_lossy().as_ref(), + ); + assert!(condition_config_any_met(&process_agent_conditions(agent))); + }); + } + + #[test] + fn fleet_policy_beats_local_system_probe_config() { + with_env_lock(|| { + clear_gated_env_vars(); + + let dir = tempfile::tempdir().unwrap(); + let fleet_dir = dir.path().join("fleet"); + std::fs::create_dir(&fleet_dir).unwrap(); + write_config( + &fleet_dir, + "system-probe.yaml", + "network_config:\n enabled: true\n", + ); + let agent = write_config( + dir.path(), + "datadog.yaml", + "process_config:\n process_collection:\n enabled: false\n process_discovery:\n enabled: false\n", + ); + let sysprobe = write_config( + dir.path(), + "system-probe.yaml", + "network_config:\n enabled: false\n", + ); + let _fleet = EnvGuard::set( + "DD_FLEET_POLICIES_DIR", + fleet_dir.to_string_lossy().as_ref(), + ); + let conditions = vec![ + ConditionConfigFile { + path: agent, + keys: vec![ + "process_config.process_collection.enabled".into(), + "process_config.process_discovery.enabled".into(), + ], + }, + ConditionConfigFile { + path: sysprobe, + keys: vec!["network_config.enabled".into()], + }, + ]; + assert!(condition_config_any_met(&conditions)); + }); + } + + #[test] + fn legacy_env_false_enables_container_collection() { + with_env_lock(|| { + clear_gated_env_vars(); + let _legacy = EnvGuard::set("DD_PROCESS_CONFIG_ENABLED", "false"); + + let dir = tempfile::tempdir().unwrap(); + let agent = write_config( + dir.path(), + "datadog.yaml", + "process_config:\n process_discovery:\n enabled: false\n", + ); + assert!(condition_config_any_met(&process_agent_conditions(agent))); + }); + } + + #[test] + fn legacy_env_whitespace_padded_disabled_enables_container_collection() { + with_env_lock(|| { + clear_gated_env_vars(); + let _legacy = EnvGuard::set("DD_PROCESS_CONFIG_ENABLED", " disabled "); + + let dir = tempfile::tempdir().unwrap(); + let agent = write_config( + dir.path(), + "datadog.yaml", + "process_config:\n process_discovery:\n enabled: false\n", + ); + assert!(condition_config_any_met(&process_agent_conditions(agent))); + }); + } + + #[test] + fn legacy_yaml_whitespace_padded_disabled_enables_container_collection() { + with_env_lock(|| { + clear_gated_env_vars(); + + let dir = tempfile::tempdir().unwrap(); + let agent = write_config( + dir.path(), + "datadog.yaml", + "process_config:\n enabled: \" disabled \"\n process_discovery:\n enabled: false\n", + ); + assert!(condition_config_any_met(&process_agent_conditions(agent))); + }); + } + + #[test] + fn missing_system_probe_without_fleet_blocks_gate() { + with_env_lock(|| { + clear_gated_env_vars(); + + let dir = tempfile::tempdir().unwrap(); + let agent = write_config( + dir.path(), + "datadog.yaml", + "process_config:\n process_collection:\n enabled: false\n process_discovery:\n enabled: false\n", + ); + let sysprobe = dir.path().join("system-probe.yaml"); + let conditions = vec![ + ConditionConfigFile { + path: agent, + keys: vec![ + "process_config.process_collection.enabled".into(), + "process_config.process_discovery.enabled".into(), + ], + }, + ConditionConfigFile { + path: sysprobe.to_string_lossy().into_owned(), + keys: vec![ + "network_config.enabled".into(), + "system_probe_config.enabled".into(), + ], + }, + ]; + assert!(!condition_config_any_met(&conditions)); + }); + } + + #[test] + fn local_system_probe_config_enables_gate() { + with_env_lock(|| { + clear_gated_env_vars(); + + let dir = tempfile::tempdir().unwrap(); + let agent = write_config( + dir.path(), + "datadog.yaml", + "process_config:\n process_collection:\n enabled: false\n process_discovery:\n enabled: false\n", + ); + let sysprobe = write_config( + dir.path(), + "system-probe.yaml", + "network_config:\n enabled: true\n", + ); + let conditions = vec![ + ConditionConfigFile { + path: agent, + keys: vec![ + "process_config.process_collection.enabled".into(), + "process_config.process_discovery.enabled".into(), + ], + }, + ConditionConfigFile { + path: sysprobe, + keys: vec!["network_config.enabled".into()], + }, + ]; + assert!(condition_config_any_met(&conditions)); + }); + } + + #[test] + fn env_override_enables_system_probe_network() { + with_env_lock(|| { + clear_gated_env_vars(); + let _network = EnvGuard::set("DD_SYSTEM_PROBE_NETWORK_ENABLED", "true"); + + let dir = tempfile::tempdir().unwrap(); + let agent = write_config( + dir.path(), + "datadog.yaml", + "process_config:\n process_collection:\n enabled: false\n process_discovery:\n enabled: false\n", + ); + let sysprobe = dir.path().join("system-probe.yaml"); + let conditions = vec![ + ConditionConfigFile { + path: agent, + keys: vec![ + "process_config.process_collection.enabled".into(), + "process_config.process_discovery.enabled".into(), + ], + }, + ConditionConfigFile { + path: sysprobe.to_string_lossy().into_owned(), + keys: vec!["network_config.enabled".into()], + }, + ]; + assert!(condition_config_any_met(&conditions)); + }); + } + + #[test] + fn fleet_policy_beats_env_override() { + with_env_lock(|| { + clear_gated_env_vars(); + + let dir = tempfile::tempdir().unwrap(); + let fleet_dir = dir.path().join("fleet"); + std::fs::create_dir(&fleet_dir).unwrap(); + write_config( + &fleet_dir, + "datadog.yaml", + "process_config:\n process_discovery:\n enabled: true\n", + ); + let agent = write_config(dir.path(), "datadog.yaml", "# api_key: placeholder\n"); + let _fleet = EnvGuard::set( + "DD_FLEET_POLICIES_DIR", + fleet_dir.to_string_lossy().as_ref(), + ); + let _discovery = EnvGuard::set("DD_PROCESS_CONFIG_PROCESS_DISCOVERY_ENABLED", "false"); + let _collection = + EnvGuard::set("DD_PROCESS_CONFIG_CONTAINER_COLLECTION_ENABLED", "false"); + assert!(condition_config_any_met(&process_agent_conditions(agent))); + }); + } + + #[test] + fn fleet_legacy_beats_env_for_process_enabled_transform() { + with_env_lock(|| { + clear_gated_env_vars(); + + let dir = tempfile::tempdir().unwrap(); + let fleet_dir = dir.path().join("fleet"); + std::fs::create_dir(&fleet_dir).unwrap(); + write_config( + &fleet_dir, + "datadog.yaml", + "process_config:\n enabled: true\n", + ); + let agent = write_config( + dir.path(), + "datadog.yaml", + "process_config:\n process_discovery:\n enabled: false\n", + ); + let _fleet = EnvGuard::set( + "DD_FLEET_POLICIES_DIR", + fleet_dir.to_string_lossy().as_ref(), + ); + let _legacy = EnvGuard::set("DD_PROCESS_CONFIG_ENABLED", "false"); + let conditions = vec![ConditionConfigFile { + path: agent, + keys: vec!["process_config.process_collection.enabled".into()], + }]; + assert!(condition_config_any_met(&conditions)); + }); + } + + #[test] + fn yaml_cache_reads_each_path_once() { + let dir = tempfile::tempdir().unwrap(); + let path = write_config( + dir.path(), + "datadog.yaml", + "process_config:\n container_collection:\n enabled: true\n process_discovery:\n enabled: false\n", + ); + + let mut cache = YamlCache(HashMap::new()); + for key in [ + "process_config.container_collection.enabled", + "process_config.process_discovery.enabled", + ] { + cache.bool_key(&path, key).unwrap(); + } + assert_eq!(cache.loaded_file_count(), 1); + } + + #[test] + fn missing_file_blocks_gate() { + let conditions = vec![ConditionConfigFile { + path: "/nonexistent/datadog.yaml".into(), + keys: vec!["process_config.enabled".into()], + }]; + assert!(!condition_config_any_met(&conditions)); + } + + #[test] + fn parse_agent_bool_string_matches_strconv_parse_bool() { + for (input, expected) in [ + ("1", Some(true)), + ("t", Some(true)), + ("T", Some(true)), + ("true", Some(true)), + ("TRUE", Some(true)), + ("True", Some(true)), + ("0", Some(false)), + ("f", Some(false)), + ("F", Some(false)), + ("false", Some(false)), + ("FALSE", Some(false)), + ("False", Some(false)), + ("yes", None), + ("on", None), + ("disabled", None), + (" true ", None), + (" false ", None), + (" 1 ", None), + ] { + assert_eq!(parse_agent_bool_string(input), expected, "input={input:?}"); + } + } + + #[cfg(windows)] + #[test] + fn env_bool_scm_whitespace_padded_does_not_enable_process_gates() { + use std::collections::HashMap; + + with_env_lock(|| { + clear_gated_env_vars(); + crate::platform::set_test_core_agent_scm_env(Some(HashMap::from([( + "DD_PROCESS_CONFIG_PROCESS_DISCOVERY_ENABLED".to_string(), + " true ".to_string(), + )]))); + let _scm = CoreAgentScmEnvGuard; + + let dir = tempfile::tempdir().unwrap(); + let agent = write_config(dir.path(), "datadog.yaml", ALL_PROCESS_GATES_OFF); + assert!(!condition_config_any_met(&process_agent_conditions(agent))); + }); + } + + #[test] + fn env_whitespace_padded_bool_does_not_enable_process_gates() { + with_env_lock(|| { + clear_gated_env_vars(); + let _discovery = EnvGuard::set("DD_PROCESS_CONFIG_PROCESS_DISCOVERY_ENABLED", " true "); + + let dir = tempfile::tempdir().unwrap(); + let agent = write_config(dir.path(), "datadog.yaml", ALL_PROCESS_GATES_OFF); + assert!(!condition_config_any_met(&process_agent_conditions(agent))); + }); + } + + #[test] + fn value_as_bool_handles_strings() { + assert_eq!( + value_as_bool(&serde_yaml::Value::String("disabled".into())), + Some(false) + ); + assert_eq!( + value_as_bool(&serde_yaml::Value::String("true".into())), + Some(true) + ); + assert_eq!( + value_as_bool(&serde_yaml::Value::String("1".into())), + Some(true) + ); + assert_eq!( + value_as_bool(&serde_yaml::Value::String("yes".into())), + Some(false) + ); + assert_eq!(value_as_bool(&serde_yaml::Value::Bool(true)), Some(true)); + } + + #[test] + fn condition_config_any_accepts_yaml_11_bool_spellings() { + with_env_lock(|| { + clear_gated_env_vars(); + + let dir = tempfile::tempdir().unwrap(); + let agent = write_config( + dir.path(), + "datadog.yaml", + "process_config:\n process_discovery:\n enabled: yes\n", + ); + let conditions = vec![ConditionConfigFile { + path: agent, + keys: vec!["process_config.process_discovery.enabled".into()], + }]; + assert!(condition_config_any_met(&conditions)); + }); + } + + #[test] + fn quoted_yaml_yes_does_not_enable_gate() { + with_env_lock(|| { + clear_gated_env_vars(); + + let dir = tempfile::tempdir().unwrap(); + let agent = write_config( + dir.path(), + "datadog.yaml", + "process_config:\n process_discovery:\n enabled: \"yes\"\n", + ); + let conditions = vec![ConditionConfigFile { + path: agent, + keys: vec!["process_config.process_discovery.enabled".into()], + }]; + assert!(!condition_config_any_met(&conditions)); + }); + } + + #[test] + fn env_yes_does_not_enable_gate() { + with_env_lock(|| { + clear_gated_env_vars(); + let _discovery = EnvGuard::set("DD_PROCESS_CONFIG_PROCESS_DISCOVERY_ENABLED", "yes"); + + let dir = tempfile::tempdir().unwrap(); + let agent = write_config(dir.path(), "datadog.yaml", ALL_PROCESS_GATES_OFF); + let sysprobe = write_config(dir.path(), "system-probe.yaml", "# empty\n"); + assert!(!condition_config_any_met( + &process_agent_windows_conditions(agent, sysprobe) + )); + }); + } + + #[test] + fn invalid_bool_value_blocks_gate() { + with_env_lock(|| { + clear_gated_env_vars(); + + let dir = tempfile::tempdir().unwrap(); + let agent = write_config( + dir.path(), + "datadog.yaml", + "process_config:\n process_collection:\n enabled: not-a-bool\n", + ); + let conditions = vec![ConditionConfigFile { + path: agent, + keys: vec!["process_config.process_collection.enabled".into()], + }]; + assert!(!condition_config_any_met(&conditions)); + }); + } + + #[test] + fn derived_tcp_queue_length_enables_system_probe_gate() { + with_env_lock(|| { + clear_gated_env_vars(); + + let dir = tempfile::tempdir().unwrap(); + let agent = write_config( + dir.path(), + "datadog.yaml", + "process_config:\n process_collection:\n enabled: false\n process_discovery:\n enabled: false\n", + ); + let sysprobe = write_config( + dir.path(), + "system-probe.yaml", + "system_probe_config:\n enable_tcp_queue_length: true\n", + ); + assert!(condition_config_any_met(&process_agent_windows_conditions( + agent, sysprobe + ))); + }); + } + + #[test] + fn derived_module_beats_explicit_system_probe_disabled() { + with_env_lock(|| { + clear_gated_env_vars(); + + let dir = tempfile::tempdir().unwrap(); + let agent = write_config( + dir.path(), + "datadog.yaml", + "process_config:\n process_collection:\n enabled: false\n process_discovery:\n enabled: false\n", + ); + let sysprobe = write_config( + dir.path(), + "system-probe.yaml", + "system_probe_config:\n enabled: false\n enable_oom_kill: true\n", + ); + assert!(condition_config_any_met(&process_agent_windows_conditions( + agent, sysprobe + ))); + }); + } + + #[test] + fn derived_npm_env_disable_blocks_back_compat_gate() { + with_env_lock(|| { + clear_gated_env_vars(); + let _network = EnvGuard::set("DD_SYSTEM_PROBE_NETWORK_ENABLED", "false"); + + let dir = tempfile::tempdir().unwrap(); + let agent = write_config(dir.path(), "datadog.yaml", ALL_PROCESS_GATES_OFF); + let sysprobe = write_config( + dir.path(), + "system-probe.yaml", + "system_probe_config:\n enabled: true\n", + ); + assert!(!condition_config_any_met( + &process_agent_windows_conditions(agent, sysprobe) + )); + }); + } + + #[test] + fn derived_npm_fleet_disable_blocks_back_compat_gate() { + with_env_lock(|| { + clear_gated_env_vars(); + + let dir = tempfile::tempdir().unwrap(); + let fleet_dir = dir.path().join("fleet"); + std::fs::create_dir(&fleet_dir).unwrap(); + write_config( + &fleet_dir, + "system-probe.yaml", + "network_config:\n enabled: false\n", + ); + let agent = write_config(dir.path(), "datadog.yaml", ALL_PROCESS_GATES_OFF); + let sysprobe = write_config( + dir.path(), + "system-probe.yaml", + "system_probe_config:\n enabled: true\n", + ); + let _fleet = EnvGuard::set( + "DD_FLEET_POLICIES_DIR", + fleet_dir.to_string_lossy().as_ref(), + ); + assert!(!condition_config_any_met( + &process_agent_windows_conditions(agent, sysprobe) + )); + }); + } + + #[test] + fn derived_npm_back_compat_with_usm_explicitly_disabled() { + with_env_lock(|| { + clear_gated_env_vars(); + + let dir = tempfile::tempdir().unwrap(); + let agent = write_config( + dir.path(), + "datadog.yaml", + "process_config:\n process_collection:\n enabled: false\n process_discovery:\n enabled: false\n", + ); + let sysprobe = write_config( + dir.path(), + "system-probe.yaml", + "system_probe_config:\n enabled: true\nservice_monitoring_config:\n enabled: false\n", + ); + assert!(condition_config_any_met(&process_agent_windows_conditions( + agent, sysprobe + ))); + }); + } + + #[test] + fn derived_npm_back_compat_ignores_valueless_network_config_enabled() { + with_env_lock(|| { + clear_gated_env_vars(); + + let dir = tempfile::tempdir().unwrap(); + let agent = write_config( + dir.path(), + "datadog.yaml", + "process_config:\n process_collection:\n enabled: false\n process_discovery:\n enabled: false\n", + ); + let sysprobe = write_config( + dir.path(), + "system-probe.yaml", + "system_probe_config:\n enabled: true\nnetwork_config:\n enabled:\nservice_monitoring_config:\n enabled: false\n", + ); + assert!(condition_config_any_met(&process_agent_windows_conditions( + agent, sysprobe + ))); + }); + } + + #[test] + fn derived_sk_tracer_disables_usm_for_system_probe_gate() { + with_env_lock(|| { + clear_gated_env_vars(); + + let dir = tempfile::tempdir().unwrap(); + let agent = write_config(dir.path(), "datadog.yaml", ALL_PROCESS_GATES_OFF); + let sysprobe = write_config( + dir.path(), + "system-probe.yaml", + "service_monitoring_config:\n enabled: true\nnetwork_config:\n enable_sk_tracer: true\n", + ); + assert!(!condition_config_any_met( + &process_agent_windows_conditions(agent, sysprobe) + )); + }); + } + + #[test] + fn derived_sk_tracer_co_re_env_disables_sk_tracer_gating() { + with_env_lock(|| { + clear_gated_env_vars(); + let _co_re = EnvGuard::set("DD_ENABLE_CO_RE", "false"); + + let dir = tempfile::tempdir().unwrap(); + let agent = write_config(dir.path(), "datadog.yaml", ALL_PROCESS_GATES_OFF); + let sysprobe = write_config( + dir.path(), + "system-probe.yaml", + "service_monitoring_config:\n enabled: true\nnetwork_config:\n enable_sk_tracer: true\n", + ); + assert!(condition_config_any_met(&process_agent_windows_conditions( + agent, sysprobe + ))); + }); + } + + #[test] + fn derived_discovery_service_map_disabled_when_ebpfless() { + with_env_lock(|| { + clear_gated_env_vars(); + + let dir = tempfile::tempdir().unwrap(); + let agent = write_config(dir.path(), "datadog.yaml", ALL_PROCESS_GATES_OFF); + let sysprobe = write_config( + dir.path(), + "system-probe.yaml", + "discovery:\n service_map:\n enabled: true\nnetwork_config:\n enable_ebpfless: true\n", + ); + assert!(!condition_config_any_met( + &process_agent_windows_conditions(agent, sysprobe) + )); + }); + } + + #[test] + fn derived_discovery_service_map_disabled_when_sk_tracer() { + with_env_lock(|| { + clear_gated_env_vars(); + + let dir = tempfile::tempdir().unwrap(); + let agent = write_config(dir.path(), "datadog.yaml", ALL_PROCESS_GATES_OFF); + let sysprobe = write_config( + dir.path(), + "system-probe.yaml", + "discovery:\n service_map:\n enabled: true\nnetwork_config:\n enable_sk_tracer: true\n", + ); + assert!(!condition_config_any_met( + &process_agent_windows_conditions(agent, sysprobe) + )); + }); + } + + /// Linux default: empty system-probe.yaml enables discovery → system-probe gate open. + #[cfg(target_os = "linux")] + #[test] + fn derived_discovery_linux_default_enables_system_probe_gate() { + with_env_lock(|| { + clear_gated_env_vars(); + + let dir = tempfile::tempdir().unwrap(); + let agent = write_config(dir.path(), "datadog.yaml", ALL_PROCESS_GATES_OFF); + let sysprobe = write_config(dir.path(), "system-probe.yaml", "# empty\n"); + assert!(condition_config_any_met(&process_agent_windows_conditions( + agent, sysprobe + ))); + }); + } + + /// Fargate platform_default overrides Linux discovery default. + #[cfg(target_os = "linux")] + #[test] + fn derived_discovery_fargate_default_disables_system_probe_gate() { + with_env_lock(|| { + clear_gated_env_vars(); + let _fargate = EnvGuard::set("AWS_EXECUTION_ENV", "AWS_ECS_FARGATE"); + + let dir = tempfile::tempdir().unwrap(); + let agent = write_config(dir.path(), "datadog.yaml", ALL_PROCESS_GATES_OFF); + let sysprobe = write_config(dir.path(), "system-probe.yaml", "# empty\n"); + assert!(!condition_config_any_met( + &process_agent_windows_conditions(agent, sysprobe) + )); + }); + } + + /// Explicit YAML false still disables discovery on Linux. + #[cfg(target_os = "linux")] + #[test] + fn derived_discovery_explicit_false_disables_on_linux() { + with_env_lock(|| { + clear_gated_env_vars(); + + let dir = tempfile::tempdir().unwrap(); + let agent = write_config(dir.path(), "datadog.yaml", ALL_PROCESS_GATES_OFF); + let sysprobe = write_config( + dir.path(), + "system-probe.yaml", + "discovery:\n enabled: false\n", + ); + assert!(!condition_config_any_met( + &process_agent_windows_conditions(agent, sysprobe) + )); + }); + } + + /// DD_DISCOVERY_ENABLED=false overrides Linux platform default. + #[cfg(target_os = "linux")] + #[test] + fn derived_discovery_env_disable_on_linux() { + with_env_lock(|| { + clear_gated_env_vars(); + let _discovery = EnvGuard::set("DD_DISCOVERY_ENABLED", "false"); + + let dir = tempfile::tempdir().unwrap(); + let agent = write_config(dir.path(), "datadog.yaml", ALL_PROCESS_GATES_OFF); + let sysprobe = write_config(dir.path(), "system-probe.yaml", "# empty\n"); + assert!(!condition_config_any_met( + &process_agent_windows_conditions(agent, sysprobe) + )); + }); + } + + #[test] + fn derived_usm_env_enables_system_probe_gate() { + with_env_lock(|| { + clear_gated_env_vars(); + let _usm = EnvGuard::set("DD_SYSTEM_PROBE_SERVICE_MONITORING_ENABLED", "true"); + + let dir = tempfile::tempdir().unwrap(); + let agent = write_config( + dir.path(), + "datadog.yaml", + "process_config:\n process_collection:\n enabled: false\n process_discovery:\n enabled: false\n", + ); + let sysprobe = write_config( + dir.path(), + "system-probe.yaml", + "service_monitoring_config:\n enabled: false\n", + ); + assert!(condition_config_any_met(&process_agent_windows_conditions( + agent, sysprobe + ))); + }); + } + + #[test] + fn derived_fleet_beats_env_for_module_toggle() { + with_env_lock(|| { + clear_gated_env_vars(); + + let dir = tempfile::tempdir().unwrap(); + let fleet_dir = dir.path().join("fleet"); + std::fs::create_dir(&fleet_dir).unwrap(); + write_config( + &fleet_dir, + "system-probe.yaml", + "service_monitoring_config:\n enabled: false\n", + ); + let agent = write_config(dir.path(), "datadog.yaml", ALL_PROCESS_GATES_OFF); + let sysprobe = write_config(dir.path(), "system-probe.yaml", "# empty\n"); + let _fleet = EnvGuard::set( + "DD_FLEET_POLICIES_DIR", + fleet_dir.to_string_lossy().as_ref(), + ); + let _usm = EnvGuard::set("DD_SYSTEM_PROBE_SERVICE_MONITORING_ENABLED", "true"); + assert!(!condition_config_any_met( + &process_agent_windows_conditions(agent, sysprobe) + )); + }); + } + + #[test] + fn condition_config_any_expands_dd_conf_dir_in_path() { + with_env_lock(|| { + clear_gated_env_vars(); + + let dir = tempfile::tempdir().unwrap(); + let conf_dir = dir.path().join("agent-conf"); + std::fs::create_dir_all(&conf_dir).unwrap(); + write_config( + &conf_dir, + "datadog.yaml", + "process_config:\n process_collection:\n enabled: true\n", + ); + let _conf = EnvGuard::set("DD_CONF_DIR", conf_dir.to_string_lossy().as_ref()); + let conditions = vec![ConditionConfigFile { + path: "${DD_CONF_DIR}/datadog.yaml".into(), + keys: vec!["process_config.process_collection.enabled".into()], + }]; + assert!(condition_config_any_met(&conditions)); + }); + } + + #[test] + fn condition_config_summary_formats_paths() { + let conditions = vec![ + ConditionConfigFile { + path: "/etc/datadog-agent/datadog.yaml".into(), + keys: vec![ + "process_config.enabled".into(), + "process_config.process_collection.enabled".into(), + ], + }, + ConditionConfigFile { + path: "/etc/datadog-agent/system-probe.yaml".into(), + keys: vec!["network_config.enabled".into()], + }, + ]; + assert_eq!( + condition_config_summary(&conditions), + "/etc/datadog-agent/datadog.yaml:process_config.enabled, /etc/datadog-agent/datadog.yaml:process_config.process_collection.enabled, /etc/datadog-agent/system-probe.yaml:network_config.enabled" + ); + } +} + +pub fn clear_secret_caches() {} diff --git a/pkg/procmgr/rust/src/config_gate/env_bindings.rs b/pkg/procmgr/rust/src/config_gate/env_bindings.rs new file mode 100644 index 000000000000..9af8ddef3b42 --- /dev/null +++ b/pkg/procmgr/rust/src/config_gate/env_bindings.rs @@ -0,0 +1,186 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +//! Config-key → environment-variable bindings for procmgr config gates. +//! +//! Source of truth: `env_vars` on each setting in `pkg/config/schema/yaml/` +//! (merged via `dda inv schema.codegen` into generated Go init settings). +//! +//! Keys listed in [`ENV_BINDINGS`] use **only** the named env vars. All other +//! keys use the agent convention `DD_`. +//! +//! This table covers only keys evaluated by config gates (`GATED_KEY_SPECS` in +//! `config_gate.rs`). Keep it in sync with the schema `env_vars` for those keys. +//! +//! On Windows, config gates resolve `DD_*` from the core Agent SCM `Environment` +//! registry first, then dd-procmgr's process environment, so service-local +//! overrides match agent config resolution. + +struct EnvBinding { + key: &'static str, + env_vars: &'static [&'static str], +} + +/// Non-default env bindings. Keys omitted here resolve via `DD_`. +const ENV_BINDINGS: &[EnvBinding] = &[ + // process.go / process_settings.go + EnvBinding { + key: "process_config.enabled", + env_vars: &["DD_PROCESS_CONFIG_ENABLED", "DD_PROCESS_AGENT_ENABLED"], + }, + EnvBinding { + key: "process_config.process_collection.enabled", + env_vars: &[ + "DD_PROCESS_CONFIG_PROCESS_COLLECTION_ENABLED", + "DD_PROCESS_AGENT_PROCESS_COLLECTION_ENABLED", + ], + }, + EnvBinding { + key: "process_config.container_collection.enabled", + env_vars: &[ + "DD_PROCESS_CONFIG_CONTAINER_COLLECTION_ENABLED", + "DD_PROCESS_AGENT_CONTAINER_COLLECTION_ENABLED", + ], + }, + EnvBinding { + key: "process_config.process_discovery.enabled", + env_vars: &[ + "DD_PROCESS_CONFIG_PROCESS_DISCOVERY_ENABLED", + "DD_PROCESS_AGENT_PROCESS_DISCOVERY_ENABLED", + "DD_PROCESS_CONFIG_DISCOVERY_ENABLED", + "DD_PROCESS_AGENT_DISCOVERY_ENABLED", + ], + }, + // system_probe_settings.go + EnvBinding { + key: "network_config.enabled", + env_vars: &["DD_SYSTEM_PROBE_NETWORK_ENABLED"], + }, + EnvBinding { + key: "system_probe_config.enabled", + env_vars: &["DD_SYSTEM_PROBE_ENABLED"], + }, + EnvBinding { + key: "service_monitoring_config.enabled", + env_vars: &["DD_SYSTEM_PROBE_SERVICE_MONITORING_ENABLED"], + }, + EnvBinding { + key: "system_probe_config.enable_co_re", + env_vars: &["DD_ENABLE_CO_RE"], + }, + EnvBinding { + key: "network_config.enable_ringbuffers", + env_vars: &["DD_SYSTEM_PROBE_NETWORK_ENABLE_RINGBUFFERS"], + }, + EnvBinding { + key: "network_config.enable_ebpfless", + env_vars: &["DD_ENABLE_EBPFLESS", "DD_NETWORK_CONFIG_ENABLE_EBPFLESS"], + }, + EnvBinding { + key: "system_probe_config.process_config.enabled", + env_vars: &["DD_SYSTEM_PROBE_PROCESS_ENABLED"], + }, + EnvBinding { + key: "dynamic_instrumentation.enabled", + env_vars: &["DD_DYNAMIC_INSTRUMENTATION_ENABLED"], + }, + // common_settings.go + EnvBinding { + key: "infrastructure_mode", + env_vars: &["DD_INFRASTRUCTURE_MODE"], + }, +]; + +pub(super) fn env_vars_for_key(key: &str) -> &'static [&'static str] { + ENV_BINDINGS + .iter() + .find(|binding| binding.key == key) + .map(|binding| binding.env_vars) + .unwrap_or(&[]) +} + +pub(super) fn env_bool_for_config_key(key: &str) -> Option { + let names = env_vars_for_key(key); + if !names.is_empty() { + return env_bool_from_names(names); + } + let auto = auto_env_var_for_key(key); + env_bool_from_names(&[&auto]) +} + +/// Whether any env var bound to `key` is set to a non-empty value (mirrors Go `IsConfigured` env source). +pub(super) fn env_configured_for_key(key: &str) -> bool { + let names = env_vars_for_key(key); + if !names.is_empty() { + return names.iter().any(|name| env_var_nonempty(name)); + } + env_var_nonempty(&auto_env_var_for_key(key)) +} + +pub(super) fn env_string_for_config_key(key: &str) -> Option { + let names = env_vars_for_key(key); + if !names.is_empty() { + for name in names { + if let Some(value) = env_var_value(name) { + return Some(value); + } + } + return None; + } + env_var_value(&auto_env_var_for_key(key)) +} + +/// Named env var with Agent SCM override first, then process env (Windows). +pub(super) fn env_var_value_for_name(name: &str) -> Option { + env_var_value(name) +} + +#[cfg(test)] +pub(super) fn all_bound_env_var_names() -> impl Iterator { + ENV_BINDINGS + .iter() + .flat_map(|binding| binding.env_vars.iter().copied()) +} + +fn auto_env_var_for_key(key: &str) -> String { + format!("DD_{}", key.replace('.', "_").to_uppercase()) +} + +fn env_var_nonempty(name: &str) -> bool { + env_var_value(name).is_some() +} + +fn env_bool_from_names(names: &[&str]) -> Option { + for name in names { + if let Some(value) = env_var_value(name) { + return Some(super::parse_agent_bool_string(&value).unwrap_or(false)); + } + } + None +} + +/// Core Agent SCM `Environment` overrides, then dd-procmgr process env. +/// SCM wins when present so config gates match agent service-local resolution. +fn env_var_value(name: &str) -> Option { + if let Some(value) = agent_scm_env_var(name) { + return Some(value); + } + if let Ok(value) = std::env::var(name) + && !value.is_empty() + { + return Some(value); + } + None +} + +#[cfg(windows)] +fn agent_scm_env_var(name: &str) -> Option { + crate::platform::core_agent_scm_env_var(name) +} + +#[cfg(not(windows))] +fn agent_scm_env_var(_name: &str) -> Option { + None +} diff --git a/pkg/procmgr/rust/src/config_gate/system_probe.rs b/pkg/procmgr/rust/src/config_gate/system_probe.rs new file mode 100644 index 000000000000..713a17e9d82f --- /dev/null +++ b/pkg/procmgr/rust/src/config_gate/system_probe.rs @@ -0,0 +1,238 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +//! Derived `system_probe_config.enabled` for process-manager config gates. +//! +//! # Keep in sync with Go +//! +//! Mirrors `load()` in `pkg/system-probe/config/config.go` and the NPM back-compat +//! rule in `pkg/system-probe/config/adjust.go`. Sk-tracer disables USM in +//! `pkg/system-probe/config/adjust_npm.go`; discovery conflicts in +//! `pkg/system-probe/config/adjust_discovery.go`. Module knob resolution uses +//! highest-priority configured source among fleet, secret (pre-fleet layers only), +//! env, and YAML ([`super::env_bindings`], `pkg/config/model/types.go` precedence). +//! +//! **When module enablement changes in Go, update `derived_enabled` below.** + +use std::path::Path; + +use super::YamlCache; + +const SYSPROBE_FLEET: &str = "system-probe.yaml"; +const AGENT_FLEET: &str = "datadog.yaml"; + +/// Returns whether any system-probe module would be enabled at runtime (post-`Adjust`). +pub(super) fn derived_enabled(sysprobe_path: &str, yaml: &mut YamlCache) -> anyhow::Result { + let agent = Path::new(sysprobe_path) + .parent() + .map(|dir| dir.join("datadog.yaml")) + .map(|path| path.to_string_lossy().into_owned()) + .unwrap_or_else(|| sysprobe_path.to_owned()); + + let mut cfg = Cfg { + sysprobe: sysprobe_path, + agent: &agent, + yaml, + }; + + // config.go:123-131 — values reused below (post-Adjust; USM may be cleared by sk tracer). + let npm = cfg.npm_enabled()?; + let usm = cfg.effective_usm_enabled()?; + let ccm = cfg.sp_bool("ccm_network_config.enabled")?; + let eudm = cfg + .agent_string("infrastructure_mode")? + .is_some_and(|m| m == "end_user_device"); + let csm = cfg.sp_bool("runtime_security_config.enabled")?; + let gpu = cfg.sp_bool("gpu_monitoring.enabled")?; + let di = cfg.sp_bool("dynamic_instrumentation.enabled")?; + let discovery_service_map = cfg.effective_discovery_service_map()?; + + // config.go:133-135 — NetworkTracerModule + let network_tracer = npm + || usm + || ccm + || eudm + || discovery_service_map + || (csm && cfg.sp_bool("runtime_security_config.network_monitoring.enabled")?); + if network_tracer { + return Ok(true); + } + + // config.go:136-141 — TCP queue length, OOM kill + if cfg.sp_bool("system_probe_config.enable_tcp_queue_length")? + || cfg.sp_bool("system_probe_config.enable_oom_kill")? + { + return Ok(true); + } + + // config.go:142-150 — EventMonitorModule + // `network_process.enabled` needs NetworkTracerModule too (config.go:146); when that is on we + // already returned above. + if csm + || cfg.sp_bool("runtime_security_config.fim_enabled")? + || cfg.agent_bool("sbom.enrichment.usage.enabled")? + || (usm && cfg.sp_bool("service_monitoring_config.enable_event_stream")?) + || gpu + || di + { + return Ok(true); + } + + // config.go:151-164 — ComplianceModule + if (cfg.agent_bool("compliance_config.enabled")? + && cfg.agent_bool("compliance_config.run_in_system_probe")?) + || cfg.sp_bool("compliance_config.database_benchmarks.enabled")? + || (csm && cfg.sp_bool("runtime_security_config.compliance_module.enabled")?) + { + return Ok(true); + } + + // config.go:187-188 — DiscoveryModule (schema platform_default: linux true, fargate/other false) + if cfg.effective_discovery_enabled()? { + return Ok(true); + } + + // config.go:165-194 — remaining modules with a single config knob each + for key in [ + "system_probe_config.process_config.enabled", + "ebpf_check.enabled", + "system_probe_config.language_detection.enabled", + "ping.enabled", + "traceroute.enabled", + "privileged_logs.enabled", + "noisy_neighbor.enabled", + "windows_crash_detection.enabled", + ] { + if cfg.sp_bool(key)? { + return Ok(true); + } + } + + #[cfg(target_os = "macos")] + if cfg.sp_bool("logon_duration.enabled")? { + return Ok(true); + } + + // config.go:210-221 — Windows/macOS modules with their own knob. + // Injector default-on-when-other-modules-enabled is skipped: it cannot be the + // first module to turn system-probe on. Auto-enabled Windows crash detection + // likewise requires network tracer or event monitor, already handled above. + #[cfg(any(windows, target_os = "macos"))] + { + if cfg.agent_bool("software_inventory.enabled")? + || cfg.sp_bool("injector.enable_telemetry")? + { + return Ok(true); + } + } + + Ok(false) +} + +/// Resolved config values from system-probe.yaml / datadog.yaml (+ fleet when set). +struct Cfg<'a> { + sysprobe: &'a str, + agent: &'a str, + yaml: &'a mut YamlCache, +} + +impl<'a> Cfg<'a> { + fn sp_bool(&mut self, key: &str) -> anyhow::Result { + self.yaml + .resolve_bool(self.sysprobe, key, Some(SYSPROBE_FLEET)) + } + + fn sp_bool_default(&mut self, key: &str, default: bool) -> anyhow::Result { + self.yaml + .resolve_bool_with_default(self.sysprobe, key, Some(SYSPROBE_FLEET), default) + } + + fn agent_bool(&mut self, key: &str) -> anyhow::Result { + self.yaml.resolve_bool(self.agent, key, Some(AGENT_FLEET)) + } + + fn agent_string(&mut self, key: &str) -> anyhow::Result> { + self.yaml.resolve_string(self.agent, key, Some(AGENT_FLEET)) + } + + fn sp_is_configured(&mut self, key: &str) -> anyhow::Result { + self.yaml + .is_configured(self.sysprobe, key, Some(SYSPROBE_FLEET)) + } + + /// adjust.go: `system_probe_config.enabled: true` with no NPM/USM block enables NPM. + fn npm_enabled(&mut self) -> anyhow::Result { + if self.sp_bool("network_config.enabled")? { + return Ok(true); + } + // Network: Go uses IsConfigured; USM: Go uses !GetBool (explicit `false` still allows back-compat). + // Keep in sync with `adjust.go` (`!cfg.IsConfigured(netNS("enabled"))`). + // Back-compat runs before adjustNetwork, so use raw USM here (not effective_usm_enabled). + if self.sp_bool("system_probe_config.enabled")? + && !self.sp_is_configured("network_config.enabled")? + && !self.sp_bool("service_monitoring_config.enabled")? + { + return Ok(true); + } + Ok(false) + } + + /// USM after `adjustNetwork`: sk tracer disables `service_monitoring_config.enabled`. + fn effective_usm_enabled(&mut self) -> anyhow::Result { + // adjust_npm.go:123-130 gates sk tracer; :131-135 disables USM when it stays on. + let sk_tracer = self.sp_bool("network_config.enable_sk_tracer")? + && self.sp_bool_default("system_probe_config.enable_co_re", true)? + && self.sp_bool_default("network_config.enable_ringbuffers", true)?; + if sk_tracer { + return Ok(false); + } + self.sp_bool("service_monitoring_config.enabled") + } + + /// `discovery.enabled` with schema platform defaults (`system-probe_schema.yaml`). + fn effective_discovery_enabled(&mut self) -> anyhow::Result { + self.sp_bool_default("discovery.enabled", discovery_enabled_platform_default()) + } + + /// Discovery service map after `adjustDiscovery` conflict checks. + fn effective_discovery_service_map(&mut self) -> anyhow::Result { + if !self.sp_bool("discovery.service_map.enabled")? { + return Ok(false); + } + // adjust_discovery.go:48-52 — full USM makes discovery redundant (pre-adjustNetwork). + if self.sp_bool("service_monitoring_config.enabled")? { + return Ok(false); + } + // adjust_discovery.go:61-77 — raw sk_tracer / ebpfless (before adjustNetwork). + if self.sp_bool("network_config.enable_sk_tracer")? + || self.sp_bool("network_config.enable_ebpfless")? + { + return Ok(false); + } + Ok(true) + } +} + +/// Schema `platform_default` for `discovery.enabled`. +fn discovery_enabled_platform_default() -> bool { + #[cfg(not(target_os = "linux"))] + { + return false; + } + #[cfg(target_os = "linux")] + { + !is_ecs_fargate() + } +} + +/// Mirrors `IsECSFargate` in `pkg/config/env/environment.go`. +#[cfg(target_os = "linux")] +fn is_ecs_fargate() -> bool { + std::env::var("ECS_FARGATE").is_ok_and(|v| !v.is_empty()) + || matches!( + std::env::var("AWS_EXECUTION_ENV").ok().as_deref(), + Some("AWS_ECS_FARGATE") + ) +} diff --git a/pkg/procmgr/rust/src/config_gate/yaml_load/mod.rs b/pkg/procmgr/rust/src/config_gate/yaml_load/mod.rs new file mode 100644 index 000000000000..556bd13d4ef1 --- /dev/null +++ b/pkg/procmgr/rust/src/config_gate/yaml_load/mod.rs @@ -0,0 +1,115 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +//! YAML loading for config gates. +//! +//! Primary path uses [`saphyr_parser`] so plain-scalar YAML 1.1 bools (`yes`/`on`/…) +//! are coerced at parse time, matching Go `gopkg.in/yaml.v2`. Quoted scalars stay strings. +//! +//! Duplicate mapping keys last-win (matching Go `yaml.Unmarshal`). On parse failure, +//! falls back to permissive [`serde_yaml`] with the same last-win semantics. + +mod permissive; +mod saphyr; + +use anyhow::{Context, Result}; +use log::debug; +use serde_yaml::Value; + +/// Parse YAML for config-gate lookups, matching Agent config file semantics. +pub(super) fn load_yaml(contents: &str) -> Result { + let mut root = match saphyr::load(contents) { + Ok(value) => value, + Err(err) => { + debug!("saphyr YAML parse failed, retrying serde_yaml permissive: {err}"); + permissive::load(contents).with_context(|| err.to_string())? + } + }; + root.apply_merge() + .context("apply YAML merge keys for config gate lookup")?; + Ok(root) +} + +#[cfg(test)] +mod tests { + use serde_yaml::Value; + + use super::*; + + fn dotted<'a>(root: &'a Value, path: &str) -> Option<&'a Value> { + let mut current = root; + for segment in path.split('.') { + current = current.get(segment)?; + } + Some(current) + } + + #[test] + fn strict_parse_used_when_no_duplicates() { + let root = load_yaml("process_config:\n enabled: true\n").unwrap(); + assert_eq!( + dotted(&root, "process_config.enabled"), + Some(&Value::Bool(true)) + ); + } + + #[test] + fn permissive_parse_keeps_last_duplicate_key() { + let yaml = "process_config:\n enabled: false\nprocess_config:\n enabled: true\n"; + let root = load_yaml(yaml).unwrap(); + assert_eq!( + dotted(&root, "process_config.enabled"), + Some(&Value::Bool(true)) + ); + } + + #[test] + fn permissive_parse_nested_duplicate_key_last_wins() { + let yaml = "process_config:\n process_collection:\n enabled: false\n process_collection:\n enabled: true\n"; + let root = load_yaml(yaml).unwrap(); + assert_eq!( + dotted(&root, "process_config.process_collection.enabled"), + Some(&Value::Bool(true)) + ); + } + + #[test] + fn permissive_parse_accepts_null_with_duplicate_keys() { + let yaml = "network_config:\n enabled:\nsystem_probe_config:\n enabled: true\nprocess_config:\n enabled: false\nprocess_config:\n process_collection:\n enabled: true\n"; + let root = load_yaml(yaml).unwrap(); + assert_eq!(dotted(&root, "network_config.enabled"), Some(&Value::Null)); + assert_eq!( + dotted(&root, "system_probe_config.enabled"), + Some(&Value::Bool(true)) + ); + assert_eq!( + dotted(&root, "process_config.process_collection.enabled"), + Some(&Value::Bool(true)) + ); + } + + #[test] + fn merge_keys_expand_disabled_process_config_defaults() { + let yaml = r#" +disabled: &disabled + process_collection: + enabled: false + container_collection: + enabled: false + +process_config: + <<: *disabled +"#; + let root = load_yaml(yaml).unwrap(); + assert_eq!( + dotted(&root, "process_config.process_collection.enabled"), + Some(&Value::Bool(false)) + ); + assert_eq!( + dotted(&root, "process_config.container_collection.enabled"), + Some(&Value::Bool(false)) + ); + } +} diff --git a/pkg/procmgr/rust/src/config_gate/yaml_load/permissive.rs b/pkg/procmgr/rust/src/config_gate/yaml_load/permissive.rs new file mode 100644 index 000000000000..7aa9520a351e --- /dev/null +++ b/pkg/procmgr/rust/src/config_gate/yaml_load/permissive.rs @@ -0,0 +1,47 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +//! Permissive [`serde_yaml`] fallback: duplicate mapping keys last-win via [`HashMap`]. + +use std::collections::HashMap; + +use anyhow::Result; +use serde::Deserialize; +use serde_yaml::{Mapping, Number, Sequence, Value}; + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum PermissiveValue { + Null, + Bool(bool), + Number(Number), + String(String), + Sequence(Vec), + Mapping(HashMap), +} + +pub(super) fn load(contents: &str) -> Result { + let root: PermissiveValue = serde_yaml::from_str(contents)?; + Ok(to_value(root)) +} + +fn to_value(value: PermissiveValue) -> Value { + match value { + PermissiveValue::Null => Value::Null, + PermissiveValue::Bool(enabled) => Value::Bool(enabled), + PermissiveValue::Number(number) => Value::Number(number), + PermissiveValue::String(text) => Value::String(text), + PermissiveValue::Sequence(items) => { + Value::Sequence(Sequence::from_iter(items.into_iter().map(to_value))) + } + PermissiveValue::Mapping(items) => { + let mut map = Mapping::new(); + for (key, item) in items { + map.insert(Value::String(key), to_value(item)); + } + Value::Mapping(map) + } + } +} diff --git a/pkg/procmgr/rust/src/config_gate/yaml_load/saphyr.rs b/pkg/procmgr/rust/src/config_gate/yaml_load/saphyr.rs new file mode 100644 index 000000000000..e5c4ffaaecf4 --- /dev/null +++ b/pkg/procmgr/rust/src/config_gate/yaml_load/saphyr.rs @@ -0,0 +1,205 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +//! Event-driven YAML loader with Go yaml.v2 plain-scalar bool coercion. + +use std::collections::HashMap; + +use anyhow::{Context, Result, bail}; +use saphyr_parser::{Event, Parser, ScalarStyle}; +use serde_yaml::{Mapping, Sequence, Value}; + +pub(super) fn load(contents: &str) -> Result { + let parser = Parser::new_from_str(contents); + let mut builder = Builder::default(); + for result in parser { + let (event, _) = result.context("parse YAML event")?; + builder.push(event)?; + } + builder.finish() +} + +fn scalar_to_value(text: &str, style: ScalarStyle) -> Value { + if style == ScalarStyle::Plain { + plain_scalar_to_value(text) + } else { + Value::String(text.to_owned()) + } +} + +/// Plain-scalar coercion aligned with Go yaml.v2 (YAML 1.1 bool/null spellings). +fn plain_scalar_to_value(text: &str) -> Value { + match text.to_ascii_lowercase().as_str() { + "" | "~" | "null" => Value::Null, + "true" | "yes" | "on" | "y" => Value::Bool(true), + "false" | "no" | "off" | "n" => Value::Bool(false), + _ => text + .parse::() + .map(|n| Value::Number(n.into())) + .unwrap_or_else(|_| Value::String(text.to_owned())), + } +} + +fn mapping_from_pairs(pairs: HashMap) -> Value { + let mut map = Mapping::new(); + for (key, value) in pairs { + map.insert(Value::String(key), value); + } + Value::Mapping(map) +} + +fn scalar_as_key(value: Value) -> String { + match value { + Value::String(text) => text, + Value::Bool(enabled) => enabled.to_string(), + Value::Number(number) => number.to_string(), + Value::Null => "null".to_owned(), + _ => String::new(), + } +} + +#[derive(Default)] +struct Builder { + stack: Vec, + anchors: HashMap, + root: Option, +} + +enum Frame { + Mapping { + pairs: HashMap, + pending_key: Option, + anchor: usize, + }, + Sequence { + items: Vec, + anchor: usize, + }, +} + +impl Builder { + fn push(&mut self, event: Event<'_>) -> Result<()> { + match event { + Event::Nothing | Event::StreamStart | Event::StreamEnd | Event::DocumentEnd => {} + Event::DocumentStart(_) => { + self.root = None; + self.stack.clear(); + self.anchors.clear(); + } + Event::MappingStart(anchor, _) => self.stack.push(Frame::Mapping { + pairs: HashMap::new(), + pending_key: None, + anchor, + }), + Event::MappingEnd => self.finish_mapping()?, + Event::SequenceStart(anchor, _) => self.stack.push(Frame::Sequence { + items: Vec::new(), + anchor, + }), + Event::SequenceEnd => self.finish_sequence()?, + Event::Scalar(text, style, anchor, _) => { + let value = scalar_to_value(&text, style); + self.store_anchor(anchor, &value); + self.attach(value); + } + Event::Alias(anchor) => { + let value = self + .anchors + .get(&anchor) + .with_context(|| format!("unknown YAML alias anchor {anchor}"))? + .clone(); + self.attach(value); + } + } + Ok(()) + } + + fn finish_mapping(&mut self) -> Result<()> { + let frame = self.stack.pop().context("unexpected YAML mapping end")?; + let (pairs, anchor) = match frame { + Frame::Mapping { + pairs, + pending_key: None, + anchor, + } => (pairs, anchor), + Frame::Mapping { + pending_key: Some(_), + .. + } => { + bail!("YAML mapping ended before value for key"); + } + _ => bail!("YAML mapping end without matching start"), + }; + self.attach_anchored(mapping_from_pairs(pairs), anchor); + Ok(()) + } + + fn finish_sequence(&mut self) -> Result<()> { + let frame = self.stack.pop().context("unexpected YAML sequence end")?; + let (items, anchor) = match frame { + Frame::Sequence { items, anchor } => (items, anchor), + _ => bail!("YAML sequence end without matching start"), + }; + self.attach_anchored(Value::Sequence(Sequence::from(items)), anchor); + Ok(()) + } + + fn store_anchor(&mut self, anchor: usize, value: &Value) { + if anchor != 0 { + self.anchors.insert(anchor, value.clone()); + } + } + + fn attach_anchored(&mut self, value: Value, anchor: usize) { + self.store_anchor(anchor, &value); + self.attach(value); + } + + fn attach(&mut self, value: Value) { + match self.stack.last_mut() { + None => self.root = Some(value), + Some(Frame::Mapping { + pairs, pending_key, .. + }) => { + if pending_key.is_none() { + *pending_key = Some(scalar_as_key(value)); + } else { + pairs.insert(pending_key.take().expect("mapping key"), value); + } + } + Some(Frame::Sequence { items, .. }) => items.push(value), + } + } + + fn finish(self) -> Result { + if !self.stack.is_empty() { + bail!("incomplete YAML document"); + } + self.root.context("empty YAML document") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plain_yaml_11_bool_coerced_at_load() { + let root = load("enabled: yes\n").unwrap(); + assert_eq!(root.get("enabled"), Some(&Value::Bool(true))); + } + + #[test] + fn quoted_yaml_11_bool_stays_string() { + let root = load("enabled: \"yes\"\n").unwrap(); + assert_eq!(root.get("enabled"), Some(&Value::String("yes".into()))); + } + + #[test] + fn single_quoted_yaml_11_bool_stays_string() { + let root = load("enabled: 'on'\n").unwrap(); + assert_eq!(root.get("enabled"), Some(&Value::String("on".into()))); + } +} diff --git a/pkg/procmgr/rust/src/grpc/service.rs b/pkg/procmgr/rust/src/grpc/service.rs index d1c757bf453c..b7e02af4fb4d 100644 --- a/pkg/procmgr/rust/src/grpc/service.rs +++ b/pkg/procmgr/rust/src/grpc/service.rs @@ -31,6 +31,10 @@ impl ProcessManagerService { cmd_tx, } } + + async fn ensure_idle(&self) -> Result<(), Status> { + self.mgr.ensure_idle().await + } } #[tonic::async_trait] @@ -39,6 +43,7 @@ impl proto::process_manager_server::ProcessManager for ProcessManagerService { &self, _request: Request, ) -> Result, Status> { + self.ensure_idle().await?; let procs = self.mgr.processes().await; let processes = procs.iter().map(process_to_proto).collect(); Ok(Response::new(proto::ListResponse { processes })) @@ -48,6 +53,7 @@ impl proto::process_manager_server::ProcessManager for ProcessManagerService { &self, request: Request, ) -> Result, Status> { + self.ensure_idle().await?; let name_or_uuid = request.into_inner().name_or_uuid; let (mut detail, pid) = { let procs = self.mgr.processes().await; @@ -67,6 +73,7 @@ impl proto::process_manager_server::ProcessManager for ProcessManagerService { &self, _request: Request, ) -> Result, Status> { + self.ensure_idle().await?; let procs = self.mgr.processes().await; let total = procs.len() as u32; let (mut created, mut starting, mut running, mut stopping) = (0u32, 0, 0, 0); @@ -103,6 +110,7 @@ impl proto::process_manager_server::ProcessManager for ProcessManagerService { request: Request, ) -> Result, Status> { require_privileged_pipe_client(&request)?; + self.ensure_idle().await?; let req = request.into_inner(); let config = create_request_to_config(&req)?; let (reply_tx, reply_rx) = oneshot::channel(); @@ -129,6 +137,7 @@ impl proto::process_manager_server::ProcessManager for ProcessManagerService { &self, request: Request, ) -> Result, Status> { + self.ensure_idle().await?; let name_or_uuid = request.into_inner().name_or_uuid; let (reply_tx, reply_rx) = oneshot::channel(); self.cmd_tx @@ -154,6 +163,7 @@ impl proto::process_manager_server::ProcessManager for ProcessManagerService { &self, request: Request, ) -> Result, Status> { + self.ensure_idle().await?; let name_or_uuid = request.into_inner().name_or_uuid; let (reply_tx, reply_rx) = oneshot::channel(); self.cmd_tx @@ -198,6 +208,7 @@ impl proto::process_manager_server::ProcessManager for ProcessManagerService { &self, _request: Request, ) -> Result, Status> { + self.ensure_idle().await?; let procs = self.mgr.processes().await; let runtime = procs .iter() diff --git a/pkg/procmgr/rust/src/lib.rs b/pkg/procmgr/rust/src/lib.rs index deac7f6a7e48..3db025364f04 100644 --- a/pkg/procmgr/rust/src/lib.rs +++ b/pkg/procmgr/rust/src/lib.rs @@ -5,10 +5,12 @@ pub mod command; pub mod config; +mod config_gate; pub mod env; pub mod grpc; pub mod handle; pub mod manager; +mod operation; pub mod ordering; pub mod platform; pub mod process; diff --git a/pkg/procmgr/rust/src/manager/mod.rs b/pkg/procmgr/rust/src/manager/mod.rs index 92124c92c725..02bda3cf86f4 100644 --- a/pkg/procmgr/rust/src/manager/mod.rs +++ b/pkg/procmgr/rust/src/manager/mod.rs @@ -122,14 +122,15 @@ fn spawn_watcher(proc: &mut ManagedProcess, tx: mpsc::Sender) { #[cfg(test)] mod tests { - use super::*; use crate::config::{ ConfigLoader, MutableConfigLoader, ProcessConfig, ProcessDefinition, RestartPolicy, StaticConfigLoader, }; + use crate::config_gate::ConditionConfigFile; use crate::state::ProcessState; use crate::test_helpers; use crate::uuid_gen::{SequentialUuidGenerator, UuidGenerator, V4UuidGenerator}; + use std::io::Write; use std::sync::Arc; fn loader(defs: Vec) -> Arc { @@ -171,6 +172,50 @@ mod tests { } } + fn gated_sleep_def(name: &str, agent_yaml: &str) -> ProcessDefinition { + let (cmd, args) = test_helpers::sleep_cmd(60); + ProcessDefinition { + name: name.to_string(), + config: ProcessConfig { + command: cmd.to_string(), + args, + condition_config_any: vec![ConditionConfigFile { + path: agent_yaml.to_string(), + keys: vec!["process_config.process_collection.enabled".into()], + }], + ..Default::default() + }, + } + } + + fn write_agent_yaml(dir: &std::path::Path, process_collection_enabled: bool) -> String { + let path = dir.join("datadog.yaml"); + let body = format!( + "process_config:\n process_collection:\n enabled: {process_collection_enabled}\n container_collection:\n enabled: false\n process_discovery:\n enabled: false\n" + ); + let mut file = std::fs::File::create(&path).unwrap(); + file.write_all(body.as_bytes()).unwrap(); + path.to_string_lossy().into_owned() + } + + fn gated_on_failure_sleep_def(name: &str, agent_yaml: &str) -> ProcessDefinition { + let (cmd, args) = test_helpers::sleep_cmd(60); + ProcessDefinition { + name: name.to_string(), + config: ProcessConfig { + command: cmd.to_string(), + args, + restart: RestartPolicy::OnFailure, + restart_sec: Some(2.0), + condition_config_any: vec![ConditionConfigFile { + path: agent_yaml.to_string(), + keys: vec!["process_config.process_collection.enabled".into()], + }], + ..Default::default() + }, + } + } + #[tokio::test] async fn test_spawn_failure_schedules_on_failure_restart() -> anyhow::Result<()> { let mgr = ProcessManager::new( @@ -481,6 +526,38 @@ mod tests { Ok(()) } + #[tokio::test] + async fn test_complete_restart_skips_when_gate_closed() -> anyhow::Result<()> { + let dir = tempfile::tempdir()?; + let agent_yaml = write_agent_yaml(dir.path(), true); + let config_loader = Arc::new(MutableConfigLoader::new(vec![gated_on_failure_sleep_def( + "svc-a", + &agent_yaml, + )])); + let mgr = ProcessManager::new(config_loader.clone(), uuid_gen()); + let (handles, _exit_rx, _restart_rx) = test_runtime_handles(); + + mgr.handle_start("svc-a", &handles).await?; + mgr.handle_stop("svc-a").await?; + assert!(!mgr.processes().await[0].is_running()); + + // Seed gate state, then close the gate while a queued restart is pending. + mgr.handle_reload_config(&handles).await?; + write_agent_yaml(dir.path(), false); + mgr.handle_reload_config(&handles).await?; + + let pending = { + let procs = mgr.processes().await; + current_pending_restart(&procs[0]) + }; + mgr.complete_restart(pending, &handles).await; + assert!( + !mgr.processes().await[0].is_running(), + "queued restart should not start process when config gates closed during delay" + ); + Ok(()) + } + #[tokio::test] async fn test_complete_restart_skips_already_running() -> anyhow::Result<()> { let mgr = ProcessManager::new(loader(vec![sleep_def("svc")]), uuid_gen()); @@ -932,6 +1009,96 @@ mod tests { Ok(()) } + #[tokio::test] + async fn test_reload_stops_unchanged_process_when_gate_closes() -> anyhow::Result<()> { + let dir = tempfile::tempdir()?; + let agent_yaml = write_agent_yaml(dir.path(), true); + let config_loader = Arc::new(MutableConfigLoader::new(vec![gated_sleep_def( + "svc-a", + &agent_yaml, + )])); + let mgr = ProcessManager::new(config_loader.clone(), uuid_gen()); + let (handles, _exit_rx, _restart_rx) = test_runtime_handles(); + + mgr.handle_start("svc-a", &handles).await?; + assert!(mgr.processes().await[0].is_running()); + + // Seed last_config_gate_met while the gate is still open. + mgr.handle_reload_config(&handles).await?; + + write_agent_yaml(dir.path(), false); + let result = mgr.handle_reload_config(&handles).await?; + assert!(result.unchanged.contains(&"svc-a".to_string())); + + let procs = mgr.processes().await; + assert!( + !procs[0].is_running(), + "running process should stop when external gate YAML disables it" + ); + Ok(()) + } + + #[tokio::test] + async fn test_reload_starts_unchanged_process_when_gate_opens() -> anyhow::Result<()> { + let dir = tempfile::tempdir()?; + let agent_yaml = write_agent_yaml(dir.path(), false); + let config_loader = Arc::new(MutableConfigLoader::new(vec![gated_sleep_def( + "svc-a", + &agent_yaml, + )])); + let mgr = ProcessManager::new(config_loader.clone(), uuid_gen()); + let (handles, _exit_rx, _restart_rx) = test_runtime_handles(); + + let result = mgr.handle_reload_config(&handles).await?; + assert!(result.unchanged.contains(&"svc-a".to_string())); + assert!(!mgr.processes().await[0].is_running()); + + write_agent_yaml(dir.path(), true); + mgr.handle_reload_config(&handles).await?; + let procs = mgr.processes().await; + assert!( + procs[0].is_running(), + "process should start when external gate YAML enables it" + ); + test_helpers::cleanup_process(procs[0].pid().unwrap()); + Ok(()) + } + + #[tokio::test] + async fn test_reload_gate_open_spawn_failure_schedules_restart() -> anyhow::Result<()> { + let dir = tempfile::tempdir()?; + let agent_yaml = write_agent_yaml(dir.path(), false); + let config_loader = Arc::new(MutableConfigLoader::new(vec![ProcessDefinition { + name: "svc-a".to_string(), + config: ProcessConfig { + command: "/nonexistent/dd-procmgr-reload-spawn-fail".to_string(), + restart: RestartPolicy::OnFailure, + restart_sec: Some(0.05), + condition_config_any: vec![ConditionConfigFile { + path: agent_yaml.clone(), + keys: vec!["process_config.process_collection.enabled".into()], + }], + ..Default::default() + }, + }])); + let mgr = ProcessManager::new(config_loader.clone(), uuid_gen()); + let (handles, _exit_rx, mut restart_rx) = test_runtime_handles(); + + mgr.handle_reload_config(&handles).await?; + assert!(!mgr.processes().await[0].is_running()); + + write_agent_yaml(dir.path(), true); + mgr.handle_reload_config(&handles).await?; + assert!(!mgr.processes().await[0].is_running()); + + let pending = tokio::time::timeout(std::time::Duration::from_secs(1), restart_rx.recv()) + .await + .expect("timed out waiting for restart after reload spawn failure") + .expect("expected pending restart"); + assert_eq!(pending.uuid, mgr.processes().await[0].uuid()); + Ok(()) + } + #[tokio::test] async fn test_create_rejects_empty_name() { let mgr = ProcessManager::new(loader(vec![]), uuid_gen()); diff --git a/pkg/procmgr/rust/src/manager/process_manager.rs b/pkg/procmgr/rust/src/manager/process_manager.rs index e806d1830b3b..fc6f6a6c6def 100644 --- a/pkg/procmgr/rust/src/manager/process_manager.rs +++ b/pkg/procmgr/rust/src/manager/process_manager.rs @@ -4,6 +4,7 @@ use super::{ }; use crate::command::{CreateResult, StartResult, StopResult}; use crate::config::{self, ConfigLoader, ProcessDefinition}; +use crate::operation::{OperationGate, OperationKind}; use crate::ordering; use crate::process::ManagedProcess; use crate::shutdown; @@ -20,6 +21,7 @@ pub struct ProcessManager { pub(in crate::manager) startup_order: Arc>>, pub(in crate::manager) config_loader: Arc, pub(in crate::manager) uuid_gen: Arc, + pub(in crate::manager) operation_gate: OperationGate, } impl ProcessManager { @@ -38,9 +40,20 @@ impl ProcessManager { startup_order: Arc::new(RwLock::new(startup_result.order)), config_loader, uuid_gen, + operation_gate: OperationGate::default(), } } + pub(crate) async fn ensure_idle(&self) -> Result<(), Status> { + self.operation_gate.ensure_idle().await + } + + pub(in crate::manager) async fn force_begin_shutdown(&self) { + self.operation_gate + .force_begin(OperationKind::Shutdown) + .await; + } + /// Wrap this manager in a [`Supervisor`] for daemon execution. pub fn supervisor(self) -> Supervisor { Supervisor::new(self) diff --git a/pkg/procmgr/rust/src/manager/reload.rs b/pkg/procmgr/rust/src/manager/reload.rs index 336cab803fad..764a25ce1858 100644 --- a/pkg/procmgr/rust/src/manager/reload.rs +++ b/pkg/procmgr/rust/src/manager/reload.rs @@ -2,6 +2,7 @@ use super::supervisor::RuntimeHandles; use super::{ProcessManager, queue_restart, try_spawn_and_watch}; use crate::command::ReloadResult; use crate::config::ProcessDefinition; +use crate::operation::OperationKind; use crate::process::{ManagedProcess, ProcessOrigin}; use crate::state::ProcessState; use log::{info, warn}; @@ -122,6 +123,20 @@ impl ProcessManager { pub(crate) async fn handle_reload_config( &self, handles: &RuntimeHandles, + ) -> Result { + self.operation_gate + .try_begin(OperationKind::ReloadConfig) + .await?; + let result = self.handle_reload_config_impl(handles).await; + self.operation_gate + .end(OperationKind::ReloadConfig) + .await; + result + } + + async fn handle_reload_config_impl( + &self, + handles: &RuntimeHandles, ) -> Result { let new_configs = self.config_loader.load(); diff --git a/pkg/procmgr/rust/src/manager/supervisor.rs b/pkg/procmgr/rust/src/manager/supervisor.rs index 4f3b0d7b4d03..6744fa9e413f 100644 --- a/pkg/procmgr/rust/src/manager/supervisor.rs +++ b/pkg/procmgr/rust/src/manager/supervisor.rs @@ -118,7 +118,7 @@ impl Supervisor { let shutdown = platform::shutdown_signal(); tokio::pin!(shutdown); - run_manager_event_loop( + let shutdown_requested = run_manager_event_loop( &manager, &handles, &mut cmd_rx, @@ -128,6 +128,10 @@ impl Supervisor { ) .await; + if shutdown_requested { + manager.force_begin_shutdown().await; + } + info!("dd-procmgrd shutting down"); let _ = grpc_shutdown_tx.send(()); diff --git a/pkg/procmgr/rust/src/operation.rs b/pkg/procmgr/rust/src/operation.rs new file mode 100644 index 000000000000..e7a50353fd3a --- /dev/null +++ b/pkg/procmgr/rust/src/operation.rs @@ -0,0 +1,82 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +use std::sync::Arc; + +use tokio::sync::Mutex; +use tonic::Status; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum OperationKind { + ReloadConfig, + Shutdown, +} + +#[derive(Clone, Default)] +pub(crate) struct OperationGate { + active: Arc>>, +} + +impl OperationGate { + pub async fn try_begin(&self, op: OperationKind) -> Result<(), Status> { + let mut guard = self.active.lock().await; + if let Some(active) = *guard { + return Err(Status::failed_precondition(format!( + "operation in progress ({active:?}); try again later" + ))); + } + *guard = Some(op); + Ok(()) + } + + pub async fn force_begin(&self, op: OperationKind) { + *self.active.lock().await = Some(op); + } + + pub async fn end(&self, op: OperationKind) { + let mut guard = self.active.lock().await; + if *guard == Some(op) { + *guard = None; + } + } + + pub async fn ensure_idle(&self) -> Result<(), Status> { + let guard = self.active.lock().await; + if let Some(active) = *guard { + return Err(Status::failed_precondition(format!( + "operation in progress ({active:?}); try again later" + ))); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_operation_gate_blocks_while_active() { + let gate = OperationGate::default(); + gate.try_begin(OperationKind::ReloadConfig) + .await + .expect("first begin"); + assert!(gate.ensure_idle().await.is_err()); + assert!(gate.try_begin(OperationKind::ReloadConfig).await.is_err()); + gate.end(OperationKind::ReloadConfig).await; + gate.ensure_idle().await.expect("idle after end"); + } + + #[tokio::test] + async fn test_force_begin_shutdown_replaces_active_operation() { + let gate = OperationGate::default(); + gate.try_begin(OperationKind::ReloadConfig) + .await + .expect("reload begin"); + gate.force_begin(OperationKind::Shutdown).await; + assert!(gate.ensure_idle().await.is_err()); + assert!(gate.try_begin(OperationKind::ReloadConfig).await.is_err()); + } +} diff --git a/pkg/procmgr/rust/src/platform/unix/spawn/managed.rs b/pkg/procmgr/rust/src/platform/unix/spawn/managed.rs index 6d2f20b0472e..b14b3100ffd2 100644 --- a/pkg/procmgr/rust/src/platform/unix/spawn/managed.rs +++ b/pkg/procmgr/rust/src/platform/unix/spawn/managed.rs @@ -18,8 +18,6 @@ pub(crate) fn spawn_child_handle(process: &mut ManagedProcess) -> Result bool { matches!(self, AgentAccount::LocalSystem) } - - /// Operator-facing account name for list/describe output. - pub(crate) fn display_name(&self) -> String { - self.account_name().display() - } - - fn account_name(&self) -> AccountName { - match self { - AgentAccount::LocalSystem => AccountName::new(NT_AUTHORITY, "SYSTEM"), - AgentAccount::LocalService => AccountName::new(NT_AUTHORITY, "LocalService"), - AgentAccount::NetworkService => AccountName::new(NT_AUTHORITY, "NetworkService"), - AgentAccount::PasswordLogon { domain, user, .. } - | AgentAccount::ServiceAccountLogon { domain, user } => { - account_name_for_logon(domain, user) - } - } - } -} - -/// Match registry-style local SAM display (`.\user`) when installer stored the computer name as domain. -fn account_name_for_logon(domain: &str, user: &str) -> AccountName { - let display_domain = match lookup_account_sid(domain, user) - .ok() - .and_then(|sid| is_local_account(&sid).ok()) - { - Some(true) => String::new(), - _ => domain.to_string(), - }; - AccountName::new(display_domain, user) -} - -/// Resolve the spawn account display string for a profile on Windows. -pub(crate) fn spawn_user_for_profile( - process_name: &str, - profile: crate::spawn::SpawnProfile, -) -> Result { - match profile { - crate::spawn::SpawnProfile::Privileged => { - Ok(AccountName::new(NT_AUTHORITY, "SYSTEM").display()) - } - crate::spawn::SpawnProfile::Agent => resolve_agent_account() - .with_context(|| { - format!("[{process_name}] resolve agent service account for spawn user") - }) - .map(|account| account.display_name()), - } } pub(crate) fn resolve_agent_account() -> Result { @@ -201,14 +154,6 @@ fn well_known_from_sid(sid: &[u8]) -> Option { } } -/// Canonical operator-facing name for built-in service SIDs. -/// -/// `LookupAccountSidW` spells LocalService and NetworkService with spaces; installer -/// state and spawn display use the compact forms instead. -pub(crate) fn canonical_account_name_for_well_known_sid(sid: &[u8]) -> Option { - well_known_from_sid(sid).map(|account| account.account_name()) -} - fn is_local_system_name(domain: &str, user: &str) -> bool { (domain.is_empty() && user.eq_ignore_ascii_case("LocalSystem")) || (domain.eq_ignore_ascii_case("NT AUTHORITY") && user.eq_ignore_ascii_case("SYSTEM")) @@ -310,7 +255,6 @@ impl Drop for PolicyHandle { #[cfg(test)] mod tests { - use super::super::account_name::AccountName; use super::*; #[test] @@ -386,60 +330,6 @@ mod tests { ); } - #[test] - fn display_name_formats_accounts() { - assert_eq!( - AgentAccount::LocalSystem.display_name(), - AccountName::new(NT_AUTHORITY, "SYSTEM").display(), - ); - assert_eq!( - AgentAccount::PasswordLogon { - domain: String::new(), - user: "ddagentuser".to_string(), - password: "secret".to_string(), - } - .display_name(), - AccountName::new("", "ddagentuser").display(), - ); - assert_eq!( - AgentAccount::ServiceAccountLogon { - domain: "CORP".to_string(), - user: "gmsa$".to_string(), - } - .display_name(), - AccountName::new("CORP", "gmsa$").display(), - ); - } - - #[test] - fn display_name_normalizes_local_machine_domain() { - let username = "Administrator"; - let sid = - match lookup_account_sid(".", username).or_else(|_| lookup_account_sid("", username)) { - Ok(sid) => sid, - Err(e) => { - eprintln!("skipping: built-in Administrator not available: {e:#}"); - return; - } - }; - if !is_local_account(&sid).unwrap_or(false) { - eprintln!("skipping: Administrator is not a local SAM account on this host"); - return; - } - - let computer = super::super::local_account::computer_name().expect("computer name"); - assert_eq!( - AgentAccount::PasswordLogon { - domain: computer, - user: username.to_string(), - password: "secret".to_string(), - } - .display_name(), - AccountName::new("", username).display(), - "installer machine-name domain should display as .\\user for local SAM accounts" - ); - } - #[test] fn passwordless_domain_account_requires_password() { let err = passwordless_agent_account( diff --git a/pkg/procmgr/rust/src/platform/windows/legacy_scm_env.rs b/pkg/procmgr/rust/src/platform/windows/legacy_scm_env.rs index 114bad987187..b6f0ce7d6753 100644 --- a/pkg/procmgr/rust/src/platform/windows/legacy_scm_env.rs +++ b/pkg/procmgr/rust/src/platform/windows/legacy_scm_env.rs @@ -4,9 +4,70 @@ // Copyright 2026-present Datadog, Inc. use std::collections::HashMap; +use std::sync::Mutex; use super::merge_env_overrides; +const CORE_AGENT_SERVICE_NAME: &str = "datadogagent"; + +static CORE_AGENT_SCM_ENV: Mutex>> = Mutex::new(None); + +#[cfg(test)] +static TEST_CORE_AGENT_SCM_ENV: std::sync::Mutex>> = + std::sync::Mutex::new(None); + +pub(crate) fn core_agent_scm_env_var(name: &str) -> Option { + #[cfg(test)] + if let Ok(guard) = TEST_CORE_AGENT_SCM_ENV.lock() + && let Some(map) = guard.as_ref() + { + return scm_env_lookup(map, name); + } + + let guard = core_agent_scm_env_map(); + scm_env_lookup(guard.as_ref().expect("initialized scm env"), name) +} + +fn core_agent_scm_env_map() -> std::sync::MutexGuard<'static, Option>> { + let mut guard = CORE_AGENT_SCM_ENV.lock().expect("core agent scm env lock"); + if guard.is_none() { + *guard = Some(load_core_agent_scm_environment()); + } + guard +} + +pub(crate) fn refresh_core_agent_scm_environment() { + let mut guard = CORE_AGENT_SCM_ENV.lock().expect("core agent scm env lock"); + *guard = Some(load_core_agent_scm_environment()); +} + +fn scm_env_lookup(env: &HashMap, name: &str) -> Option { + env.iter() + .find(|(key, _)| key.eq_ignore_ascii_case(name)) + .map(|(_, value)| value.clone()) + .filter(|value| !value.is_empty()) +} + +fn load_core_agent_scm_environment() -> HashMap { + match read_service_environment(CORE_AGENT_SERVICE_NAME) { + Ok(entries) => parse_scm_environment_entries(&entries) + .into_iter() + .collect(), + Err(e) => { + log::warn!("failed to read core Agent SCM Environment for config gates: {e:#}"); + HashMap::new() + } + } +} + +#[cfg(test)] +pub(crate) fn set_test_core_agent_scm_env(env: Option>) { + let mut guard = TEST_CORE_AGENT_SCM_ENV + .lock() + .expect("test core agent scm env lock"); + *guard = env; +} + fn legacy_scm_service_name(process_name: &str) -> Option<&'static str> { match process_name { "datadog-agent-process" => Some("datadog-process-agent"), @@ -21,6 +82,39 @@ const LEGACY_SCM_ENV_DENYLIST: &[&str] = &[ "DD_OTELCOLLECTOR_INSTALLATION_METHOD", ]; +pub(crate) fn merge_core_agent_scm_env(vars: &mut HashMap) { + let overrides = core_agent_scm_env_overrides(); + if overrides.is_empty() { + return; + } + merge_env_overrides(vars, &overrides); +} + +pub(crate) fn build_secret_backend_env_vars( + baseline: HashMap, +) -> HashMap { + let mut vars = baseline; + merge_core_agent_scm_env(&mut vars); + vars +} + +fn core_agent_scm_env_overrides() -> Vec<(String, String)> { + #[cfg(test)] + if let Ok(guard) = TEST_CORE_AGENT_SCM_ENV.lock() + && let Some(map) = guard.as_ref() + { + return map.iter().map(|(k, v)| (k.clone(), v.clone())).collect(); + } + + let guard = core_agent_scm_env_map(); + guard + .as_ref() + .expect("initialized scm env") + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect() +} + pub(crate) fn build_child_env_vars( process_name: &str, baseline: HashMap, @@ -189,6 +283,64 @@ mod tests { assert_eq!(vars.get("BASE").unwrap(), "1"); } + #[test] + fn build_secret_backend_env_vars_merges_core_agent_scm_over_baseline() { + set_test_core_agent_scm_env(Some(HashMap::from([ + ("DD_SECRET_PATH".to_string(), r"C:\secrets".to_string()), + ("path".to_string(), r"C:\agent\bin".to_string()), + ]))); + let baseline = HashMap::from([ + ("BASE".to_string(), "1".to_string()), + ("Path".to_string(), "baseline".to_string()), + ]); + let vars = build_secret_backend_env_vars(baseline); + assert_eq!(vars.get("BASE").unwrap(), "1"); + assert_eq!(vars.get("DD_SECRET_PATH").unwrap(), r"C:\secrets"); + assert_eq!(vars.get("path").unwrap(), r"C:\agent\bin"); + assert_eq!(vars.len(), 3); + set_test_core_agent_scm_env(None); + } + + #[test] + fn core_agent_scm_env_var_uses_case_insensitive_lookup() { + use std::collections::HashMap; + + set_test_core_agent_scm_env(Some(HashMap::from([( + "dd_process_config_enabled".to_string(), + "false".to_string(), + )]))); + assert_eq!( + core_agent_scm_env_var("DD_PROCESS_CONFIG_ENABLED"), + Some("false".to_string()) + ); + set_test_core_agent_scm_env(None); + } + + #[test] + fn refresh_core_agent_scm_environment_replaces_stale_cache() { + set_test_core_agent_scm_env(None); + { + let mut guard = CORE_AGENT_SCM_ENV.lock().expect("core agent scm env lock"); + *guard = Some(HashMap::from([( + "DD_STALE_PROCMGR_SCM_CACHE".to_string(), + "stale".to_string(), + )])); + } + assert_eq!( + core_agent_scm_env_var("DD_STALE_PROCMGR_SCM_CACHE"), + Some("stale".to_string()) + ); + + refresh_core_agent_scm_environment(); + + assert_ne!( + core_agent_scm_env_var("DD_STALE_PROCMGR_SCM_CACHE"), + Some("stale".to_string()), + "reload refresh should replace the cached SCM Environment map" + ); + *CORE_AGENT_SCM_ENV.lock().expect("core agent scm env lock") = None; + } + #[test] fn legacy_scm_service_name_maps_procmgr_managed_processes() { assert_eq!( diff --git a/pkg/procmgr/rust/src/platform/windows/mod.rs b/pkg/procmgr/rust/src/platform/windows/mod.rs index 4584e3d5b81c..1ed2a2997c59 100644 --- a/pkg/procmgr/rust/src/platform/windows/mod.rs +++ b/pkg/procmgr/rust/src/platform/windows/mod.rs @@ -11,6 +11,7 @@ mod local_account; mod managed_service_account; mod pipe_caller; mod pipe_security; +mod resolve_executable; mod runtime_user; mod scm_service; mod sid; @@ -394,6 +395,66 @@ pub async fn shutdown_signal() { } } +// Child process baseline environment (after `env_clear`) + +const FALLBACK_ENV_KEYS: &[&str] = &[ + "SystemRoot", + "WINDIR", + "SystemDrive", + "ProgramData", + "ProgramFiles", + "ProgramFiles(x86)", + "ProgramW6432", + "CommonProgramFiles", + "CommonProgramFiles(x86)", + "CommonProgramW6432", + "PUBLIC", + "TEMP", + "TMP", + "Path", + "PATHEXT", + "LOCALAPPDATA", + "APPDATA", + "USERPROFILE", + "ComSpec", +]; + +pub(crate) fn child_baseline_env_vars() -> HashMap { + use windows_sys::Win32::Security::{TOKEN_DUPLICATE, TOKEN_QUERY}; + use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + + let mut token: HANDLE = std::ptr::null_mut(); + let ok = unsafe { + OpenProcessToken( + GetCurrentProcess(), + TOKEN_QUERY | TOKEN_DUPLICATE, + &mut token, + ) + }; + if ok == 0 { + log::warn!( + "OpenProcessToken(GetCurrentProcess) failed ({}); using process-env fallback", + std::io::Error::last_os_error() + ); + return fallback_process_env_vars(); + } + + let vars = match baseline_env_vars_from_token(token) { + Ok(vars) => vars, + Err(e) => { + log::warn!( + "CreateEnvironmentBlock baseline failed ({e:#}); using process-env fallback" + ); + fallback_process_env_vars() + } + }; + + unsafe { + CloseHandle(token); + } + vars +} + // Spawn token environment (`CreateProcessAsUserW`) pub(crate) fn baseline_env_vars_from_token(token: HANDLE) -> Result> { @@ -476,6 +537,18 @@ fn split_env_entry_wide(wide: &[u16]) -> Option<(std::ffi::OsString, std::ffi::O )) } +fn fallback_process_env_vars() -> HashMap { + let mut vars = HashMap::new(); + for &key in FALLBACK_ENV_KEYS { + if let Ok(val) = std::env::var(key) + && !val.is_empty() + { + vars.insert(key.to_string(), val); + } + } + vars +} + #[cfg(test)] mod env_override_tests { use super::*; diff --git a/pkg/procmgr/rust/src/platform/windows/resolve_executable.rs b/pkg/procmgr/rust/src/platform/windows/resolve_executable.rs new file mode 100644 index 000000000000..01a88a53ffcc --- /dev/null +++ b/pkg/procmgr/rust/src/platform/windows/resolve_executable.rs @@ -0,0 +1,206 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +//! Resolve bare executable names through `PATH`/`PATHEXT` before ACL validation. +//! +//! Go's `exec.Command` calls `LookPath` for bare names before `filesystem.CheckRights`; +//! procmgr must match that so `secret_backend_command` values like `secret-generic-connector.exe` +//! resolve under the Agent service environment rather than failing as nonexistent paths. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use anyhow::{Result, bail}; + +const DEFAULT_PATHEXT: &str = ".COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC"; + +/// Resolve `command` the way `exec.Command` would under `env` (PATH + PATHEXT). +pub(crate) fn resolve_executable_in_env( + command: &str, + env: &HashMap, +) -> Result { + if command.trim().is_empty() { + bail!("secretBackendCommand is empty"); + } + + if contains_path_separator(command) { + if path_is_existing_file(Path::new(command)) { + return Ok(command.to_string()); + } + bail!("secretBackendCommand '{command}' does not exist"); + } + + let path_dirs = path_directories(env); + let extensions = pathext_extensions(env); + + if path_has_extension(command) { + if let Some(resolved) = find_on_path(command, &path_dirs) { + return Ok(resolved); + } + bail!("secretBackendCommand '{command}' does not exist"); + } + + for dir in &path_dirs { + for ext in &extensions { + let candidate_name = format!("{command}{ext}"); + let candidate = dir.join(&candidate_name); + if path_is_existing_file(&candidate) { + return Ok(candidate.to_string_lossy().into_owned()); + } + } + } + + bail!("secretBackendCommand '{command}' does not exist"); +} + +fn path_is_existing_file(path: &Path) -> bool { + if path.is_file() { + return true; + } + #[cfg(windows)] + { + let Some(name) = path.file_name() else { + return false; + }; + let Some(parent) = path.parent() else { + return false; + }; + let Ok(entries) = std::fs::read_dir(parent) else { + return false; + }; + return entries.flatten().any(|entry| { + entry.file_name().eq_ignore_ascii_case(name) + && entry.file_type().is_ok_and(|t| t.is_file()) + }); + } + #[cfg(not(windows))] + false +} + +fn contains_path_separator(command: &str) -> bool { + command.contains('\\') || command.contains('/') +} + +fn path_has_extension(command: &str) -> bool { + Path::new(command).extension().is_some() +} + +fn env_var_case_insensitive<'a>(env: &'a HashMap, key: &str) -> Option<&'a str> { + env.iter() + .find(|(name, _)| name.eq_ignore_ascii_case(key)) + .map(|(_, value)| value.as_str()) + .filter(|value| !value.is_empty()) +} + +fn path_directories(env: &HashMap) -> Vec { + env_var_case_insensitive(env, "PATH") + .map(|path| { + path.split(';') + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .map(PathBuf::from) + .collect() + }) + .unwrap_or_default() +} + +fn pathext_extensions(env: &HashMap) -> Vec { + let raw = env_var_case_insensitive(env, "PATHEXT").unwrap_or(DEFAULT_PATHEXT); + raw.split(';') + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .map(|entry| { + if entry.starts_with('.') { + entry.to_string() + } else { + format!(".{entry}") + } + }) + .collect() +} + +fn find_on_path(command: &str, path_dirs: &[PathBuf]) -> Option { + for dir in path_dirs { + let candidate = dir.join(command); + if path_is_existing_file(&candidate) { + return Some(candidate.to_string_lossy().into_owned()); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::sync::atomic::{AtomicU64, Ordering}; + + static NEXT_ID: AtomicU64 = AtomicU64::new(0); + + fn temp_bin_dir() -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().expect("tempdir"); + let bin = dir + .path() + .join(format!("bin{}", NEXT_ID.fetch_add(1, Ordering::Relaxed))); + fs::create_dir_all(&bin).expect("create bin dir"); + (dir, bin) + } + + #[test] + fn resolve_bare_name_through_path_and_pathext() { + let (_dir, bin) = temp_bin_dir(); + let exe = bin.join("secret-backend.exe"); + fs::write(&exe, b"").expect("write exe"); + + let env = HashMap::from([("Path".to_string(), bin.to_string_lossy().into_owned())]); + let resolved = + resolve_executable_in_env("secret-backend", &env).expect("resolve bare name"); + assert_eq!(resolved, exe.to_string_lossy()); + } + + #[test] + fn resolve_bare_name_with_extension_on_path() { + let (_dir, bin) = temp_bin_dir(); + let exe = bin.join("connector.exe"); + fs::write(&exe, b"").expect("write exe"); + + let env = HashMap::from([("PATH".to_string(), bin.to_string_lossy().into_owned())]); + let resolved = + resolve_executable_in_env("connector.exe", &env).expect("resolve with extension"); + assert_eq!(resolved, exe.to_string_lossy()); + } + + #[test] + fn resolve_explicit_path_without_path_search() { + let dir = tempfile::tempdir().expect("tempdir"); + let exe = dir.path().join("nested.exe"); + fs::write(&exe, b"").expect("write exe"); + + let env = HashMap::new(); + let path = exe.to_string_lossy().into_owned(); + let resolved = resolve_executable_in_env(&path, &env).expect("resolve explicit path"); + assert_eq!(resolved, path); + } + + #[test] + fn resolve_missing_bare_name_errors() { + let env = HashMap::from([("PATH".to_string(), r"C:\missing".to_string())]); + let err = resolve_executable_in_env("nowhere", &env).unwrap_err(); + assert!( + err.to_string().contains("does not exist"), + "unexpected error: {err:#}" + ); + } + + #[test] + fn resolve_missing_explicit_path_errors() { + let env = HashMap::new(); + let err = resolve_executable_in_env(r"C:\missing\backend.exe", &env).unwrap_err(); + assert!( + err.to_string().contains("does not exist"), + "unexpected error: {err:#}" + ); + } +} diff --git a/pkg/procmgr/rust/src/platform/windows/spawn/stdio.rs b/pkg/procmgr/rust/src/platform/windows/spawn/stdio.rs index 41a00400a10b..c156aa4edd1c 100644 --- a/pkg/procmgr/rust/src/platform/windows/spawn/stdio.rs +++ b/pkg/procmgr/rust/src/platform/windows/spawn/stdio.rs @@ -5,6 +5,8 @@ use anyhow::{Result, bail}; use log::warn; +use std::path::Path; +use std::process::Stdio; use std::ptr; use windows_sys::Win32::Foundation::{ CloseHandle, DUPLICATE_SAME_ACCESS, DuplicateHandle, HANDLE, HANDLE_FLAG_INHERIT, @@ -23,6 +25,33 @@ use super::super::agent_credentials::AgentAccount; use super::super::wide; use super::logon::{logon_user_credentials, logon_user_token, with_impersonated_token}; +/// Resolve portable stdio settings for `tokio::process::Command` fallback spawns. +pub(super) fn to_command_stdio(setting: &StdioSetting, inheritable: bool) -> Stdio { + match setting { + StdioSetting::Null => Stdio::null(), + StdioSetting::Inherit if inheritable => Stdio::inherit(), + StdioSetting::Inherit => Stdio::null(), + StdioSetting::File(path) => file_to_stdio(path), + } +} + +fn file_to_stdio(path: &Path) -> Stdio { + match std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + { + Ok(f) => f.into(), + Err(e) => { + warn!( + "failed to open stdio file {}: {e}, falling back to inherit", + path.display() + ); + Stdio::inherit() + } + } +} + pub(super) fn map_stdio_setting( process_name: &str, setting: &StdioSetting, @@ -197,6 +226,7 @@ fn duplicate_inheritable_handle(source: HANDLE) -> Result { #[cfg(test)] mod tests { use super::*; + use crate::test_helpers; use std::path::PathBuf; #[test] @@ -218,4 +248,55 @@ mod tests { .expect("map_stdio_setting should fall back instead of failing spawn"); assert!(!handle.raw().is_null()); } + + fn command_stdio(yaml: &str) -> Stdio { + let setting = match yaml { + "null" => StdioSetting::Null, + "inherit" | "" => StdioSetting::Inherit, + path => StdioSetting::File(path.into()), + }; + to_command_stdio(&setting, crate::platform::stdout_inheritable()) + } + + #[test] + fn null_discards_child_stdout() { + let (sh, flag) = test_helpers::shell_cmd(); + let out = std::process::Command::new(sh) + .arg(flag) + .arg("echo hello") + .stdout(command_stdio("null")) + .output() + .unwrap(); + assert!(out.stdout.is_empty()); + } + + #[test] + fn writable_path_redirect() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("pmgr_stdio_redirect.log"); + let path_str = path.to_str().unwrap(); + let (sh, flag) = test_helpers::shell_cmd(); + let status = std::process::Command::new(sh) + .arg(flag) + .arg("echo fileline") + .stdout(command_stdio(path_str)) + .status() + .unwrap(); + assert!(status.success()); + let contents = std::fs::read_to_string(&path).unwrap(); + assert!(contents.contains("fileline"), "got {contents:?}"); + } + + #[test] + fn unopenable_file_path_falls_back_to_inherit() { + let bad_path = StdioSetting::File(PathBuf::from(r"C:\nonexistent_pmgr_stdio_dir\out.log")); + let handle = map_stdio_setting( + "test-proc", + &bad_path, + STD_OUTPUT_HANDLE, + &AgentAccount::LocalSystem, + ) + .expect("map_stdio_setting should fall back instead of failing spawn"); + assert!(!handle.raw().is_null()); + } } diff --git a/pkg/procmgr/rust/src/platform/windows/spawn/win32.rs b/pkg/procmgr/rust/src/platform/windows/spawn/win32.rs index 303e9eedc8d4..bc9f124de092 100644 --- a/pkg/procmgr/rust/src/platform/windows/spawn/win32.rs +++ b/pkg/procmgr/rust/src/platform/windows/spawn/win32.rs @@ -21,6 +21,19 @@ pub(crate) fn build_windows_command_line(command: &str, args: &[String]) -> Stri cmdline } +pub(crate) fn env_block_from_token(token: HANDLE) -> Result> { + let baseline = super::super::baseline_env_vars_from_token(token) + .context("build process environment from token")?; + Ok(env_vars_to_wide_block(&baseline)) +} + +pub(crate) fn env_block_for_secret_backend(token: HANDLE) -> Result> { + let baseline = super::super::baseline_env_vars_from_token(token) + .context("build secret backend environment from token")?; + let vars = super::super::legacy_scm_env::build_secret_backend_env_vars(baseline); + Ok(env_vars_to_wide_block(&vars)) +} + pub(crate) fn env_block_from_baseline_plus_overrides( process_name: &str, token: HANDLE, diff --git a/pkg/procmgr/rust/src/process.rs b/pkg/procmgr/rust/src/process.rs index fd89fc47e257..78c2c5d55ad3 100644 --- a/pkg/procmgr/rust/src/process.rs +++ b/pkg/procmgr/rust/src/process.rs @@ -137,6 +137,7 @@ pub struct ManagedProcess { restarts: RestartTracker, origin: ProcessOrigin, last_exit_status: Option, + last_config_gate_met: Option, last_start_conditions_met: Option, config_generation: u64, had_successful_run: bool, @@ -174,6 +175,7 @@ impl ManagedProcess { restarts, origin, last_exit_status: None, + last_config_gate_met: None, last_start_conditions_met: None, config_generation: 0, had_successful_run: false, @@ -341,7 +343,16 @@ impl ManagedProcess { self.state = next; } + #[must_use] + pub fn config_gate_met(&self) -> bool { + if self.config.condition_config_any.is_empty() { + return true; + } + crate::config_gate::condition_config_any_met(&self.config.condition_config_any) + } + pub(crate) fn record_config_gate_met(&mut self) { + self.last_config_gate_met = Some(self.config_gate_met()); self.last_start_conditions_met = Some(self.start_conditions_met()); } @@ -377,7 +388,7 @@ impl ManagedProcess { #[must_use] pub(crate) fn start_conditions_met(&self) -> bool { - self.condition_path_exists_met() + self.condition_path_exists_met() && self.config_gate_met() } #[must_use] diff --git a/pkg/system-probe/config/adjust.go b/pkg/system-probe/config/adjust.go index f8c823d9d3b7..b4a7ac27eaaf 100644 --- a/pkg/system-probe/config/adjust.go +++ b/pkg/system-probe/config/adjust.go @@ -34,6 +34,7 @@ func Adjust(cfg model.Config) { !cfg.GetBool(smNS("enabled")) { // This case exists to preserve backwards compatibility. If system_probe_config.enabled is explicitly set to true, and there is no network_config block, // enable the connections/network check. + // Keep this rule in sync with npm_enabled() in pkg/procmgr/rust/src/config_gate/system_probe.rs. log.Warn(deprecationMessage(spNS("enabled"), netNS("enabled"))) // ensure others can key off of this single config value for NPM status cfg.Set(netNS("enabled"), true, model.SourceAgentRuntime) diff --git a/pkg/system-probe/config/config.go b/pkg/system-probe/config/config.go index 20268a25fb1a..0dec989cd4c1 100644 --- a/pkg/system-probe/config/config.go +++ b/pkg/system-probe/config/config.go @@ -239,6 +239,9 @@ func load() (*types.Config, error) { } c.Enabled = len(c.EnabledModules) > 0 + // Keep module enablement above in sync with pkg/procmgr/rust/src/config_gate/system_probe.rs + // (Windows process-manager config gates derive system_probe_config.enabled from these modules). + // Non-default env bindings for those knobs are in pkg/procmgr/rust/src/config_gate/env_bindings.rs. // only allowed raw config adjustments here, otherwise use Adjust function cfg.Set(spNS("enabled"), c.Enabled, pkgconfigmodel.SourceAgentRuntime)