Skip to content

Commit c01c807

Browse files
committed
preserve zero length strings
1 parent 70aa966 commit c01c807

2 files changed

Lines changed: 111 additions & 57 deletions

File tree

bin/agent-data-plane/src/internal/remote_agent.rs

Lines changed: 94 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -310,10 +310,21 @@ async fn run_config_stream_event_loop(
310310
Some(config_event::Event::Snapshot(snapshot)) => {
311311
Some(ConfigUpdate::Snapshot(snapshot_to_settings(&snapshot)))
312312
}
313-
Some(config_event::Event::Update(update)) => update
314-
.setting
315-
.as_ref()
316-
.map(|setting| ConfigUpdate::Partial(setting_to_config_setting(setting))),
313+
Some(config_event::Event::Update(update)) => {
314+
update.setting.as_ref().and_then(|setting| {
315+
match setting_to_config_setting(setting) {
316+
Some(converted) => Some(ConfigUpdate::Partial(converted)),
317+
None => {
318+
// Do not inject JSON null: typed fields interpret it as an invalid value.
319+
debug!(
320+
key = %setting.key,
321+
"Ignoring a configuration update for a declared key with no value."
322+
);
323+
None
324+
}
325+
}
326+
})
327+
}
317328
None => {
318329
error!("Received a configuration update event with no data.");
319330
None
@@ -338,33 +349,35 @@ async fn run_config_stream_event_loop(
338349
}
339350
}
340351

341-
/// Agent source names for values the Agent supplied itself rather than an operator: `default` is a
342-
/// schema default value, and `schema` is a declared key that has no value at all.
343-
const AGENT_UNSET_SOURCES: [&str; 2] = ["default", "schema"];
352+
// Source for a value supplied by the Agent's schema default.
353+
const AGENT_DEFAULT_SOURCE: &str = "default";
344354

345-
/// Converts a setting from the Agent's RPC wire protocol to our `ConfigSetting` type.
346-
///
347-
///
348-
/// An unrecognized `source` is treated as `Provenance::Explicit` because it is safer.
349-
/// `Provenance::Default` values can be overwritten downstream so we want to be sure when labeling a
350-
/// value as such.
351-
fn setting_to_config_setting(setting: &AgentConfigSetting) -> ConfigSetting {
352-
let provenance = if AGENT_UNSET_SOURCES.contains(&setting.source.as_str()) {
355+
// Source for a schema-declared key with no configured value.
356+
const AGENT_DECLARED_ONLY_SOURCE: &str = "schema";
357+
358+
// Omit settings without values; JSON null is not a missing-value sentinel for typed fields.
359+
fn setting_to_config_setting(setting: &AgentConfigSetting) -> Option<ConfigSetting> {
360+
if setting.source == AGENT_DECLARED_ONLY_SOURCE {
361+
return None;
362+
}
363+
364+
let value = proto_value_to_serde_value(&setting.value);
365+
if value.is_null() {
366+
return None;
367+
}
368+
369+
let provenance = if setting.source == AGENT_DEFAULT_SOURCE {
353370
Provenance::Default
354371
} else {
355372
Provenance::Explicit
356373
};
357374

358-
ConfigSetting::new(
359-
setting.key.clone(),
360-
proto_value_to_serde_value(&setting.value),
361-
provenance,
362-
)
375+
Some(ConfigSetting::new(setting.key.clone(), value, provenance))
363376
}
364377

365-
/// Converts a `ConfigSnapshot` into the settings it carries.
378+
// A snapshot must not pass valueless settings to the typed configuration layer.
366379
fn snapshot_to_settings(snapshot: &ConfigSnapshot) -> Vec<ConfigSetting> {
367-
snapshot.settings.iter().map(setting_to_config_setting).collect()
380+
snapshot.settings.iter().filter_map(setting_to_config_setting).collect()
368381
}
369382

370383
/// Recursively converts a `google::protobuf::Value` into a `serde_json::Value`.
@@ -908,32 +921,77 @@ mod tests {
908921
}
909922

910923
#[test]
911-
fn agent_supplied_sources_are_marked_as_defaults() {
912-
for source in AGENT_UNSET_SOURCES {
913-
let setting = setting_to_config_setting(&agent_setting(source, "dd_url", "https://app.datadoghq.com"));
924+
fn an_agent_default_is_marked_as_a_default() {
925+
let setting = setting_to_config_setting(&agent_setting(
926+
AGENT_DEFAULT_SOURCE,
927+
"dd_url",
928+
"https://app.datadoghq.com",
929+
))
930+
.expect("a defaulted setting has a value");
931+
932+
assert_eq!(setting.key, "dd_url");
933+
assert_eq!(setting.value, Value::from("https://app.datadoghq.com"));
934+
assert_eq!(setting.provenance, Provenance::Default);
935+
}
914936

915-
assert_eq!(setting.key, "dd_url");
916-
assert_eq!(setting.value, Value::from("https://app.datadoghq.com"));
917-
assert_eq!(
918-
setting.provenance,
919-
Provenance::Default,
920-
"source {source} should be a default"
937+
#[test]
938+
fn a_declared_key_with_no_value_is_absent() {
939+
// Both an omitted protobuf value and the schema-only source represent an unset key.
940+
assert!(setting_to_config_setting(&AgentConfigSetting {
941+
source: AGENT_DECLARED_ONLY_SOURCE.to_string(),
942+
key: "api_key".to_string(),
943+
value: None,
944+
})
945+
.is_none());
946+
947+
assert!(setting_to_config_setting(&agent_setting(AGENT_DECLARED_ONLY_SOURCE, "api_key", "")).is_none());
948+
}
949+
950+
#[test]
951+
fn a_valueless_setting_is_absent_whatever_its_source() {
952+
for source in [AGENT_DEFAULT_SOURCE, "file", "remote-config"] {
953+
assert!(
954+
setting_to_config_setting(&AgentConfigSetting {
955+
source: source.to_string(),
956+
key: "api_key".to_string(),
957+
value: Some(prost_types::Value {
958+
kind: Some(Kind::NullValue(0)),
959+
}),
960+
})
961+
.is_none(),
962+
"source {source} with a null value should be absent"
921963
);
922964
}
923965
}
924966

967+
#[test]
968+
fn an_empty_string_value_is_kept_with_its_provenance() {
969+
// An empty string is still a value; provenance comes from its source, not its content.
970+
let setting =
971+
setting_to_config_setting(&agent_setting("file", "site", "")).expect("an empty string is still a value");
972+
973+
assert_eq!(setting.value, Value::from(""));
974+
assert_eq!(setting.provenance, Provenance::Explicit);
975+
976+
let setting = setting_to_config_setting(&agent_setting(AGENT_DEFAULT_SOURCE, "site", ""))
977+
.expect("an empty string is still a value");
978+
979+
assert_eq!(setting.value, Value::from(""));
980+
assert_eq!(setting.provenance, Provenance::Default);
981+
}
982+
925983
#[test]
926984
fn operator_supplied_sources_are_marked_as_explicit() {
927-
// The last source is deliberately not one the Agent publishes today: an unrecognized source is
928-
// treated as a real input rather than silently discarded as a default.
985+
// Unknown sources are treated as explicit inputs rather than defaults.
929986
for source in [
930987
"file",
931988
"environment-variable",
932989
"remote-config",
933990
"cli",
934991
"source-from-the-future",
935992
] {
936-
let setting = setting_to_config_setting(&agent_setting(source, "dd_url", "https://app.datadoghq.eu"));
993+
let setting = setting_to_config_setting(&agent_setting(source, "dd_url", "https://app.datadoghq.eu"))
994+
.expect("an explicit setting has a value");
937995

938996
assert_eq!(
939997
setting.provenance,
@@ -944,13 +1002,14 @@ mod tests {
9441002
}
9451003

9461004
#[test]
947-
fn snapshot_settings_keep_their_order_values_and_provenance() {
1005+
fn snapshot_settings_keep_order_values_and_provenance_and_drop_valueless_keys() {
9481006
let snapshot = ConfigSnapshot {
9491007
origin: "core-agent".to_string(),
9501008
sequence_id: 1,
9511009
settings: vec![
9521010
agent_setting("file", "site", "datadoghq.eu"),
9531011
agent_setting("default", "dd_url", "https://app.datadoghq.com"),
1012+
agent_setting(AGENT_DECLARED_ONLY_SOURCE, "api_key", ""),
9541013
],
9551014
};
9561015

lib/agent-data-plane-config-system/src/translators/datadog_translator.rs

Lines changed: 17 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ use agent_data_plane_config::domains::otlp::{
2828
DEFAULT_GRPC_MAX_RECV_MSG_SIZE_MIB,
2929
};
3030
use agent_data_plane_config::shared::ForwarderHttpProtocol;
31-
use agent_data_plane_config::{ConfigValue, Provenance, SalukiConfiguration};
31+
use agent_data_plane_config::{ConfigValue, SalukiConfiguration};
3232
use bytesize::ByteSize;
3333
use datadog_agent_config::{drive, DatadogConfigWitness, DatadogConfiguration, TranslateError, TranslateErrors};
3434

@@ -73,18 +73,6 @@ impl<'a> DatadogTranslator<'a> {
7373
fn record_error(&mut self, error: TranslateError) {
7474
self.errors.push(error);
7575
}
76-
77-
/// Returns whether an input set `key`'s value explicitly, treating an empty value as a default.
78-
///
79-
/// An empty string expresses no intent: it names no site and no URL. Recording it as a default
80-
/// keeps a consumer from having to re-check for emptiness before honoring an override.
81-
fn provenance_if_non_empty(&self, key: &str, value: &str) -> Provenance {
82-
if value.is_empty() {
83-
Provenance::Default
84-
} else {
85-
self.sources.provenance(key)
86-
}
87-
}
8876
}
8977

9078
/// Returns `None` for an empty `s`; otherwise returns `Some(s)`.
@@ -458,11 +446,10 @@ impl DatadogConfigWitness for DatadogTranslator<'_> {
458446
}
459447

460448
fn consume_dd_url(&mut self, value: String) {
461-
// The Core Agent streams this key at its schema default even when the operator configured
462-
// only `site`, so the URL is carried through as-is and provenance records whether it is an
463-
// override anyone set. Programmatic overrides via `EndpointConfiguration::set_dd_url`
464-
// (MRF, cluster-agent forwarder) bypass this translator entirely.
465-
let provenance = self.provenance_if_non_empty("dd_url", &value);
449+
// The Agent may send its default URL even when the operator configured only `site`; retain
450+
// the value and use provenance to decide whether it overrides `site`. Programmatic
451+
// overrides via `EndpointConfiguration::set_dd_url` bypass this translator.
452+
let provenance = self.sources.provenance("dd_url");
466453
self.config.shared.endpoints.dd_url = ConfigValue::new(value, provenance);
467454
}
468455

@@ -1062,7 +1049,7 @@ impl DatadogConfigWitness for DatadogTranslator<'_> {
10621049
}
10631050

10641051
fn consume_site(&mut self, value: String) {
1065-
let provenance = self.provenance_if_non_empty("site", &value);
1052+
let provenance = self.sources.provenance("site");
10661053
self.config.shared.endpoints.site = ConfigValue::new(value, provenance);
10671054
}
10681055

@@ -1368,11 +1355,19 @@ mod tests {
13681355
}
13691356

13701357
#[test]
1371-
fn an_empty_endpoint_value_is_not_explicit() {
1372-
// An empty string names no site and no URL, so it expresses no intent no matter which source
1373-
// supplied it.
1358+
fn an_empty_endpoint_value_keeps_its_provenance() {
1359+
// Empty endpoint strings retain the provenance of the source that supplied them.
13741360
let (config, errors) = translate_explicit(json!({ "site": "", "dd_url": "" }));
13751361

1362+
assert!(errors.is_none());
1363+
assert_explicit(&config.shared.endpoints.site, "");
1364+
assert_explicit(&config.shared.endpoints.dd_url, "");
1365+
1366+
let (config, errors) = translate_stream(&[
1367+
("site", json!(""), StreamProvenance::Default),
1368+
("dd_url", json!(""), StreamProvenance::Default),
1369+
]);
1370+
13761371
assert!(errors.is_none());
13771372
assert_defaulted(&config.shared.endpoints.site, "");
13781373
assert_defaulted(&config.shared.endpoints.dd_url, "");

0 commit comments

Comments
 (0)