diff --git a/grpc-xds/Cargo.toml b/grpc-xds/Cargo.toml index aa77ef186..1420c63ac 100644 --- a/grpc-xds/Cargo.toml +++ b/grpc-xds/Cargo.toml @@ -21,7 +21,7 @@ allowed_external_types = [] protobuf = "4.35.1-release" protobuf-well-known-types = "4.35.1-release" bytes = "1.11.0" -xds-client = { version = "0.1.0-alpha.2", path = "../xds-client", default-features = false } +xds-client = { version = "0.1.0-alpha.3", path = "../xds-client", default-features = false } regex = "1" [build-dependencies] diff --git a/tonic-xds/Cargo.toml b/tonic-xds/Cargo.toml index 348f8ac3b..f96df246c 100644 --- a/tonic-xds/Cargo.toml +++ b/tonic-xds/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tonic-xds" -version = "0.1.0-alpha.2" +version = "0.1.0-alpha.3" edition = "2024" rust-version.workspace = true homepage = "https://github.com/hyperium/tonic" @@ -33,7 +33,7 @@ url = "2.5.8" futures-core = "0.3.31" futures-util = "0.3" bytes = "1" -xds-client = { version = "0.1.0-alpha.2", path = "../xds-client" } +xds-client = { version = "0.1.0-alpha.3", path = "../xds-client" } serde = { version = "1", features = ["derive"] } serde_json = "1" envoy-types = "0.7" @@ -56,13 +56,13 @@ rustls = { version = "0.23", default-features = false, features = ["std", "tls12 rustls-pemfile = { version = "2", optional = true } x509-parser = { version = "0.17", optional = true } opentelemetry = { version = "0.32", optional = true, default-features = false, features = ["metrics"] } -xds-client-opentelemetry = { version = "0.1.0-alpha.2", path = "../xds-client-opentelemetry", optional = true } +xds-client-opentelemetry = { version = "0.1.0-alpha.3", path = "../xds-client-opentelemetry", optional = true } [lints] workspace = true [dev-dependencies] -xds-client = { version = "0.1.0-alpha.2", path = "../xds-client", features = ["test-util"] } +xds-client = { version = "0.1.0-alpha.3", path = "../xds-client", features = ["test-util"] } xds-test-util = { path = "../xds-test-util" } tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "test-util"] } tonic = { version = "0.14", features = [ "server", "channel", "tls-ring" ] } @@ -123,5 +123,6 @@ allowed_external_types = [ "tower::util::boxed_clone_sync::BoxCloneSyncService", "url::parser::ParseError", "serde_core::de::Deserialize", + "serde_core::ser::Serialize", "serde_json::error::Error", ] diff --git a/tonic-xds/src/lib.rs b/tonic-xds/src/lib.rs index 4bdeeee8b..b0aa2ba38 100644 --- a/tonic-xds/src/lib.rs +++ b/tonic-xds/src/lib.rs @@ -54,7 +54,8 @@ //! //! | Method | How | //! |--------|-----| -//! | Programmatic | [`BootstrapConfig::from_json`] then [`XdsChannelConfig::with_bootstrap`] | +//! | Programmatic (builder) | [`BootstrapConfig::builder`] then [`XdsChannelConfig::with_bootstrap`] | +//! | Programmatic (JSON) | [`BootstrapConfig::from_json`] then [`XdsChannelConfig::with_bootstrap`] | //! | Environment (explicit) | [`XdsChannelConfig::with_bootstrap_from_env`] | //! | Environment (implicit) | Omit bootstrap; the builder loads from env vars automatically | //! @@ -105,6 +106,26 @@ //! // let client = MyServiceClient::new(channel); //! ``` //! +//! ### Using the builder +//! +//! ```rust,no_run +//! use tonic_xds::{BootstrapConfig, ChannelCredentialType, XdsChannelBuilder, XdsChannelConfig, XdsUri}; +//! +//! let bootstrap = BootstrapConfig::builder("xds.example.com:443") +//! .channel_creds([ChannelCredentialType::Tls]) +//! .node_id("my-node") +//! .node_cluster("my-cluster") +//! .build() +//! .unwrap(); +//! +//! let target = XdsUri::parse("xds:///myservice:50051").unwrap(); +//! let channel = XdsChannelBuilder::new( +//! XdsChannelConfig::new(target).with_bootstrap(bootstrap), +//! ).build_grpc_channel().unwrap(); +//! +//! // let client = MyServiceClient::new(channel); +//! ``` +//! //! ## TLS Security (gRFC A29) //! //! Upstream data-plane TLS is enabled when: @@ -182,7 +203,9 @@ pub use client::retry::{ pub use client::route::PreRouteInterceptor; pub use common::async_util::BoxFuture; pub use shared_http_body::SharedBody; -pub use xds::bootstrap::{BootstrapConfig, BootstrapError}; +pub use xds::bootstrap::{ + BootstrapConfig, BootstrapConfigBuilder, BootstrapError, ChannelCredentialType, +}; pub use xds::resource::route_config::{RouteConfigMetadata, TypedMetadata}; pub use xds::uri::{XdsUri, XdsUriError}; pub use xds_client::TonicCallCredentials; diff --git a/tonic-xds/src/xds/bootstrap.rs b/tonic-xds/src/xds/bootstrap.rs index 2935c7878..32d79bf6e 100644 --- a/tonic-xds/src/xds/bootstrap.rs +++ b/tonic-xds/src/xds/bootstrap.rs @@ -57,18 +57,52 @@ const ENV_BOOTSTRAP_CONFIG: &str = "GRPC_XDS_BOOTSTRAP_CONFIG"; /// let config = BootstrapConfig::from_json(json).unwrap(); /// ``` /// +/// # Inspecting +/// +/// The fields are private so new bootstrap keys can be added without breaking +/// changes. The accessors below report what a loaded config will act on. +/// +/// ```rust +/// use tonic_xds::BootstrapConfig; +/// +/// let json = r#"{ +/// "xds_servers": [{"server_uri": "xds.example.com:443"}], +/// "node": {"id": "node-1"} +/// }"#; +/// let config = BootstrapConfig::from_json(json).unwrap(); +/// assert_eq!(config.server_uri(), "xds.example.com:443"); +/// assert_eq!(config.node_id(), "node-1"); +/// ``` +/// +/// # Building programmatically +/// +/// [`BootstrapConfig::builder`] constructs a config from typed values, +/// without needing a JSON string. It covers the +/// same bootstrap keys [`from_json`] acts on, per gRFC A27. +/// +/// ```rust +/// use tonic_xds::{BootstrapConfig, ChannelCredentialType}; +/// +/// let config = BootstrapConfig::builder("xds.example.com:443") +/// .channel_creds([ChannelCredentialType::Tls]) +/// .node_id("node-1") +/// .build() +/// .unwrap(); +/// +/// assert_eq!(config.server_uri(), "xds.example.com:443"); +/// assert!(config.use_tls()); +/// ``` +/// +/// [`from_env`]: BootstrapConfig::from_env +/// [`from_json`]: BootstrapConfig::from_json /// [gRFC A27]: https://github.com/grpc/proposal/blob/master/A27-xds-global-load-balancing.md -// TODO: Design a public builder API for constructing BootstrapConfig -// programmatically (not just from JSON). The current `new()` is pub(crate); -// a public API should use the builder pattern to accommodate future fields -// without breaking changes. -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(try_from = "BootstrapConfigDe")] #[non_exhaustive] pub struct BootstrapConfig { /// xDS management servers to connect to. pub(crate) xds_servers: Vec, /// Node identity sent to the xDS server. - #[serde(default)] pub(crate) node: NodeConfig, /// Certificate provider plugin instances, keyed by instance name. /// @@ -77,15 +111,42 @@ pub struct BootstrapConfig { /// See gRFC A29 for details. /// /// [`CertificateProviderPluginInstance`]: https://github.com/envoyproxy/envoy/blob/main/api/envoy/extensions/transport_sockets/tls/v3/common.proto - #[serde(default)] // Consumed by `CertProviderRegistry::from_bootstrap` only under TLS // features; parsed regardless so non-TLS builds accept the same JSON. #[cfg_attr(not(feature = "_tls-any"), allow(dead_code))] pub(crate) certificate_providers: HashMap, } +/// Wire form of [`BootstrapConfig`]. +/// +/// [`BootstrapConfig`] deserializes through this type via `serde(try_from)`, +/// so a config a caller obtains by embedding it in their own config struct +/// goes through [`validate`](BootstrapConfig::validate) too. +#[derive(Deserialize)] +pub(crate) struct BootstrapConfigDe { + xds_servers: Vec, + #[serde(default)] + node: NodeConfig, + #[serde(default)] + certificate_providers: HashMap, +} + +impl TryFrom for BootstrapConfig { + type Error = BootstrapError; + + fn try_from(de: BootstrapConfigDe) -> Result { + let config = Self { + xds_servers: de.xds_servers, + node: de.node, + certificate_providers: de.certificate_providers, + }; + config.validate()?; + Ok(config) + } +} + /// Configuration for a single xDS management server. -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, PartialEq, Deserialize)] pub(crate) struct XdsServerConfig { /// URI of the xDS server (e.g., `"xds.example.com:443"`). pub server_uri: String, @@ -100,24 +161,42 @@ pub(crate) struct XdsServerConfig { } /// A channel credential entry from the bootstrap config. -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, PartialEq, Deserialize)] pub(crate) struct ChannelCredentialConfig { /// Credential type (e.g., `"insecure"`, `"tls"`, `"google_default"`). #[serde(rename = "type")] pub cred_type: ChannelCredentialType, } -/// Channel credential type from the bootstrap config. +/// Channel credential type offered to the xDS management server. +/// +/// The client uses the first type it supports, per [gRFC A27]. This is the +/// bootstrap's `xds_servers[].channel_creds[].type`, and is what +/// [`BootstrapConfigBuilder::channel_creds`] accepts. /// -/// Known types are deserialized into specific variants; unrecognized types -/// are captured as `Unsupported(String)` so they can be skipped gracefully. +/// [gRFC A27]: https://github.com/grpc/proposal/blob/master/A27-xds-global-load-balancing.md #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(rename_all = "snake_case")] -pub(crate) enum ChannelCredentialType { +#[non_exhaustive] +pub enum ChannelCredentialType { + /// Plaintext connection to the xDS server. Insecure, + /// TLS with the platform's default trust roots. Tls, + /// Google default credentials (ALTS or TLS plus call credentials). GoogleDefault, + /// A credential type this client does not implement. + /// + /// Produced when parsing a bootstrap written for a newer or different + /// client; such entries are skipped when selecting a credential, so a + /// bootstrap can list types this version does not know about. + /// + /// `#[non_exhaustive]` reserves the variant for the parser: downstream + /// code can neither construct it nor match its payload. [`Deserialize`] + /// still yields one for an unknown type, so + /// [`BootstrapConfigBuilder::build`] rejects it. #[serde(untagged)] + #[non_exhaustive] Unsupported(String), } @@ -131,7 +210,7 @@ pub(crate) enum ChannelCredentialType { /// fields. See [gRFC A29]. /// /// [gRFC A29]: https://github.com/grpc/proposal/blob/master/A29-xds-tls-security.md -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, PartialEq, Deserialize)] // In non-TLS builds `cert_provider` is gated out, so nothing reads these // fields after serde populates them. #[cfg_attr(not(feature = "_tls-any"), allow(dead_code))] @@ -142,7 +221,7 @@ pub(crate) struct CertProviderPluginConfig { } /// Node identity configuration from bootstrap JSON. -#[derive(Debug, Clone, Default, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Deserialize)] pub(crate) struct NodeConfig { /// Opaque node identifier. #[serde(default)] @@ -184,7 +263,7 @@ fn json_to_metadata(value: serde_json::Value) -> Result, - node: NodeConfig, - ) -> Result { - let config = Self { - xds_servers, - node, - certificate_providers: HashMap::new(), - }; - config.validate()?; - Ok(config) - } - /// Load bootstrap configuration from environment variables. /// /// Checks `GRPC_XDS_BOOTSTRAP` (file path) first, then falls back to @@ -252,11 +330,22 @@ impl BootstrapConfig { /// Parse bootstrap configuration from a JSON string. pub fn from_json(json: &str) -> Result { - let config: BootstrapConfig = serde_json::from_str(json)?; - config.validate()?; - Ok(config) + // The wire form reports validation failures as + // `BootstrapError::Validation`; `Self` reports them as a serde error. + let de: BootstrapConfigDe = serde_json::from_str(json)?; + Self::try_from(de) } + /// Checks the invariants every constructor upholds. + /// + /// Runs on both construction paths — `TryFrom`, which + /// covers [`from_json`], [`from_env`] and `Deserialize`, and + /// [`BootstrapConfigBuilder::build`] — so [`server_uri`] can index + /// `xds_servers` directly. + /// + /// [`from_json`]: BootstrapConfig::from_json + /// [`from_env`]: BootstrapConfig::from_env + /// [`server_uri`]: BootstrapConfig::server_uri fn validate(&self) -> Result<(), BootstrapError> { if self.xds_servers.is_empty() { return Err(BootstrapError::Validation( @@ -273,14 +362,24 @@ impl BootstrapConfig { Ok(()) } - /// Returns the URI of the first xDS server. - pub(crate) fn server_uri(&self) -> &str { + /// Returns the URI of the xDS server this config connects to. + /// + /// Only the first entry is used; further `xds_servers` are parsed but not + /// yet connected to. + pub fn server_uri(&self) -> &str { self.xds_servers .first() .map(|s| s.server_uri.as_str()) .expect("xds_servers validated non-empty") } + /// Returns the node identifier presented to the xDS server. + /// + /// Empty when the bootstrap omits `node.id`. + pub fn node_id(&self) -> &str { + &self.node.id + } + /// Select the first supported channel credential type from the first server's config. /// /// Per gRFC A27, the client stops at the first credential type it supports. @@ -301,13 +400,251 @@ impl BootstrapConfig { }) } - /// Returns `true` if the first server's selected credential is TLS. - pub(crate) fn use_tls(&self) -> bool { + /// Returns `true` if the connection to the xDS server uses TLS. + pub fn use_tls(&self) -> bool { matches!( self.selected_credential(), Some(ChannelCredentialType::Tls | ChannelCredentialType::GoogleDefault) ) } + + /// Starts building a bootstrap config for the given xDS server URI. + /// + /// Use this when the process already holds the configuration, so it can + /// build the config directly. [`from_json`] remains the full-fidelity + /// path and the only one that accepts a gRFC A27 document verbatim. + /// + /// ```rust + /// use tonic_xds::{BootstrapConfig, ChannelCredentialType}; + /// + /// let config = BootstrapConfig::builder("xds.example.com:443") + /// .channel_creds([ChannelCredentialType::Tls]) + /// .node_id("my-node") + /// .build() + /// .unwrap(); + /// + /// assert_eq!(config.server_uri(), "xds.example.com:443"); + /// assert_eq!(config.node_id(), "my-node"); + /// assert!(config.use_tls()); + /// ``` + /// + /// [`from_json`]: BootstrapConfig::from_json + pub fn builder(server_uri: impl Into) -> BootstrapConfigBuilder { + BootstrapConfigBuilder::new(server_uri) + } +} + +/// Builds a [`BootstrapConfig`] without going through JSON. +/// +/// Created by [`BootstrapConfig::builder`]. Every setter is optional except +/// the server URI, which [`BootstrapConfig::builder`] takes up front because +/// the config is invalid without it. +/// +/// The builder covers the bootstrap keys the client acts on. A bootstrap that +/// needs keys outside that set — additional `xds_servers` entries, which are +/// parsed but not yet connected to — must still come from [`from_json`]. +/// +/// ```rust +/// use tonic_xds::{BootstrapConfig, ChannelCredentialType}; +/// +/// let config = BootstrapConfig::builder("xds.example.com:443") +/// .channel_creds([ChannelCredentialType::Tls, ChannelCredentialType::Insecure]) +/// .node_id("projects/123/nodes/456") +/// .node_cluster("my-cluster") +/// .node_locality("us-east1", "us-east1-b", "rack1") +/// .node_metadata("GENERATOR", "grpc") +/// .build() +/// .unwrap(); +/// +/// assert!(config.use_tls()); +/// ``` +/// +/// [`from_json`]: BootstrapConfig::from_json +#[derive(Debug)] +pub struct BootstrapConfigBuilder { + server_uri: String, + channel_creds: Vec, + node_id: String, + node_cluster: Option, + node_locality: Option, + node_metadata: HashMap, + certificate_providers: HashMap, + error: Option, +} + +impl BootstrapConfigBuilder { + fn new(server_uri: impl Into) -> Self { + Self { + server_uri: server_uri.into(), + channel_creds: Vec::new(), + node_id: String::new(), + node_cluster: None, + node_locality: None, + node_metadata: HashMap::new(), + certificate_providers: HashMap::new(), + error: None, + } + } + + /// Sets the credentials offered to the xDS server, in preference order. + /// + /// Replaces any previously set credentials. Unset, the client connects to + /// the management server in plaintext, matching a bootstrap with no + /// `channel_creds`; pass [`ChannelCredentialType::Tls`] for a secured + /// server. + #[must_use] + pub fn channel_creds(mut self, creds: impl IntoIterator) -> Self { + self.channel_creds = creds + .into_iter() + .map(|cred_type| ChannelCredentialConfig { cred_type }) + .collect(); + self + } + + /// Sets the opaque node identifier presented to the xDS server. + #[must_use] + pub fn node_id(mut self, id: impl Into) -> Self { + self.node_id = id.into(); + self + } + + /// Sets the cluster the node belongs to. + #[must_use] + pub fn node_cluster(mut self, cluster: impl Into) -> Self { + self.node_cluster = Some(cluster.into()); + self + } + + /// Sets the locality the node is running in. + #[must_use] + pub fn node_locality( + mut self, + region: impl Into, + zone: impl Into, + sub_zone: impl Into, + ) -> Self { + self.node_locality = Some(LocalityConfig { + region: region.into(), + zone: zone.into(), + sub_zone: sub_zone.into(), + }); + self + } + + /// Adds one free-form node metadata entry, replacing any entry with the + /// same key. + /// + /// Accepts anything [`Serialize`], so a string, a number, or a nested + /// struct all work. Some control planes vary the served config based on + /// metadata — e.g. Istio's istiod gates proxyless gRPC config behind + /// `GENERATOR = "grpc"`. + /// + /// [`build`] reports a value with no JSON form as + /// [`BootstrapError::Serialization`]. + /// + /// [`Serialize`]: serde::Serialize + /// [`build`]: BootstrapConfigBuilder::build + #[must_use] + pub fn node_metadata(mut self, key: impl Into, value: impl serde::Serialize) -> Self { + let key = key.into(); + match serde_json::to_value(value) { + Ok(value) => { + self.node_metadata.insert(key, value); + } + Err(source) => self.record_error("node.metadata", source), + } + self + } + + /// Registers a certificate provider instance, replacing any instance with + /// the same name. + /// + /// `instance_name` is what CDS/LDS resources reference by + /// `CertificateProviderPluginInstance`; `config` is the opaque + /// plugin-specific blob, accepted as anything [`Serialize`]. See [gRFC A29]. + /// + /// [`build`] reports a config with no JSON form as + /// [`BootstrapError::Serialization`]. + /// + /// [`Serialize`]: serde::Serialize + /// [`build`]: BootstrapConfigBuilder::build + /// [gRFC A29]: https://github.com/grpc/proposal/blob/master/A29-xds-tls-security.md + #[must_use] + pub fn certificate_provider( + mut self, + instance_name: impl Into, + plugin_name: impl Into, + config: impl serde::Serialize, + ) -> Self { + let instance_name = instance_name.into(); + match serde_json::to_value(config) { + Ok(config) => { + self.certificate_providers.insert( + instance_name, + CertProviderPluginConfig { + plugin_name: plugin_name.into(), + config, + }, + ); + } + Err(source) => self.record_error("certificate_providers", source), + } + self + } + + /// Keeps the first error so the chain can continue and report at `build`. + fn record_error(&mut self, field: &'static str, source: serde_json::Error) { + if self.error.is_none() { + self.error = Some(BootstrapError::Serialization { field, source }); + } + } + + /// Validates the accumulated settings and returns the config. + /// + /// # Errors + /// + /// - [`BootstrapError::Serialization`] if a metadata or certificate + /// provider value could not be serialized to JSON; the first failing + /// setter wins. + /// - [`BootstrapError::Validation`] if the server URI is empty — the same + /// validation configs parsed from JSON go through — or if the + /// credentials include [`ChannelCredentialType::Unsupported`]. + pub fn build(self) -> Result { + if let Some(error) = self.error { + return Err(error); + } + // Parsing skips `Unsupported` for forward compatibility; building + // treats it as a caller mistake. `Deserialize` is the only source of + // one, since the constructor is sealed. + if let Some(ChannelCredentialType::Unsupported(name)) = self + .channel_creds + .iter() + .map(|c| &c.cred_type) + .find(|t| matches!(t, ChannelCredentialType::Unsupported(_))) + { + return Err(BootstrapError::Validation(format!( + "channel credential type '{name}' is not supported by this client" + ))); + } + let config = BootstrapConfig { + xds_servers: vec![XdsServerConfig { + server_uri: self.server_uri, + channel_creds: self.channel_creds, + // The client acts on no server feature yet, so the builder + // omits a setter. + server_features: Vec::new(), + }], + node: NodeConfig { + id: self.node_id, + cluster: self.node_cluster, + locality: self.node_locality, + metadata: self.node_metadata, + }, + certificate_providers: self.certificate_providers, + }; + config.validate()?; + Ok(config) + } } impl TryFrom for Node { @@ -513,6 +850,227 @@ mod tests { assert!(node.id.is_none()); } + #[test] + fn public_accessors_report_what_the_client_will_use() { + let json = r#"{ + "xds_servers": [ + {"server_uri": "primary:443", "channel_creds": [{"type": "tls"}]}, + {"server_uri": "fallback:443"} + ], + "node": {"id": "node-1"} + }"#; + let config = BootstrapConfig::from_json(json).unwrap(); + + assert_eq!(config.server_uri(), "primary:443"); + assert_eq!(config.node_id(), "node-1"); + assert!(config.use_tls()); + } + + #[test] + fn public_accessors_report_absent_optional_fields() { + let json = r#"{"xds_servers": [{"server_uri": "localhost:5000"}]}"#; + let config = BootstrapConfig::from_json(json).unwrap(); + + assert_eq!(config.node_id(), ""); + assert!(!config.use_tls()); + } + + #[test] + fn equal_configs_compare_equal_regardless_of_json_formatting() { + let compact = r#"{"xds_servers":[{"server_uri":"xds:443"}],"node":{"id":"n1"}}"#; + let spaced = r#"{ + "node": {"id": "n1"}, + "xds_servers": [{"server_uri": "xds:443"}] + }"#; + + assert_eq!( + BootstrapConfig::from_json(compact).unwrap(), + BootstrapConfig::from_json(spaced).unwrap(), + ); + } + + #[test] + fn misplaced_keys_are_dropped_and_compare_unequal() { + let intended = r#"{"xds_servers":[{"server_uri":"xds:443"}],"node":{"id":"n1"}}"#; + let misplaced = r#"{"xds_servers":[{"server_uri":"xds:443"}],"node_id":"n1"}"#; + + let misparsed = BootstrapConfig::from_json(misplaced).unwrap(); + assert_eq!(misparsed.node_id(), ""); + assert_ne!(BootstrapConfig::from_json(intended).unwrap(), misparsed); + } + + #[test] + fn builder_matches_the_equivalent_json() { + let built = BootstrapConfig::builder("xds.example.com:443") + .channel_creds([ + ChannelCredentialType::GoogleDefault, + ChannelCredentialType::Tls, + ChannelCredentialType::Insecure, + ]) + .node_id("projects/123/nodes/456") + .node_cluster("test-cluster") + .node_locality("us-east1", "us-east1-b", "rack1") + .build() + .unwrap(); + + // `full_json` also carries `server_features`; the client ignores it, + // so the builder omits it. + let parsed = BootstrapConfig::from_json(full_json()).unwrap(); + assert_eq!(parsed.xds_servers[0].server_features, vec!["xds_v3"]); + assert!(built.xds_servers[0].server_features.is_empty()); + + let mut parsed_without_features = parsed; + parsed_without_features.xds_servers[0] + .server_features + .clear(); + assert_eq!(parsed_without_features, built); + } + + #[test] + fn builder_defaults_omit_optional_fields() { + let built = BootstrapConfig::builder("xds.example.com:443") + .node_id("test-node") + .build() + .unwrap(); + + assert_eq!(BootstrapConfig::from_json(minimal_json()).unwrap(), built); + assert!(!built.use_tls()); + } + + #[test] + fn builder_carries_metadata_and_cert_providers() { + let built = BootstrapConfig::builder("localhost:5000") + .node_metadata("GENERATOR", "grpc") + .certificate_provider( + "google_cloud_private_spiffe", + "file_watcher", + serde_json::json!({"certificate_file": "/etc/certs/cert.pem"}), + ) + .build() + .unwrap(); + + let equivalent = BootstrapConfig::from_json( + r#"{ + "xds_servers": [{"server_uri": "localhost:5000"}], + "node": {"metadata": {"GENERATOR": "grpc"}}, + "certificate_providers": { + "google_cloud_private_spiffe": { + "plugin_name": "file_watcher", + "config": {"certificate_file": "/etc/certs/cert.pem"} + } + } + }"#, + ) + .unwrap(); + + assert_eq!(equivalent, built); + } + + #[test] + fn builder_runs_the_same_validation_as_json() { + let err = BootstrapConfig::builder("").build().unwrap_err(); + assert!(matches!(err, BootstrapError::Validation(_))); + assert!(err.to_string().contains("server_uri must not be empty")); + } + + #[test] + fn builder_reports_unrepresentable_metadata_at_build() { + // JSON object keys must be strings, so a tuple-keyed map has no JSON + // form and `build` reports it. + let unrepresentable = HashMap::from([((1u8, 2u8), "v")]); + let err = BootstrapConfig::builder("xds:443") + .node_metadata("bad", unrepresentable) + .build() + .unwrap_err(); + + assert!(matches!( + err, + BootstrapError::Serialization { + field: "node.metadata", + .. + } + )); + } + + #[test] + fn builder_reports_only_the_first_serialization_error() { + let unrepresentable = HashMap::from([((1u8, 2u8), "v")]); + let err = BootstrapConfig::builder("xds:443") + .node_metadata("bad", unrepresentable.clone()) + .certificate_provider("bad", "file_watcher", unrepresentable) + .build() + .unwrap_err(); + + assert!(matches!( + err, + BootstrapError::Serialization { + field: "node.metadata", + .. + } + )); + } + + #[test] + fn parsed_unsupported_creds_are_skipped_but_rejected_by_the_builder() { + // Parsing keeps forward compatibility: an unknown type is skipped and + // the next supported one wins. + let parsed = BootstrapConfig::from_json( + r#"{ + "xds_servers": [{ + "server_uri": "xds:443", + "channel_creds": [{"type": "future_creds"}, {"type": "tls"}] + }] + }"#, + ) + .unwrap(); + assert!(parsed.use_tls()); + + // Building states what this client should use, so `build` rejects the + // same value parsing skips. `Deserialize` is the only way to name one. + let err = BootstrapConfig::builder("xds:443") + .channel_creds( + parsed.xds_servers[0] + .channel_creds + .iter() + .map(|c| c.cred_type.clone()), + ) + .build() + .unwrap_err(); + + assert!(matches!(err, BootstrapError::Validation(_))); + assert!(err.to_string().contains("future_creds")); + } + + #[test] + fn deserializing_a_config_directly_runs_validation() { + // `BootstrapConfig` derives `Deserialize` so callers can embed it in + // their own config structs; that path validates too, keeping + // `server_uri` total. + let err = + serde_json::from_str::(r#"{"xds_servers": []}"#).expect_err("empty"); + assert!(err.to_string().contains("xds_servers must not be empty")); + + let ok = serde_json::from_str::( + r#"{"xds_servers": [{"server_uri": "xds:443"}]}"#, + ) + .unwrap(); + assert_eq!(ok.server_uri(), "xds:443"); + } + + #[test] + fn builder_setters_replace_rather_than_accumulate() { + let built = BootstrapConfig::builder("xds:443") + .channel_creds([ChannelCredentialType::Insecure]) + .channel_creds([ChannelCredentialType::Tls]) + .node_metadata("k", "first") + .node_metadata("k", "second") + .build() + .unwrap(); + + assert!(built.use_tls()); + assert_eq!(built.node.metadata["k"], serde_json::json!("second")); + } + #[test] fn parse_node_metadata() { let json = r#"{ diff --git a/xds-client-opentelemetry/Cargo.toml b/xds-client-opentelemetry/Cargo.toml index 2c9a9fec5..a325d5ef5 100644 --- a/xds-client-opentelemetry/Cargo.toml +++ b/xds-client-opentelemetry/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "xds-client-opentelemetry" description = "OpenTelemetry metrics recorder for xds-client (gRFC A78 XdsClient metrics)" -version = "0.1.0-alpha.2" +version = "0.1.0-alpha.3" edition = "2024" homepage = "https://github.com/grpc/grpc-rust" repository = "https://github.com/grpc/grpc-rust" @@ -13,7 +13,7 @@ publish = true workspace = true [dependencies] -xds-client = { version = "0.1.0-alpha.2", path = "../xds-client" } +xds-client = { version = "0.1.0-alpha.3", path = "../xds-client" } opentelemetry = { version = "0.32", default-features = false, features = ["metrics"] } [dev-dependencies] diff --git a/xds-client/Cargo.toml b/xds-client/Cargo.toml index 3a8c7bb00..5f87f99ea 100644 --- a/xds-client/Cargo.toml +++ b/xds-client/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "xds-client" description = "An xDS client implementation in Rust" -version = "0.1.0-alpha.2" +version = "0.1.0-alpha.3" edition = "2024" homepage = "https://github.com/hyperium/tonic" repository = "https://github.com/hyperium/tonic"