From 6ef2172babd536515dba80570d354d277899f48f Mon Sep 17 00:00:00 2001 From: Mikey Lombardi Date: Thu, 8 Jan 2026 09:39:41 -0600 Subject: [PATCH 01/11] (GH-538) Define newtype for fully qualified type name Prior to this change, the `type` field for resource manifests was just a `String` type, without any inherent validation. When schemars generates the schema for this field for any struct that has a fully qualified type name, the generated schema just ensures that the type is `string`. While we could add the validation pattern to each of these fields with the `schemars` derive attribute, we would need to add it to every field. We also can't extract the `definitions/resourceType` schema from the code because it isn't separaely maintained from the structs where it is referenced. This change: 1. Follows the Rust ["parse, don't validate"][01] mantra by using the newtype pattern to create a `FullyQualifiedTypeName` struct that wraps a `String` value. This change implements several traits for the newtype to simplify using it as much as possible, including referencing and dereferencing as a string. 1. Ensures that the newtype defines the canonical JSON schema for a fully qualified type name. This schema can be extracted. 1. Adds the first extended documentation strings for the JSON schemas in a separate file from the `en-us.toml`, to enable writing many multiline strings needed by schemas without cluttering the translations for typical library messages. --- lib/dsc-lib/locales/en-us.toml | 2 + lib/dsc-lib/locales/schemas.definitions.yaml | 35 +++++ lib/dsc-lib/src/dscerror.rs | 3 + lib/dsc-lib/src/lib.rs | 1 + .../src/types/fully_qualified_type_name.rs | 134 ++++++++++++++++++ lib/dsc-lib/src/types/mod.rs | 5 + lib/dsc-lib/tests/integration/main.rs | 1 + .../types/fully_qualified_type_name.rs | 113 +++++++++++++++ lib/dsc-lib/tests/integration/types/mod.rs | 5 + 9 files changed, 299 insertions(+) create mode 100644 lib/dsc-lib/locales/schemas.definitions.yaml create mode 100644 lib/dsc-lib/src/types/fully_qualified_type_name.rs create mode 100644 lib/dsc-lib/src/types/mod.rs create mode 100644 lib/dsc-lib/tests/integration/types/fully_qualified_type_name.rs create mode 100644 lib/dsc-lib/tests/integration/types/mod.rs diff --git a/lib/dsc-lib/locales/en-us.toml b/lib/dsc-lib/locales/en-us.toml index d900998e7..55ffb540d 100644 --- a/lib/dsc-lib/locales/en-us.toml +++ b/lib/dsc-lib/locales/en-us.toml @@ -709,6 +709,8 @@ forExecutable = "for executable" function = "Function" integerConversion = "Function integer argument conversion" invalidConfiguration = "Invalid configuration" +invalidTypeNamePrefix = "Invalid type name" +invalidTypeNameSuffix = "valid resource type names must match the following pattern" unsupportedManifestVersion = "Unsupported manifest version" mustBe = "Must be" invalidFunctionParameterCount = "Invalid function parameter count for" diff --git a/lib/dsc-lib/locales/schemas.definitions.yaml b/lib/dsc-lib/locales/schemas.definitions.yaml new file mode 100644 index 000000000..64f5e4808 --- /dev/null +++ b/lib/dsc-lib/locales/schemas.definitions.yaml @@ -0,0 +1,35 @@ +_version: 2 +schemas: + definitions: + resourceType: + title: Fully qualified type name + description: >- + Uniquely identifies a DSC resource or extension. + markdownDescription: |- + The fully qualified type name of a DSC resource or extension uniquely identifies a resource + or extension. + + Fully qualified type names use the following syntax: + + ```yaml + [....]/ + ``` + + Where the type may have zero to three namespace segments for organizing the type. The + `owner`, `namespace`, and `name` segments must consist only of alphanumeric characters and + underscores. + + Conventionally, the first character of each segment is capitalized. When a segments + contains a brand or proper name, use the correct casing for that word, like + `TailspinToys/Settings`, not `Tailspintoys/Settings`. + + Example fully qualified type names include: + + - `Microsoft/OSInfo` + - `Microsoft.SqlServer/Database` + - `Microsoft.Windows.IIS/WebApp` + patternErrorMessage: >- + Invalid type name. Valid resource type names always define an owner and a name separated by + a slash, like `Microsoft/OSInfo`. Type names may optionally include the group, area, and + subarea segments to namespace the resource under the owner, like + `Microsoft.Windows/Registry`. diff --git a/lib/dsc-lib/src/dscerror.rs b/lib/dsc-lib/src/dscerror.rs index 2e1488c77..4da9b4d13 100644 --- a/lib/dsc-lib/src/dscerror.rs +++ b/lib/dsc-lib/src/dscerror.rs @@ -58,6 +58,9 @@ pub enum DscError { #[error("{t} '{0}': {1}", t = t!("dscerror.invalidRequiredVersion"))] InvalidRequiredVersion(String, String), + #[error("{t} '{0}' - {t2}: '{1}'", t = t!("dscerror.invalidTypeNamePrefix"), t2 = t!("dscerror.InvalidTypeNameSuffix"))] + InvalidTypeName(String, String), + #[error("IO: {0}")] Io(#[from] std::io::Error), diff --git a/lib/dsc-lib/src/lib.rs b/lib/dsc-lib/src/lib.rs index 32b0cd320..d4be1d74f 100644 --- a/lib/dsc-lib/src/lib.rs +++ b/lib/dsc-lib/src/lib.rs @@ -18,6 +18,7 @@ pub mod extensions; pub mod functions; pub mod parser; pub mod progress; +pub mod types; pub mod util; // Re-export the dependency crate to minimize dependency management. diff --git a/lib/dsc-lib/src/types/fully_qualified_type_name.rs b/lib/dsc-lib/src/types/fully_qualified_type_name.rs new file mode 100644 index 000000000..ac049ed49 --- /dev/null +++ b/lib/dsc-lib/src/types/fully_qualified_type_name.rs @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::fmt::{Display, Formatter}; +use std::ops::Deref; +use std::str::FromStr; +use std::sync::OnceLock; + +use regex::Regex; +use rust_i18n::t; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::dscerror::DscError; +use crate::schemas::dsc_repo::DscRepoSchema; + +/// Defines the fully qualified type name for a DSC resource or extension. The fully qualified name +/// uniquely identifies each resource and extension. +#[derive( + Clone, + Debug, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Serialize, + Deserialize, + JsonSchema, + DscRepoSchema, +)] +#[serde(try_from = "String")] +#[schemars( + title = t!("schemas.definitions.resourceType.title"), + description = t!("schemas.definitions.resourceType.description"), + extend( + "pattern" = FullyQualifiedTypeName::VALIDATING_PATTERN, + "patternErrorMessage" = t!("schemas.definitions.resourceType.patternErrorMessage"), + "markdownDescription" = t!("schemas.definitions.resourceType.markdownDescription"), + ) +)] +#[dsc_repo_schema(base_name = "resourceType", folder_path = "definitions")] +pub struct FullyQualifiedTypeName(String); + +/// This static lazily defines the validating regex for [`FullyQualifiedTypeName`]. It enables the +/// [`Regex`] instance to be constructed once, the first time it's used, and then reused on all +/// subsequent validation calls. It's kept private, since the API usage is to invoke the +/// [`FullyQualifiedTypeName::validate()`] method for direct validation or to leverage this static +/// from within the constructor for [`FullyQualifiedTypeName`]. +static VALIDATING_REGEX: OnceLock = OnceLock::new(); + +impl FullyQualifiedTypeName { + /// Defines the regular expression for validating a string as a fully qualified type name. + /// + /// The string must begin with one or more alphanumeric characters and underscores that define + /// the `owner` for the type. Following the `owner` segment, the string may include any number + /// of `namespace` segments, which must be separated from the previous segment by a single + /// period (`.`). Finally, the string must include a forward slash (`/`) followed by one or + /// more alphanumeric characters and underscores to define the `name` segment. + pub const VALIDATING_PATTERN: &str = r"^\w+(\.\w+)*\/\w+$"; + + /// Returns the [`Regex`] for [`Self::VALIDATING_PATTERN`]. + /// + /// This private method is used to initialize the [`VALIDATING_REGEX`] private static to reduce + /// the number of times the regular expression is compiled from the pattern string. + fn init_pattern() -> Regex { + Regex::new(Self::VALIDATING_PATTERN).expect("pattern is valid") + } + + /// Validates a given string as a fully qualified name. + /// + /// A string is valid if it matches the [`VALIDATING_PATTERN`]. If the string is invalid, DSC + /// raises the [`DscError::InvalidTypeName`] error. + /// + /// [`VALIDATING_PATTERN`]: Self::VALIDATING_PATTERN + pub fn validate(name: &str) -> Result<(), DscError> { + let pattern = VALIDATING_REGEX.get_or_init(Self::init_pattern); + match pattern.is_match(name) { + true => Ok(()), + false => Err(DscError::InvalidTypeName( + name.to_string(), + pattern.to_string(), + )), + } + } + + /// Creates a new instance of [`FullyQualifiedName`] from a string if the input is valid for the + /// [`VALIDATING_PATTERN`]. If the string is invalid, the method raises the + /// [`DscError::InvalidTypeName`] error. + pub fn new(name: &str) -> Result { + Self::validate(name)?; + Ok(Self(name.to_string())) + } +} + +impl Default for FullyQualifiedTypeName { + fn default() -> Self { + Self(String::new()) + } +} + +impl FromStr for FullyQualifiedTypeName { + type Err = DscError; + fn from_str(s: &str) -> Result { + Self::new(s) + } +} + +impl TryFrom for FullyQualifiedTypeName { + type Error = DscError; + fn try_from(value: String) -> Result { + Self::new(value.as_str()) + } +} + +impl Display for FullyQualifiedTypeName { + fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl AsRef for FullyQualifiedTypeName { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl Deref for FullyQualifiedTypeName { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} diff --git a/lib/dsc-lib/src/types/mod.rs b/lib/dsc-lib/src/types/mod.rs new file mode 100644 index 000000000..b046d479b --- /dev/null +++ b/lib/dsc-lib/src/types/mod.rs @@ -0,0 +1,5 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +mod fully_qualified_type_name; +pub use fully_qualified_type_name::FullyQualifiedTypeName; diff --git a/lib/dsc-lib/tests/integration/main.rs b/lib/dsc-lib/tests/integration/main.rs index a203d38a9..28aca9416 100644 --- a/lib/dsc-lib/tests/integration/main.rs +++ b/lib/dsc-lib/tests/integration/main.rs @@ -14,3 +14,4 @@ //! Rust would generate numerous binaries to execute our tests. #[cfg(test)] mod schemas; +#[cfg(test)] mod types; diff --git a/lib/dsc-lib/tests/integration/types/fully_qualified_type_name.rs b/lib/dsc-lib/tests/integration/types/fully_qualified_type_name.rs new file mode 100644 index 000000000..86252c1e9 --- /dev/null +++ b/lib/dsc-lib/tests/integration/types/fully_qualified_type_name.rs @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use jsonschema::Validator; +use schemars::schema_for; +use serde_json::{json, Value}; + +use dsc_lib::{dscerror::DscError, types::FullyQualifiedTypeName}; + +#[test] +fn test_schema_without_segments() { + let schema = Validator::new(schema_for!(FullyQualifiedTypeName).as_value()).unwrap(); + let name = "invalid_type_name"; + + assert!(schema + .validate(&json!(name)) + .unwrap_err() + .to_string() + .starts_with(format!(r#""{name}" does not match"#).as_str())) +} + +#[test] +fn test_schema_with_invalid_character() { + let schema = Validator::new(schema_for!(FullyQualifiedTypeName).as_value()).unwrap(); + let name = "With&Invalid/Character"; + + assert!(schema + .validate(&json!(name)) + .unwrap_err() + .to_string() + .starts_with(format!(r#""{name}" does not match"#).as_str())) +} + +#[test] +fn test_schema_without_namespaces() { + let schema = Validator::new(schema_for!(FullyQualifiedTypeName).as_value()).unwrap(); + let name = "Owner/Name"; + + assert!(schema.validate(&json!(name)).is_ok()) +} + +#[test] +fn test_schema_with_one_namespace() { + let schema = Validator::new(schema_for!(FullyQualifiedTypeName).as_value()).unwrap(); + let name = "Owner.Namespace/Name"; + + assert!(schema.validate(&json!(name)).is_ok()) +} + +#[test] +fn test_schema_with_many_namespaces() { + let schema = Validator::new(schema_for!(FullyQualifiedTypeName).as_value()).unwrap(); + let name = "Owner.A.B.C.D.E.F/Name"; + + assert!(schema.validate(&json!(name)).is_ok()) +} + +#[test] +fn test_deserialize_valid() { + let name = "Owner/Name"; + let deserialized: FullyQualifiedTypeName = serde_json::from_value(json!(name)).unwrap(); + assert_eq!(deserialized.to_string(), name.to_string()) +} + +#[test] +fn test_deserialize_invalid() { + let name = "invalid_name"; + let deserializing_error = serde_json::from_value::(json!(name)) + .unwrap_err() + .to_string(); + let expected_error = DscError::InvalidTypeName( + name.to_string(), + FullyQualifiedTypeName::VALIDATING_PATTERN.to_string(), + ) + .to_string(); + + assert_eq!(deserializing_error, expected_error) +} + +#[test] +fn test_serialize_valid() { + let name = "Owner/Name"; + let instance = FullyQualifiedTypeName::new(name).unwrap(); + let serialized: Value = serde_json::to_value(instance).unwrap(); + assert_eq!(serialized, json!(name)) +} + +#[test] +fn test_display() { + let name = "Owner/Name"; + let instance = FullyQualifiedTypeName::new(name).unwrap(); + assert_eq!(format!("{instance}"), format!("{name}")) +} + +#[test] +fn test_as_ref() { + let name = "Owner/Name"; + let instance = FullyQualifiedTypeName::new(name).unwrap(); + assert_eq!(name, instance.as_ref()) +} + +#[test] +fn test_deref() { + let name = "Owner/Name"; + let instance = FullyQualifiedTypeName::new(name).unwrap(); + assert_eq!(*name, *instance) +} + +#[test] +fn test_default_is_empty() { + let instance = FullyQualifiedTypeName::default(); + assert!(instance.is_empty()) +} diff --git a/lib/dsc-lib/tests/integration/types/mod.rs b/lib/dsc-lib/tests/integration/types/mod.rs new file mode 100644 index 000000000..c45605926 --- /dev/null +++ b/lib/dsc-lib/tests/integration/types/mod.rs @@ -0,0 +1,5 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#[cfg(test)] +mod fully_qualified_type_name; From dabb91fb86d9fc7d33d5f7193ceba226138b986f Mon Sep 17 00:00:00 2001 From: Mikey Lombardi Date: Thu, 8 Jan 2026 09:50:40 -0600 Subject: [PATCH 02/11] (GH-538) Update resource manifest to use FQTN Prior to this change, the resource manifest struct defined the `resource_type` field as a `String`. This change updates the type to use the new `FullyQualifiedTypeName` struct instead so that the generated schema will be canonical and correctly validated. This change updates the code where needed to account for this change. Generally this entails calling `to_string()` instead of `clone()` to access the string value or `parse().unwrap()` instead of `to_string()` when constructing the value from a string literal. --- lib/dsc-lib/src/discovery/command_discovery.rs | 2 +- lib/dsc-lib/src/dscresources/command_resource.rs | 12 ++++++------ lib/dsc-lib/src/dscresources/resource_manifest.rs | 7 ++++--- tools/test_group_resource/src/main.rs | 6 +++--- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/lib/dsc-lib/src/discovery/command_discovery.rs b/lib/dsc-lib/src/discovery/command_discovery.rs index 8479b8cb6..079bc09e6 100644 --- a/lib/dsc-lib/src/discovery/command_discovery.rs +++ b/lib/dsc-lib/src/discovery/command_discovery.rs @@ -774,7 +774,7 @@ fn load_resource_manifest(path: &Path, manifest: &ResourceManifest) -> Result = serde_json::from_value(pre_state_value)?; @@ -220,12 +220,12 @@ pub fn invoke_set(resource: &ResourceManifest, cwd: &Path, desired: &str, skip_t // command should be returning actual state as a JSON line and a list of properties that differ as separate JSON line let mut lines = stdout.lines(); let Some(actual_line) = lines.next() else { - return Err(DscError::Command(resource.resource_type.clone(), exit_code, t!("dscresources.commandResource.setUnexpectedOutput").to_string())); + return Err(DscError::Command(resource.resource_type.to_string(), exit_code, t!("dscresources.commandResource.setUnexpectedOutput").to_string())); }; let actual_value: Value = serde_json::from_str(actual_line)?; // TODO: need schema for diff_properties to validate against let Some(diff_line) = lines.next() else { - return Err(DscError::Command(resource.resource_type.clone(), exit_code, t!("dscresources.commandResource.setUnexpectedDiff").to_string())); + return Err(DscError::Command(resource.resource_type.to_string(), exit_code, t!("dscresources.commandResource.setUnexpectedDiff").to_string())); }; let diff_properties: Vec = serde_json::from_str(diff_line)?; Ok(SetResult::Resource(ResourceSetResponse { @@ -324,11 +324,11 @@ pub fn invoke_test(resource: &ResourceManifest, cwd: &Path, expected: &str, targ // command should be returning actual state as a JSON line and a list of properties that differ as separate JSON line let mut lines = stdout.lines(); let Some(actual_value) = lines.next() else { - return Err(DscError::Command(resource.resource_type.clone(), exit_code, t!("dscresources.commandResource.testNoActualState").to_string())); + return Err(DscError::Command(resource.resource_type.to_string(), exit_code, t!("dscresources.commandResource.testNoActualState").to_string())); }; let actual_value: Value = serde_json::from_str(actual_value)?; let Some(diff_properties) = lines.next() else { - return Err(DscError::Command(resource.resource_type.clone(), exit_code, t!("dscresources.commandResource.testNoDiff").to_string())); + return Err(DscError::Command(resource.resource_type.to_string(), exit_code, t!("dscresources.commandResource.testNoDiff").to_string())); }; let diff_properties: Vec = serde_json::from_str(diff_properties)?; expected_value = redact(&expected_value); @@ -481,7 +481,7 @@ pub fn invoke_validate(resource: &ResourceManifest, cwd: &Path, config: &str, ta /// Error if schema is not available or if there is an error getting the schema pub fn get_schema(resource: &ResourceManifest, cwd: &Path) -> Result { let Some(schema_kind) = resource.schema.as_ref() else { - return Err(DscError::SchemaNotAvailable(resource.resource_type.clone())); + return Err(DscError::SchemaNotAvailable(resource.resource_type.to_string())); }; match schema_kind { diff --git a/lib/dsc-lib/src/dscresources/resource_manifest.rs b/lib/dsc-lib/src/dscresources/resource_manifest.rs index 4e9a19925..d0dd3b48e 100644 --- a/lib/dsc-lib/src/dscresources/resource_manifest.rs +++ b/lib/dsc-lib/src/dscresources/resource_manifest.rs @@ -11,6 +11,7 @@ use std::collections::HashMap; use crate::{ dscerror::DscError, schemas::{dsc_repo::DscRepoSchema, transforms::idiomaticize_string_enum}, + types::FullyQualifiedTypeName, }; #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, DscRepoSchema)] @@ -44,7 +45,7 @@ pub struct ResourceManifest { pub schema_version: String, /// The namespaced name of the resource. #[serde(rename = "type")] - pub resource_type: String, + pub resource_type: FullyQualifiedTypeName, /// An optional condition for the resource to be active. If the condition evaluates to false, the resource is skipped. #[serde(skip_serializing_if = "Option::is_none")] pub condition: Option, @@ -332,7 +333,7 @@ mod test { let manifest = ResourceManifest{ schema_version: invalid_uri.clone(), - resource_type: "Microsoft.Dsc.Test/InvalidSchemaUri".to_string(), + resource_type: "Microsoft.Dsc.Test/InvalidSchemaUri".parse().unwrap(), version: "0.1.0".to_string(), ..Default::default() }; @@ -353,7 +354,7 @@ mod test { fn test_validate_schema_uri_with_valid_uri() { let manifest = ResourceManifest{ schema_version: ResourceManifest::default_schema_id_uri(), - resource_type: "Microsoft.Dsc.Test/ValidSchemaUri".to_string(), + resource_type: "Microsoft.Dsc.Test/ValidSchemaUri".parse().unwrap(), version: "0.1.0".to_string(), ..Default::default() }; diff --git a/tools/test_group_resource/src/main.rs b/tools/test_group_resource/src/main.rs index f3888c408..17d9c1398 100644 --- a/tools/test_group_resource/src/main.rs +++ b/tools/test_group_resource/src/main.rs @@ -5,8 +5,8 @@ mod args; use args::{Args, SubCommand}; use clap::Parser; -use dsc_lib::dscresources::resource_manifest::{ResourceManifest, GetMethod, Kind}; use dsc_lib::dscresources::dscresource::{Capability, DscResource, ImplementedAs}; +use dsc_lib::dscresources::resource_manifest::{GetMethod, Kind, ResourceManifest}; use dsc_lib::schemas::dsc_repo::DscRepoSchema; use std::path::PathBuf; @@ -30,7 +30,7 @@ fn main() { manifest: Some(serde_json::to_value(ResourceManifest { description: Some("This is a test resource.".to_string()), schema_version: dsc_lib::dscresources::resource_manifest::ResourceManifest::default_schema_id_uri(), - resource_type: "Test/TestResource1".to_string(), + resource_type: "Test/TestResource1".parse().unwrap(), kind: Some(Kind::Resource), version: "1.0.0".to_string(), get: Some(GetMethod { @@ -56,7 +56,7 @@ fn main() { manifest: Some(serde_json::to_value(ResourceManifest { description: Some("This is a test resource.".to_string()), schema_version: dsc_lib::dscresources::resource_manifest::ResourceManifest::default_schema_id_uri(), - resource_type: "Test/TestResource2".to_string(), + resource_type: "Test/TestResource2".parse().unwrap(), kind: Some(Kind::Resource), version: "1.0.1".to_string(), get: Some(GetMethod { From 5a404f8b470f0abfe8f4b0a386062a3d3661eb47 Mon Sep 17 00:00:00 2001 From: Mikey Lombardi Date: Thu, 8 Jan 2026 10:05:34 -0600 Subject: [PATCH 03/11] (GH-538) Use FQTN for extensions This change updates both the `DscExtension` and `ExtensionManifest` types to use `FullyQualifiedTypeName` instead of `String` for the type name field. This change also updates all code as needed to address this change. --- dsc/src/subcommand.rs | 2 +- lib/dsc-lib/src/discovery/command_discovery.rs | 6 +++--- lib/dsc-lib/src/extensions/discover.rs | 6 +++--- lib/dsc-lib/src/extensions/dscextension.rs | 5 +++-- lib/dsc-lib/src/extensions/extension_manifest.rs | 7 ++++--- lib/dsc-lib/src/extensions/import.rs | 6 +++--- lib/dsc-lib/src/extensions/secret.rs | 6 +++--- 7 files changed, 20 insertions(+), 18 deletions(-) diff --git a/dsc/src/subcommand.rs b/dsc/src/subcommand.rs index 3132dfedd..94b1b8a27 100644 --- a/dsc/src/subcommand.rs +++ b/dsc/src/subcommand.rs @@ -619,7 +619,7 @@ fn list_extensions(dsc: &mut DscManager, extension_name: Option<&String>, format if write_table { table.add_row(vec![ - extension.type_name, + extension.type_name.to_string(), extension.version, capabilities, extension.description.unwrap_or_default() diff --git a/lib/dsc-lib/src/discovery/command_discovery.rs b/lib/dsc-lib/src/discovery/command_discovery.rs index 079bc09e6..b8b7e9c61 100644 --- a/lib/dsc-lib/src/discovery/command_discovery.rs +++ b/lib/dsc-lib/src/discovery/command_discovery.rs @@ -277,7 +277,7 @@ impl ResourceDiscovery for CommandDiscovery { if regex.is_match(&extension.type_name) { trace!("{}", t!("discovery.commandDiscovery.extensionFound", extension = extension.type_name, version = extension.version)); // we only keep newest version of the extension so compare the version and only keep the newest - if let Some(existing_extension) = extensions.get_mut(&extension.type_name) { + if let Some(existing_extension) = extensions.get_mut(extension.type_name.as_ref()) { let Ok(existing_version) = Version::parse(&existing_extension.version) else { return Err(DscError::Operation(t!("discovery.commandDiscovery.extensionInvalidVersion", extension = existing_extension.type_name, version = existing_extension.version).to_string())); }; @@ -285,10 +285,10 @@ impl ResourceDiscovery for CommandDiscovery { return Err(DscError::Operation(t!("discovery.commandDiscovery.extensionInvalidVersion", extension = extension.type_name, version = extension.version).to_string())); }; if new_version > existing_version { - extensions.insert(extension.type_name.clone(), extension.clone()); + extensions.insert(extension.type_name.to_string(), extension.clone()); } } else { - extensions.insert(extension.type_name.clone(), extension.clone()); + extensions.insert(extension.type_name.to_string(), extension.clone()); } } }, diff --git a/lib/dsc-lib/src/extensions/discover.rs b/lib/dsc-lib/src/extensions/discover.rs index 11128669c..055fc0bf4 100644 --- a/lib/dsc-lib/src/extensions/discover.rs +++ b/lib/dsc-lib/src/extensions/discover.rs @@ -62,11 +62,11 @@ impl DscExtension { let extension = match serde_json::from_value::(self.manifest.clone()) { Ok(manifest) => manifest, Err(err) => { - return Err(DscError::Manifest(self.type_name.clone(), err)); + return Err(DscError::Manifest(self.type_name.to_string(), err)); } }; let Some(discover) = extension.discover else { - return Err(DscError::UnsupportedCapability(self.type_name.clone(), Capability::Discover.to_string())); + return Err(DscError::UnsupportedCapability(self.type_name.to_string(), Capability::Discover.to_string())); }; let args = process_args(discover.args.as_ref(), "", &self.type_name); let (_exit_code, stdout, _stderr) = invoke_command( @@ -103,7 +103,7 @@ impl DscExtension { Ok(resources) } else { Err(DscError::UnsupportedCapability( - self.type_name.clone(), + self.type_name.to_string(), Capability::Discover.to_string() )) } diff --git a/lib/dsc-lib/src/extensions/dscextension.rs b/lib/dsc-lib/src/extensions/dscextension.rs index 379dedcb6..ad57a9762 100644 --- a/lib/dsc-lib/src/extensions/dscextension.rs +++ b/lib/dsc-lib/src/extensions/dscextension.rs @@ -3,6 +3,7 @@ use crate::extensions::import::ImportMethod; use crate::schemas::{dsc_repo::DscRepoSchema, transforms::idiomaticize_string_enum}; +use crate::types::FullyQualifiedTypeName; use serde::{Deserialize, Serialize}; use serde_json::Value; use schemars::JsonSchema; @@ -15,7 +16,7 @@ use std::path::PathBuf; pub struct DscExtension { /// The namespaced name of the resource. #[serde(rename="type")] - pub type_name: String, + pub type_name: FullyQualifiedTypeName, /// The version of the resource. pub version: String, /// The capabilities of the resource. @@ -61,7 +62,7 @@ impl DscExtension { #[must_use] pub fn new() -> Self { Self { - type_name: String::new(), + type_name: FullyQualifiedTypeName::default(), version: String::new(), capabilities: Vec::new(), import: None, diff --git a/lib/dsc-lib/src/extensions/extension_manifest.rs b/lib/dsc-lib/src/extensions/extension_manifest.rs index 59c95e979..c0bad512f 100644 --- a/lib/dsc-lib/src/extensions/extension_manifest.rs +++ b/lib/dsc-lib/src/extensions/extension_manifest.rs @@ -11,6 +11,7 @@ use std::collections::HashMap; use crate::dscerror::DscError; use crate::extensions::{discover::DiscoverMethod, import::ImportMethod, secret::SecretMethod}; use crate::schemas::dsc_repo::DscRepoSchema; +use crate::types::FullyQualifiedTypeName; #[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize, JsonSchema, DscRepoSchema)] #[serde(deny_unknown_fields)] @@ -31,7 +32,7 @@ pub struct ExtensionManifest { pub schema_version: String, /// The namespaced name of the extension. #[serde(rename = "type")] - pub r#type: String, + pub r#type: FullyQualifiedTypeName, /// The version of the extension using semantic versioning. pub version: String, /// An optional condition for the extension to be active. If the condition evaluates to false, the extension is skipped. @@ -106,7 +107,7 @@ mod test { let manifest = ExtensionManifest{ schema_version: invalid_uri.clone(), - r#type: "Microsoft.Dsc.Test/InvalidSchemaUri".to_string(), + r#type: "Microsoft.Dsc.Test/InvalidSchemaUri".parse().unwrap(), version: "0.1.0".to_string(), ..Default::default() }; @@ -127,7 +128,7 @@ mod test { fn test_validate_schema_uri_with_valid_uri() { let manifest = ExtensionManifest{ schema_version: ExtensionManifest::default_schema_id_uri(), - r#type: "Microsoft.Dsc.Test/ValidSchemaUri".to_string(), + r#type: "Microsoft.Dsc.Test/ValidSchemaUri".parse().unwrap(), version: "0.1.0".to_string(), ..Default::default() }; diff --git a/lib/dsc-lib/src/extensions/import.rs b/lib/dsc-lib/src/extensions/import.rs index d456c235a..661987828 100644 --- a/lib/dsc-lib/src/extensions/import.rs +++ b/lib/dsc-lib/src/extensions/import.rs @@ -75,11 +75,11 @@ impl DscExtension { let extension = match serde_json::from_value::(self.manifest.clone()) { Ok(manifest) => manifest, Err(err) => { - return Err(DscError::Manifest(self.type_name.clone(), err)); + return Err(DscError::Manifest(self.type_name.to_string(), err)); } }; let Some(import) = extension.import else { - return Err(DscError::UnsupportedCapability(self.type_name.clone(), Capability::Import.to_string())); + return Err(DscError::UnsupportedCapability(self.type_name.to_string(), Capability::Import.to_string())); }; let args = process_import_args(import.args.as_ref(), file)?; let (_exit_code, stdout, _stderr) = invoke_command( @@ -105,7 +105,7 @@ impl DscExtension { } } Err(DscError::UnsupportedCapability( - self.type_name.clone(), + self.type_name.to_string(), Capability::Import.to_string() )) } diff --git a/lib/dsc-lib/src/extensions/secret.rs b/lib/dsc-lib/src/extensions/secret.rs index 819620b07..2805e2588 100644 --- a/lib/dsc-lib/src/extensions/secret.rs +++ b/lib/dsc-lib/src/extensions/secret.rs @@ -70,11 +70,11 @@ impl DscExtension { let extension = match serde_json::from_value::(self.manifest.clone()) { Ok(manifest) => manifest, Err(err) => { - return Err(DscError::Manifest(self.type_name.clone(), err)); + return Err(DscError::Manifest(self.type_name.to_string(), err)); } }; let Some(secret) = extension.secret else { - return Err(DscError::UnsupportedCapability(self.type_name.clone(), Capability::Secret.to_string())); + return Err(DscError::UnsupportedCapability(self.type_name.to_string(), Capability::Secret.to_string())); }; let args = process_secret_args(secret.args.as_ref(), name, vault); let (_exit_code, stdout, _stderr) = invoke_command( @@ -101,7 +101,7 @@ impl DscExtension { } } else { Err(DscError::UnsupportedCapability( - self.type_name.clone(), + self.type_name.to_string(), Capability::Secret.to_string() )) } From 438b897664dd0403de116c31027d95dc1d22155f Mon Sep 17 00:00:00 2001 From: Mikey Lombardi Date: Thu, 8 Jan 2026 10:27:46 -0600 Subject: [PATCH 04/11] (GH-538) Update `DscResource` to use FQTN This change updates the `DscResource` struct to define the `type_name` field as `FullyQualifiedTypeName` instead of `String`. It also updates the rest of the code as required. --- dsc/src/mcp/list_dsc_resources.rs | 2 +- dsc/src/mcp/show_dsc_resource.rs | 2 +- dsc/src/subcommand.rs | 2 +- lib/dsc-lib/src/configure/mod.rs | 2 +- .../src/discovery/command_discovery.rs | 4 +-- lib/dsc-lib/src/dscresources/dscresource.rs | 30 +++++++++---------- tools/test_group_resource/src/main.rs | 6 ++-- 7 files changed, 24 insertions(+), 24 deletions(-) diff --git a/dsc/src/mcp/list_dsc_resources.rs b/dsc/src/mcp/list_dsc_resources.rs index 2f347fb87..c27d8aed2 100644 --- a/dsc/src/mcp/list_dsc_resources.rs +++ b/dsc/src/mcp/list_dsc_resources.rs @@ -67,7 +67,7 @@ impl McpServer { for resource in dsc.list_available(&DiscoveryKind::Resource, "*", &adapter_filter, ProgressFormat::None) { if let Resource(resource) = resource { let summary = ResourceSummary { - r#type: resource.type_name.clone(), + r#type: resource.type_name.to_string(), kind: resource.kind.clone(), description: resource.description.clone(), require_adapter: resource.require_adapter.clone(), diff --git a/dsc/src/mcp/show_dsc_resource.rs b/dsc/src/mcp/show_dsc_resource.rs index 660fbf312..ea7b89dd5 100644 --- a/dsc/src/mcp/show_dsc_resource.rs +++ b/dsc/src/mcp/show_dsc_resource.rs @@ -66,7 +66,7 @@ impl McpServer { Err(_) => None, }; Ok(DscResource { - type_name: resource.type_name.clone(), + type_name: resource.type_name.to_string(), kind: resource.kind.clone(), version: resource.version.clone(), capabilities: resource.capabilities.clone(), diff --git a/dsc/src/subcommand.rs b/dsc/src/subcommand.rs index 94b1b8a27..624b42194 100644 --- a/dsc/src/subcommand.rs +++ b/dsc/src/subcommand.rs @@ -820,7 +820,7 @@ pub fn list_resources(dsc: &mut DscManager, resource_name: Option<&String>, adap if write_table { table.add_row(vec![ - resource.type_name, + resource.type_name.to_string(), format!("{:?}", resource.kind), resource.version, capabilities, diff --git a/lib/dsc-lib/src/configure/mod.rs b/lib/dsc-lib/src/configure/mod.rs index 04e96a144..6e5a83fe6 100644 --- a/lib/dsc-lib/src/configure/mod.rs +++ b/lib/dsc-lib/src/configure/mod.rs @@ -68,7 +68,7 @@ pub fn add_resource_export_results_to_configuration(resource: &DscResource, conf } else { for (i, instance) in export_result.actual_state.iter().enumerate() { let mut r: Resource = config_doc::Resource::new(); - r.resource_type.clone_from(&resource.type_name); + r.resource_type.clone_from(&resource.type_name.to_string()); let mut props: Map = serde_json::from_value(instance.clone())?; if let Some(kind) = props.remove("_kind") { if !kind.is_string() { diff --git a/lib/dsc-lib/src/discovery/command_discovery.rs b/lib/dsc-lib/src/discovery/command_discovery.rs index b8b7e9c61..328060e24 100644 --- a/lib/dsc-lib/src/discovery/command_discovery.rs +++ b/lib/dsc-lib/src/discovery/command_discovery.rs @@ -427,7 +427,7 @@ impl ResourceDiscovery for CommandDiscovery { match serde_json::from_str::(line){ Result::Ok(resource) => { if resource.require_adapter.is_none() { - warn!("{}", DscError::MissingRequires(adapter_name.clone(), resource.type_name.clone()).to_string()); + warn!("{}", DscError::MissingRequires(adapter_name.clone(), resource.type_name.to_string()).to_string()); continue; } @@ -774,7 +774,7 @@ fn load_resource_manifest(path: &Path, manifest: &ResourceManifest) -> Result Self { Self { - type_name: String::new(), + type_name: FullyQualifiedTypeName::default(), kind: Kind::Resource, version: String::new(), capabilities: Vec::new(), @@ -118,8 +118,8 @@ impl DscResource { // create new configuration with adapter and use this as the resource let mut configuration = Configuration::new(); let mut property_map = Map::new(); - property_map.insert("name".to_string(), Value::String(self.type_name.clone())); - property_map.insert("type".to_string(), Value::String(self.type_name.clone())); + property_map.insert("name".to_string(), Value::String(self.type_name.to_string())); + property_map.insert("type".to_string(), Value::String(self.type_name.to_string())); if !input.is_empty() { let resource_properties: Value = serde_json::from_str(input)?; property_map.insert("properties".to_string(), resource_properties); @@ -127,7 +127,7 @@ impl DscResource { let mut resources_map = Map::new(); resources_map.insert("resources".to_string(), Value::Array(vec![Value::Object(property_map)])); let adapter_resource = Resource { - name: self.type_name.clone(), + name: self.type_name.to_string(), resource_type: adapter.to_string(), properties: Some(resources_map), ..Default::default() @@ -250,7 +250,7 @@ impl DscResource { let mut configurator = self.clone().create_config_for_adapter(adapter, input)?; let mut adapter = Self::get_adapter_resource(&mut configurator, adapter)?; if get_adapter_input_kind(&adapter)? == AdapterInputKind::Single { - adapter.target_resource = Some(self.type_name.clone()); + adapter.target_resource = Some(self.type_name.to_string()); return adapter.export(input); } @@ -391,7 +391,7 @@ impl Invoke for DscResource { }, ImplementedAs::Command => { let Some(manifest) = &self.manifest else { - return Err(DscError::MissingManifest(self.type_name.clone())); + return Err(DscError::MissingManifest(self.type_name.to_string())); }; let resource_manifest = import_manifest(manifest.clone())?; command_resource::invoke_get(&resource_manifest, &self.directory, filter, self.target_resource.as_deref()) @@ -411,7 +411,7 @@ impl Invoke for DscResource { }, ImplementedAs::Command => { let Some(manifest) = &self.manifest else { - return Err(DscError::MissingManifest(self.type_name.clone())); + return Err(DscError::MissingManifest(self.type_name.to_string())); }; let resource_manifest = import_manifest(manifest.clone())?; command_resource::invoke_set(&resource_manifest, &self.directory, desired, skip_test, execution_type, self.target_resource.as_deref()) @@ -431,7 +431,7 @@ impl Invoke for DscResource { }, ImplementedAs::Command => { let Some(manifest) = &self.manifest else { - return Err(DscError::MissingManifest(self.type_name.clone())); + return Err(DscError::MissingManifest(self.type_name.to_string())); }; // if test is not directly implemented, then we need to handle it here @@ -480,7 +480,7 @@ impl Invoke for DscResource { }, ImplementedAs::Command => { let Some(manifest) = &self.manifest else { - return Err(DscError::MissingManifest(self.type_name.clone())); + return Err(DscError::MissingManifest(self.type_name.to_string())); }; let resource_manifest = import_manifest(manifest.clone())?; command_resource::invoke_delete(&resource_manifest, &self.directory, filter, self.target_resource.as_deref()) @@ -500,7 +500,7 @@ impl Invoke for DscResource { }, ImplementedAs::Command => { let Some(manifest) = &self.manifest else { - return Err(DscError::MissingManifest(self.type_name.clone())); + return Err(DscError::MissingManifest(self.type_name.to_string())); }; let resource_manifest = import_manifest(manifest.clone())?; command_resource::invoke_validate(&resource_manifest, &self.directory, config, self.target_resource.as_deref()) @@ -520,7 +520,7 @@ impl Invoke for DscResource { }, ImplementedAs::Command => { let Some(manifest) = &self.manifest else { - return Err(DscError::MissingManifest(self.type_name.clone())); + return Err(DscError::MissingManifest(self.type_name.to_string())); }; let resource_manifest = import_manifest(manifest.clone())?; command_resource::get_schema(&resource_manifest, &self.directory) @@ -535,7 +535,7 @@ impl Invoke for DscResource { } let Some(manifest) = &self.manifest else { - return Err(DscError::MissingManifest(self.type_name.clone())); + return Err(DscError::MissingManifest(self.type_name.to_string())); }; let resource_manifest = import_manifest(manifest.clone())?; command_resource::invoke_export(&resource_manifest, &self.directory, Some(input), self.target_resource.as_deref()) @@ -548,7 +548,7 @@ impl Invoke for DscResource { } let Some(manifest) = &self.manifest else { - return Err(DscError::MissingManifest(self.type_name.clone())); + return Err(DscError::MissingManifest(self.type_name.to_string())); }; let resource_manifest = import_manifest(manifest.clone())?; command_resource::invoke_resolve(&resource_manifest, &self.directory, input) diff --git a/tools/test_group_resource/src/main.rs b/tools/test_group_resource/src/main.rs index 17d9c1398..02a6c905c 100644 --- a/tools/test_group_resource/src/main.rs +++ b/tools/test_group_resource/src/main.rs @@ -15,7 +15,7 @@ fn main() { match args.subcommand { SubCommand::List => { let resource1 = DscResource { - type_name: "Test/TestResource1".to_string(), + type_name: "Test/TestResource1".parse().unwrap(), kind: Kind::Resource, version: "1.0.0".to_string(), capabilities: vec![Capability::Get, Capability::Set], @@ -41,7 +41,7 @@ fn main() { }).unwrap()), }; let resource2 = DscResource { - type_name: "Test/TestResource2".to_string(), + type_name: "Test/TestResource2".parse().unwrap(), kind: Kind::Resource, version: "1.0.1".to_string(), capabilities: vec![Capability::Get, Capability::Set], @@ -71,7 +71,7 @@ fn main() { }, SubCommand::ListMissingRequires => { let resource1 = DscResource { - type_name: "InvalidResource".to_string(), + type_name: "InvalidResource".parse().unwrap(), kind: Kind::Resource, version: "1.0.0".to_string(), capabilities: vec![Capability::Get], From 3b7497be700d2da84f6b18cc764d2fb467c35e8f Mon Sep 17 00:00:00 2001 From: Mikey Lombardi Date: Thu, 8 Jan 2026 10:44:53 -0600 Subject: [PATCH 05/11] (GH-538) Update config structs to use FQTN This change updates the following structs to use `FullyQualifiedTypeName` instead of `String` for the relevant field: - `configure::config_doc::Resource` - `configure::config_result::ResourceMessage` - `configure::config_result::ResourceGetResult` - `configure::config_result::ResourceSetResult` - `configure::config_result::ResourceTestResult` It also updates references to those fields as needed. --- lib/dsc-lib/src/configure/config_doc.rs | 16 ++++++++-------- lib/dsc-lib/src/configure/config_result.rs | 9 +++++---- lib/dsc-lib/src/configure/depends_on.rs | 6 ++++-- lib/dsc-lib/src/configure/mod.rs | 10 +++++----- lib/dsc-lib/src/dscresources/dscresource.rs | 2 +- 5 files changed, 23 insertions(+), 20 deletions(-) diff --git a/lib/dsc-lib/src/configure/config_doc.rs b/lib/dsc-lib/src/configure/config_doc.rs index 367d01456..ba0303cff 100644 --- a/lib/dsc-lib/src/configure/config_doc.rs +++ b/lib/dsc-lib/src/configure/config_doc.rs @@ -8,10 +8,10 @@ use serde::{Deserialize, Deserializer, Serialize}; use serde_json::{Map, Value}; use std::{collections::HashMap, fmt::Display}; -use crate::schemas::{ +use crate::{schemas::{ dsc_repo::DscRepoSchema, transforms::{idiomaticize_externally_tagged_enum, idiomaticize_string_enum} -}; +}, types::FullyQualifiedTypeName}; #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, DscRepoSchema)] #[serde(rename_all = "camelCase")] @@ -383,7 +383,7 @@ pub struct Resource { pub condition: Option, /// The fully qualified name of the resource type #[serde(rename = "type")] - pub resource_type: String, + pub resource_type: FullyQualifiedTypeName, #[serde(skip_serializing_if = "Option::is_none", rename = "apiVersion")] pub api_version: Option, /// A friendly name for the resource instance @@ -452,7 +452,7 @@ impl Resource { #[must_use] pub fn new() -> Self { Self { - resource_type: String::new(), + resource_type: FullyQualifiedTypeName::default(), name: String::new(), depends_on: None, kind: None, @@ -602,11 +602,11 @@ mod test { assert_eq!(config.resources.len(), 2); assert_eq!(config.resources[0].name, "echoResource"); - assert_eq!(config.resources[0].resource_type, "Microsoft.DSC.Debug/Echo"); + assert_eq!(config.resources[0].resource_type, "Microsoft.DSC.Debug/Echo".parse().unwrap()); assert_eq!(config.resources[0].api_version.as_deref(), Some("1.0.0")); assert_eq!(config.resources[1].name, "processResource"); - assert_eq!(config.resources[1].resource_type, "Microsoft/Process"); + assert_eq!(config.resources[1].resource_type, "Microsoft/Process".parse().unwrap()); assert_eq!(config.resources[1].api_version.as_deref(), Some("0.1.0")); } @@ -649,10 +649,10 @@ mod test { let echo_resource = config.resources.iter().find(|r| r.name == "echoResource").unwrap(); let process_resource = config.resources.iter().find(|r| r.name == "processResource").unwrap(); - assert_eq!(echo_resource.resource_type, "Microsoft.DSC.Debug/Echo"); + assert_eq!(echo_resource.resource_type, "Microsoft.DSC.Debug/Echo".parse().unwrap()); assert_eq!(echo_resource.api_version.as_deref(), Some("1.0.0")); - assert_eq!(process_resource.resource_type, "Microsoft/Process"); + assert_eq!(process_resource.resource_type, "Microsoft/Process".parse().unwrap()); assert_eq!(process_resource.api_version.as_deref(), Some("0.1.0")); } } diff --git a/lib/dsc-lib/src/configure/config_result.rs b/lib/dsc-lib/src/configure/config_result.rs index 31b7c2a84..31fae6e8b 100644 --- a/lib/dsc-lib/src/configure/config_result.rs +++ b/lib/dsc-lib/src/configure/config_result.rs @@ -8,6 +8,7 @@ use serde_json::{Map, Value}; use crate::dscresources::invoke_result::{GetResult, SetResult, TestResult}; use crate::configure::config_doc::{Configuration, Metadata}; use crate::schemas::{dsc_repo::DscRepoSchema, transforms::idiomaticize_string_enum}; +use crate::types::FullyQualifiedTypeName; #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "camelCase")] @@ -24,7 +25,7 @@ pub enum MessageLevel { pub struct ResourceMessage { pub name: String, #[serde(rename="type")] - pub resource_type: String, + pub resource_type: FullyQualifiedTypeName, pub message: String, pub level: MessageLevel, } @@ -37,7 +38,7 @@ pub struct ResourceGetResult { pub metadata: Option, pub name: String, #[serde(rename="type")] - pub resource_type: String, + pub resource_type: FullyQualifiedTypeName, pub result: GetResult, } @@ -108,7 +109,7 @@ pub struct ResourceSetResult { pub metadata: Option, pub name: String, #[serde(rename="type")] - pub resource_type: String, + pub resource_type: FullyQualifiedTypeName, pub result: SetResult, } @@ -184,7 +185,7 @@ pub struct ResourceTestResult { pub metadata: Option, pub name: String, #[serde(rename="type")] - pub resource_type: String, + pub resource_type: FullyQualifiedTypeName, pub result: TestResult, } diff --git a/lib/dsc-lib/src/configure/depends_on.rs b/lib/dsc-lib/src/configure/depends_on.rs index 2364804f3..dae5fca9d 100644 --- a/lib/dsc-lib/src/configure/depends_on.rs +++ b/lib/dsc-lib/src/configure/depends_on.rs @@ -5,6 +5,7 @@ use crate::configure::config_doc::Resource; use crate::configure::{Configuration, IntOrExpression, ProcessMode, invoke_property_expressions}; use crate::DscError; use crate::parser::Statement; +use crate::types::FullyQualifiedTypeName; use rust_i18n::t; use serde_json::Value; @@ -131,14 +132,15 @@ fn unroll_and_push(order: &mut Vec, resource: &Resource, parser: &mut Ok(()) } -fn get_type_and_name(statement: &str) -> Result<(&str, String), DscError> { +fn get_type_and_name(statement: &str) -> Result<(FullyQualifiedTypeName, String), DscError> { let parts: Vec<&str> = statement.split(':').collect(); if parts.len() != 2 { return Err(DscError::Validation(t!("configure.dependsOn.syntaxIncorrect", dependency = statement).to_string())); } // the name is url encoded so we need to decode it let decoded_name = urlencoding::decode(parts[1]).map_err(|_| DscError::Validation(t!("configure.dependsOn.syntaxIncorrect", dependency = statement).to_string()))?; - Ok((parts[0], decoded_name.into_owned())) + let type_name = parts[0].parse()?; + Ok((type_name, decoded_name.into_owned())) } #[cfg(test)] diff --git a/lib/dsc-lib/src/configure/mod.rs b/lib/dsc-lib/src/configure/mod.rs index 6e5a83fe6..8aeb0e42e 100644 --- a/lib/dsc-lib/src/configure/mod.rs +++ b/lib/dsc-lib/src/configure/mod.rs @@ -68,7 +68,7 @@ pub fn add_resource_export_results_to_configuration(resource: &DscResource, conf } else { for (i, instance) in export_result.actual_state.iter().enumerate() { let mut r: Resource = config_doc::Resource::new(); - r.resource_type.clone_from(&resource.type_name.to_string()); + r.resource_type.clone_from(&resource.type_name); let mut props: Map = serde_json::from_value(instance.clone())?; if let Some(kind) = props.remove("_kind") { if !kind.is_string() { @@ -364,7 +364,7 @@ impl Configurator { continue; } let Some(dsc_resource) = discovery.find_resource(&resource.resource_type, resource.api_version.as_deref()) else { - return Err(DscError::ResourceNotFound(resource.resource_type, resource.api_version.as_deref().unwrap_or("").to_string())); + return Err(DscError::ResourceNotFound(resource.resource_type.to_string(), resource.api_version.as_deref().unwrap_or("").to_string())); }; let properties = self.get_properties(&resource, &dsc_resource.kind)?; let filter = add_metadata(dsc_resource, properties, resource.metadata.clone())?; @@ -448,7 +448,7 @@ impl Configurator { continue; } let Some(dsc_resource) = discovery.find_resource(&resource.resource_type, resource.api_version.as_deref()) else { - return Err(DscError::ResourceNotFound(resource.resource_type, resource.api_version.as_deref().unwrap_or("").to_string())); + return Err(DscError::ResourceNotFound(resource.resource_type.to_string(), resource.api_version.as_deref().unwrap_or("").to_string())); }; let properties = self.get_properties(&resource, &dsc_resource.kind)?; debug!("resource_type {}", &resource.resource_type); @@ -616,7 +616,7 @@ impl Configurator { continue; } let Some(dsc_resource) = discovery.find_resource(&resource.resource_type, resource.api_version.as_deref()) else { - return Err(DscError::ResourceNotFound(resource.resource_type, resource.api_version.as_deref().unwrap_or("").to_string())); + return Err(DscError::ResourceNotFound(resource.resource_type.to_string(), resource.api_version.as_deref().unwrap_or("").to_string())); }; let properties = self.get_properties(&resource, &dsc_resource.kind)?; debug!("resource_type {}", &resource.resource_type); @@ -699,7 +699,7 @@ impl Configurator { continue; } let Some(dsc_resource) = discovery.find_resource(&resource.resource_type, resource.api_version.as_deref()) else { - return Err(DscError::ResourceNotFound(resource.resource_type.clone(), resource.api_version.as_deref().unwrap_or("").to_string())); + return Err(DscError::ResourceNotFound(resource.resource_type.to_string(), resource.api_version.as_deref().unwrap_or("").to_string())); }; let properties = self.get_properties(resource, &dsc_resource.kind)?; let input = add_metadata(dsc_resource, properties, resource.metadata.clone())?; diff --git a/lib/dsc-lib/src/dscresources/dscresource.rs b/lib/dsc-lib/src/dscresources/dscresource.rs index b0e721d4c..271c29d82 100644 --- a/lib/dsc-lib/src/dscresources/dscresource.rs +++ b/lib/dsc-lib/src/dscresources/dscresource.rs @@ -128,7 +128,7 @@ impl DscResource { resources_map.insert("resources".to_string(), Value::Array(vec![Value::Object(property_map)])); let adapter_resource = Resource { name: self.type_name.to_string(), - resource_type: adapter.to_string(), + resource_type: adapter.parse()?, properties: Some(resources_map), ..Default::default() }; From 075039aff7f85944d1fd7b0d09bbe87409914c90 Mon Sep 17 00:00:00 2001 From: Mikey Lombardi Date: Thu, 8 Jan 2026 11:26:40 -0600 Subject: [PATCH 06/11] (GH-538) Extend traits for `FullyQualifiedTypeName` This change implements `PartialEq` for case-insensitively comparing FQTNs directly and with string types. This obviates the need to manually lowercase both values before comparison elsewhere and simplifies how you compare an FQTN to a string value. This change also adds some maintainer comments to the code to explain the purpose behind each of the hand-implemented traits. --- lib/dsc-lib/src/configure/config_doc.rs | 8 ++-- .../src/types/fully_qualified_type_name.rs | 42 ++++++++++++++++++- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/lib/dsc-lib/src/configure/config_doc.rs b/lib/dsc-lib/src/configure/config_doc.rs index ba0303cff..98a361c8e 100644 --- a/lib/dsc-lib/src/configure/config_doc.rs +++ b/lib/dsc-lib/src/configure/config_doc.rs @@ -602,11 +602,11 @@ mod test { assert_eq!(config.resources.len(), 2); assert_eq!(config.resources[0].name, "echoResource"); - assert_eq!(config.resources[0].resource_type, "Microsoft.DSC.Debug/Echo".parse().unwrap()); + assert_eq!(config.resources[0].resource_type, "Microsoft.DSC.Debug/Echo"); assert_eq!(config.resources[0].api_version.as_deref(), Some("1.0.0")); assert_eq!(config.resources[1].name, "processResource"); - assert_eq!(config.resources[1].resource_type, "Microsoft/Process".parse().unwrap()); + assert_eq!(config.resources[1].resource_type, "Microsoft/Process"); assert_eq!(config.resources[1].api_version.as_deref(), Some("0.1.0")); } @@ -649,10 +649,10 @@ mod test { let echo_resource = config.resources.iter().find(|r| r.name == "echoResource").unwrap(); let process_resource = config.resources.iter().find(|r| r.name == "processResource").unwrap(); - assert_eq!(echo_resource.resource_type, "Microsoft.DSC.Debug/Echo".parse().unwrap()); + assert_eq!(echo_resource.resource_type, "Microsoft.DSC.Debug/Echo"); assert_eq!(echo_resource.api_version.as_deref(), Some("1.0.0")); - assert_eq!(process_resource.resource_type, "Microsoft/Process".parse().unwrap()); + assert_eq!(process_resource.resource_type, "Microsoft/Process"); assert_eq!(process_resource.api_version.as_deref(), Some("0.1.0")); } } diff --git a/lib/dsc-lib/src/types/fully_qualified_type_name.rs b/lib/dsc-lib/src/types/fully_qualified_type_name.rs index ac049ed49..4b7247b9c 100644 --- a/lib/dsc-lib/src/types/fully_qualified_type_name.rs +++ b/lib/dsc-lib/src/types/fully_qualified_type_name.rs @@ -19,7 +19,6 @@ use crate::schemas::dsc_repo::DscRepoSchema; #[derive( Clone, Debug, - PartialEq, Eq, PartialOrd, Ord, @@ -93,12 +92,41 @@ impl FullyQualifiedTypeName { } } +// While it's technically never valid for a _defined_ FQTN to be empty, we need the default +// implementation for creating empty instances of various structs to then populate/modify. impl Default for FullyQualifiedTypeName { fn default() -> Self { Self(String::new()) } } +// We implement `PartialEq` by hand for various types because FQTNs should be compared +// case insensitively. This obviates the need to `.to_string().to_lowercase()` for comparisons. +impl PartialEq for FullyQualifiedTypeName { + fn eq(&self, other: &Self) -> bool { + self.0.to_lowercase() == other.0.to_lowercase() + } +} + +impl PartialEq for FullyQualifiedTypeName { + fn eq(&self, other: &String) -> bool { + self.0.to_lowercase() == other.to_lowercase() + } +} + +impl PartialEq for FullyQualifiedTypeName { + fn eq(&self, other: &str) -> bool { + self.0.to_lowercase() == other.to_lowercase() + } +} + +impl PartialEq<&str> for FullyQualifiedTypeName { + fn eq(&self, other: &&str) -> bool { + self.0.to_lowercase() == other.to_lowercase() + } +} + +// Enables using the construct `"Owner/Name".parse()` to convert a literal string into an FQTN. impl FromStr for FullyQualifiedTypeName { type Err = DscError; fn from_str(s: &str) -> Result { @@ -106,6 +134,8 @@ impl FromStr for FullyQualifiedTypeName { } } +// Enables converting from a `String` and raising the appropriate error message for an invalid +// FQTN. impl TryFrom for FullyQualifiedTypeName { type Error = DscError; fn try_from(value: String) -> Result { @@ -113,18 +143,28 @@ impl TryFrom for FullyQualifiedTypeName { } } +// Enables converting an FQTN into a string. +impl From for String { + fn from(value: FullyQualifiedTypeName) -> Self { + value.0 + } +} + +// Enables using FQTNs in `format!()` and similar macros. impl Display for FullyQualifiedTypeName { fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { write!(f, "{}", self.0) } } +// Enables passing an FQTN as `&str` impl AsRef for FullyQualifiedTypeName { fn as_ref(&self) -> &str { &self.0 } } +// Enables directly accessing string methods on an FQTN, like `.to_lowercase()` or `starts_with()`. impl Deref for FullyQualifiedTypeName { type Target = str; From c95a2bd45481e5213ac9c3894c44d2d122366b11 Mon Sep 17 00:00:00 2001 From: Mikey Lombardi Date: Thu, 8 Jan 2026 11:39:13 -0600 Subject: [PATCH 07/11] (GH-538) Update `Progress` to use FQTN This change updates the `Progress` struct to define the `resource_type` field as `FullyQualifiedTypeName` for consistency and to ensure that the progress data can be canonically schematized. --- lib/dsc-lib/src/progress.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/dsc-lib/src/progress.rs b/lib/dsc-lib/src/progress.rs index 41c4ae54d..66857d01a 100644 --- a/lib/dsc-lib/src/progress.rs +++ b/lib/dsc-lib/src/progress.rs @@ -2,6 +2,7 @@ // Licensed under the MIT License. use crate::DscError; +use crate::types::FullyQualifiedTypeName; use clap::ValueEnum; use indicatif::ProgressStyle; @@ -52,7 +53,7 @@ pub struct Progress { pub resource_name: Option, /// The type of the resource being operated on. #[serde(skip_serializing_if = "Option::is_none")] - pub resource_type: Option, + pub resource_type: Option, /// The result of the operation. #[serde(skip_serializing_if = "Option::is_none")] pub result: Option, @@ -139,9 +140,9 @@ impl ProgressBar { /// * `resource_type` - The type of the resource being operated on /// * `result` - The result of the operation /// - pub fn set_resource(&mut self, name: &str, resource_type: &str) { + pub fn set_resource(&mut self, name: &str, resource_type: &FullyQualifiedTypeName) { self.progress_value.resource_name = Some(name.to_string()); - self.progress_value.resource_type = Some(resource_type.to_string()); + self.progress_value.resource_type = Some(resource_type.clone()); self.progress_value.result = None; self.progress_value.failure = None; } From bd6591bfa03665b80d09d1a2baf0f0336ba43334 Mon Sep 17 00:00:00 2001 From: Mikey Lombardi Date: Thu, 8 Jan 2026 12:01:14 -0600 Subject: [PATCH 08/11] (GH-538) Update `require_adapter` to use FQTN This change updates the `dscresources::dscresource::DscResource` struct to define the `require_adapter` field to use `FullyQualifiedTypeName` instead of `String` for consistency and correctness. This change also updates the `*adapter` methods for the type to use FQTN for the `adapter` parameters. --- lib/dsc-lib/src/dscresources/dscresource.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/dsc-lib/src/dscresources/dscresource.rs b/lib/dsc-lib/src/dscresources/dscresource.rs index 271c29d82..5555e262c 100644 --- a/lib/dsc-lib/src/dscresources/dscresource.rs +++ b/lib/dsc-lib/src/dscresources/dscresource.rs @@ -55,7 +55,7 @@ pub struct DscResource { pub properties: Vec, /// The required resource adapter for the resource. #[serde(rename="requireAdapter")] - pub require_adapter: Option, + pub require_adapter: Option, /// The target resource for the resource adapter. pub target_resource: Option, /// The manifest of the resource. @@ -114,7 +114,7 @@ impl DscResource { } } - fn create_config_for_adapter(self, adapter: &str, input: &str) -> Result { + fn create_config_for_adapter(self, adapter: &FullyQualifiedTypeName, input: &str) -> Result { // create new configuration with adapter and use this as the resource let mut configuration = Configuration::new(); let mut property_map = Map::new(); @@ -140,7 +140,7 @@ impl DscResource { Ok(configurator) } - fn invoke_get_with_adapter(&self, adapter: &str, resource_name: &str, filter: &str) -> Result { + fn invoke_get_with_adapter(&self, adapter: &FullyQualifiedTypeName, resource_name: &str, filter: &str) -> Result { let mut configurator = self.clone().create_config_for_adapter(adapter, filter)?; let mut adapter = Self::get_adapter_resource(&mut configurator, adapter)?; if get_adapter_input_kind(&adapter)? == AdapterInputKind::Single { @@ -164,7 +164,7 @@ impl DscResource { Ok(get_result) } - fn invoke_set_with_adapter(&self, adapter: &str, resource_name: &str, desired: &str, skip_test: bool, execution_type: &ExecutionKind) -> Result { + fn invoke_set_with_adapter(&self, adapter: &FullyQualifiedTypeName, resource_name: &str, desired: &str, skip_test: bool, execution_type: &ExecutionKind) -> Result { let mut configurator = self.clone().create_config_for_adapter(adapter, desired)?; let mut adapter = Self::get_adapter_resource(&mut configurator, adapter)?; if get_adapter_input_kind(&adapter)? == AdapterInputKind::Single { @@ -197,7 +197,7 @@ impl DscResource { Ok(set_result) } - fn invoke_test_with_adapter(&self, adapter: &str, resource_name: &str, expected: &str) -> Result { + fn invoke_test_with_adapter(&self, adapter: &FullyQualifiedTypeName, resource_name: &str, expected: &str) -> Result { let mut configurator = self.clone().create_config_for_adapter(adapter, expected)?; let mut adapter = Self::get_adapter_resource(&mut configurator, adapter)?; if get_adapter_input_kind(&adapter)? == AdapterInputKind::Single { @@ -231,7 +231,7 @@ impl DscResource { Ok(test_result) } - fn invoke_delete_with_adapter(&self, adapter: &str, resource_name: &str, filter: &str) -> Result<(), DscError> { + fn invoke_delete_with_adapter(&self, adapter: &FullyQualifiedTypeName, resource_name: &str, filter: &str) -> Result<(), DscError> { let mut configurator = self.clone().create_config_for_adapter(adapter, filter)?; let mut adapter = Self::get_adapter_resource(&mut configurator, adapter)?; if get_adapter_input_kind(&adapter)? == AdapterInputKind::Single { @@ -246,7 +246,7 @@ impl DscResource { Ok(()) } - fn invoke_export_with_adapter(&self, adapter: &str, input: &str) -> Result { + fn invoke_export_with_adapter(&self, adapter: &FullyQualifiedTypeName, input: &str) -> Result { let mut configurator = self.clone().create_config_for_adapter(adapter, input)?; let mut adapter = Self::get_adapter_resource(&mut configurator, adapter)?; if get_adapter_input_kind(&adapter)? == AdapterInputKind::Single { @@ -276,7 +276,7 @@ impl DscResource { Ok(export_result) } - fn get_adapter_resource(configurator: &mut Configurator, adapter: &str) -> Result { + fn get_adapter_resource(configurator: &mut Configurator, adapter: &FullyQualifiedTypeName) -> Result { if let Some(adapter_resource) = configurator.discovery().find_resource(adapter, None) { return Ok(adapter_resource.clone()); } From 6eec01f5a51f0450c4674f80a498633c565088fa Mon Sep 17 00:00:00 2001 From: Mikey Lombardi Date: Thu, 8 Jan 2026 14:17:54 -0600 Subject: [PATCH 09/11] (GH-538) Update `target_resource` to use FQTN This change updates the `target_resource` field of `DscResource` to use `FullyQualifiedTypeName` instead of `String`. It also updates related functions to expect that type instead of `&str`. --- dsc/src/mcp/list_dsc_resources.rs | 2 +- dsc/src/subcommand.rs | 2 +- .../src/dscresources/command_resource.rs | 38 +++++++++---------- lib/dsc-lib/src/dscresources/dscresource.rs | 28 +++++++------- tools/test_group_resource/src/main.rs | 4 +- 5 files changed, 37 insertions(+), 37 deletions(-) diff --git a/dsc/src/mcp/list_dsc_resources.rs b/dsc/src/mcp/list_dsc_resources.rs index c27d8aed2..2f15ea489 100644 --- a/dsc/src/mcp/list_dsc_resources.rs +++ b/dsc/src/mcp/list_dsc_resources.rs @@ -70,7 +70,7 @@ impl McpServer { r#type: resource.type_name.to_string(), kind: resource.kind.clone(), description: resource.description.clone(), - require_adapter: resource.require_adapter.clone(), + require_adapter: resource.require_adapter.map(|fqtn| fqtn.to_string()), }; resources.insert(resource.type_name.to_lowercase(), summary); } diff --git a/dsc/src/subcommand.rs b/dsc/src/subcommand.rs index 624b42194..800f02767 100644 --- a/dsc/src/subcommand.rs +++ b/dsc/src/subcommand.rs @@ -824,7 +824,7 @@ pub fn list_resources(dsc: &mut DscManager, resource_name: Option<&String>, adap format!("{:?}", resource.kind), resource.version, capabilities, - resource.require_adapter.unwrap_or_default(), + resource.require_adapter.unwrap_or_default().to_string(), resource.description.unwrap_or_default() ]); } diff --git a/lib/dsc-lib/src/dscresources/command_resource.rs b/lib/dsc-lib/src/dscresources/command_resource.rs index 6b5a1b468..082895ccf 100644 --- a/lib/dsc-lib/src/dscresources/command_resource.rs +++ b/lib/dsc-lib/src/dscresources/command_resource.rs @@ -7,7 +7,7 @@ use rust_i18n::t; use serde::Deserialize; use serde_json::{Map, Value}; use std::{collections::HashMap, env, path::Path, process::Stdio}; -use crate::{configure::{config_doc::ExecutionKind, config_result::{ResourceGetResult, ResourceTestResult}}, util::canonicalize_which}; +use crate::{configure::{config_doc::ExecutionKind, config_result::{ResourceGetResult, ResourceTestResult}}, types::FullyQualifiedTypeName, util::canonicalize_which}; use crate::dscerror::DscError; use super::{dscresource::{get_diff, redact}, invoke_result::{ExportResult, GetResult, ResolveResult, SetResult, TestResult, ValidateResult, ResourceGetResponse, ResourceSetResponse, ResourceTestResponse, get_in_desired_state}, resource_manifest::{ArgKind, InputKind, Kind, ResourceManifest, ReturnKind, SchemaKind}}; use tracing::{error, warn, info, debug, trace}; @@ -25,7 +25,7 @@ pub const EXIT_PROCESS_TERMINATED: i32 = 0x102; /// # Errors /// /// Error returned if the resource does not successfully get the current state -pub fn invoke_get(resource: &ResourceManifest, cwd: &Path, filter: &str, target_resource: Option<&str>) -> Result { +pub fn invoke_get(resource: &ResourceManifest, cwd: &Path, filter: &str, target_resource: Option) -> Result { debug!("{}", t!("dscresources.commandResource.invokeGet", resource = &resource.resource_type)); let mut command_input = CommandInput { env: None, stdin: None }; let Some(get) = &resource.get else { @@ -33,9 +33,9 @@ pub fn invoke_get(resource: &ResourceManifest, cwd: &Path, filter: &str, target_ }; let resource_type = match target_resource { Some(r) => r, - None => &resource.resource_type, + None => resource.resource_type.clone(), }; - let args = process_args(get.args.as_ref(), filter, resource_type); + let args = process_args(get.args.as_ref(), filter, &resource_type); if !filter.is_empty() { verify_json(resource, cwd, filter)?; command_input = get_command_input(get.input.as_ref(), filter)?; @@ -78,7 +78,7 @@ pub fn invoke_get(resource: &ResourceManifest, cwd: &Path, filter: &str, target_ /// /// Error returned if the resource does not successfully set the desired state #[allow(clippy::too_many_lines)] -pub fn invoke_set(resource: &ResourceManifest, cwd: &Path, desired: &str, skip_test: bool, execution_type: &ExecutionKind, target_resource: Option<&str>) -> Result { +pub fn invoke_set(resource: &ResourceManifest, cwd: &Path, desired: &str, skip_test: bool, execution_type: &ExecutionKind, target_resource: Option) -> Result { debug!("{}", t!("dscresources.commandResource.invokeSet", resource = &resource.resource_type)); let operation_type: String; let mut is_synthetic_what_if = false; @@ -105,7 +105,7 @@ pub fn invoke_set(resource: &ResourceManifest, cwd: &Path, desired: &str, skip_t // if resource doesn't implement a pre-test, we execute test first to see if a set is needed if !skip_test && set.pre_test != Some(true) { info!("{}", t!("dscresources.commandResource.noPretest", resource = &resource.resource_type)); - let test_result = invoke_test(resource, cwd, desired, target_resource)?; + let test_result = invoke_test(resource, cwd, desired, target_resource.clone())?; if is_synthetic_what_if { return Ok(test_result.into()); } @@ -140,11 +140,11 @@ pub fn invoke_set(resource: &ResourceManifest, cwd: &Path, desired: &str, skip_t let Some(get) = &resource.get else { return Err(DscError::NotImplemented("get".to_string())); }; - let resource_type = match target_resource { + let resource_type = match target_resource.clone() { Some(r) => r, - None => &resource.resource_type, + None => resource.resource_type.clone(), }; - let args = process_args(get.args.as_ref(), desired, resource_type); + let args = process_args(get.args.as_ref(), desired, &resource_type); let command_input = get_command_input(get.input.as_ref(), desired)?; info!("{}", t!("dscresources.commandResource.setGetCurrent", resource = &resource.resource_type, executable = &get.executable)); @@ -176,7 +176,7 @@ pub fn invoke_set(resource: &ResourceManifest, cwd: &Path, desired: &str, skip_t let mut env: Option> = None; let mut input_desired: Option<&str> = None; - let args = process_args(set.args.as_ref(), desired, resource_type); + let args = process_args(set.args.as_ref(), desired, &resource_type); match &set.input { Some(InputKind::Env) => { env = Some(json_to_hashmap(desired)?); @@ -271,7 +271,7 @@ pub fn invoke_set(resource: &ResourceManifest, cwd: &Path, desired: &str, skip_t /// # Errors /// /// Error is returned if the underlying command returns a non-zero exit code. -pub fn invoke_test(resource: &ResourceManifest, cwd: &Path, expected: &str, target_resource: Option<&str>) -> Result { +pub fn invoke_test(resource: &ResourceManifest, cwd: &Path, expected: &str, target_resource: Option) -> Result { debug!("{}", t!("dscresources.commandResource.invokeTest", resource = &resource.resource_type)); let Some(test) = &resource.test else { info!("{}", t!("dscresources.commandResource.testSyntheticTest", resource = &resource.resource_type)); @@ -280,11 +280,11 @@ pub fn invoke_test(resource: &ResourceManifest, cwd: &Path, expected: &str, targ verify_json(resource, cwd, expected)?; - let resource_type = match target_resource { + let resource_type = match target_resource.clone() { Some(r) => r, - None => &resource.resource_type, + None => resource.resource_type.clone(), }; - let args = process_args(test.args.as_ref(), expected, resource_type); + let args = process_args(test.args.as_ref(), expected, &resource_type); let command_input = get_command_input(test.input.as_ref(), expected)?; info!("{}", t!("dscresources.commandResource.invokeTestUsing", resource = &resource.resource_type, executable = &test.executable)); @@ -380,7 +380,7 @@ fn get_desired_state(actual: &Value) -> Result, DscError> { Ok(in_desired_state) } -fn invoke_synthetic_test(resource: &ResourceManifest, cwd: &Path, expected: &str, target_resource: Option<&str>) -> Result { +fn invoke_synthetic_test(resource: &ResourceManifest, cwd: &Path, expected: &str, target_resource: Option) -> Result { let get_result = invoke_get(resource, cwd, expected, target_resource)?; let actual_state = match get_result { GetResult::Group(results) => { @@ -511,7 +511,7 @@ pub fn get_schema(resource: &ResourceManifest, cwd: &Path) -> Result, target_resource: Option<&str>) -> Result { +pub fn invoke_export(resource: &ResourceManifest, cwd: &Path, input: Option<&str>, target_resource: Option) -> Result { let Some(export) = resource.export.as_ref() else { // see if get is supported and use that instead if resource.get.is_some() { @@ -540,7 +540,7 @@ pub fn invoke_export(resource: &ResourceManifest, cwd: &Path, input: Option<&str let args: Option>; let resource_type = match target_resource { Some(r) => r, - None => &resource.resource_type, + None => resource.resource_type.clone(), }; if let Some(input) = input { if !input.is_empty() { @@ -549,9 +549,9 @@ pub fn invoke_export(resource: &ResourceManifest, cwd: &Path, input: Option<&str command_input = get_command_input(export.input.as_ref(), input)?; } - args = process_args(export.args.as_ref(), input, resource_type); + args = process_args(export.args.as_ref(), input, &resource_type); } else { - args = process_args(export.args.as_ref(), "", resource_type); + args = process_args(export.args.as_ref(), "", &resource_type); } let (_exit_code, stdout, stderr) = invoke_command(&export.executable, args, command_input.stdin.as_deref(), Some(cwd), command_input.env, resource.exit_codes.as_ref())?; diff --git a/lib/dsc-lib/src/dscresources/dscresource.rs b/lib/dsc-lib/src/dscresources/dscresource.rs index 5555e262c..cf59133a5 100644 --- a/lib/dsc-lib/src/dscresources/dscresource.rs +++ b/lib/dsc-lib/src/dscresources/dscresource.rs @@ -57,7 +57,7 @@ pub struct DscResource { #[serde(rename="requireAdapter")] pub require_adapter: Option, /// The target resource for the resource adapter. - pub target_resource: Option, + pub target_resource: Option, /// The manifest of the resource. pub manifest: Option, } @@ -140,11 +140,11 @@ impl DscResource { Ok(configurator) } - fn invoke_get_with_adapter(&self, adapter: &FullyQualifiedTypeName, resource_name: &str, filter: &str) -> Result { + fn invoke_get_with_adapter(&self, adapter: &FullyQualifiedTypeName, resource_name: &FullyQualifiedTypeName, filter: &str) -> Result { let mut configurator = self.clone().create_config_for_adapter(adapter, filter)?; let mut adapter = Self::get_adapter_resource(&mut configurator, adapter)?; if get_adapter_input_kind(&adapter)? == AdapterInputKind::Single { - adapter.target_resource = Some(resource_name.to_string()); + adapter.target_resource = Some(resource_name.clone()); return adapter.get(filter); } @@ -164,11 +164,11 @@ impl DscResource { Ok(get_result) } - fn invoke_set_with_adapter(&self, adapter: &FullyQualifiedTypeName, resource_name: &str, desired: &str, skip_test: bool, execution_type: &ExecutionKind) -> Result { + fn invoke_set_with_adapter(&self, adapter: &FullyQualifiedTypeName, resource_name: &FullyQualifiedTypeName, desired: &str, skip_test: bool, execution_type: &ExecutionKind) -> Result { let mut configurator = self.clone().create_config_for_adapter(adapter, desired)?; let mut adapter = Self::get_adapter_resource(&mut configurator, adapter)?; if get_adapter_input_kind(&adapter)? == AdapterInputKind::Single { - adapter.target_resource = Some(resource_name.to_string()); + adapter.target_resource = Some(resource_name.clone()); return adapter.set(desired, skip_test, execution_type); } @@ -197,11 +197,11 @@ impl DscResource { Ok(set_result) } - fn invoke_test_with_adapter(&self, adapter: &FullyQualifiedTypeName, resource_name: &str, expected: &str) -> Result { + fn invoke_test_with_adapter(&self, adapter: &FullyQualifiedTypeName, resource_name: &FullyQualifiedTypeName, expected: &str) -> Result { let mut configurator = self.clone().create_config_for_adapter(adapter, expected)?; let mut adapter = Self::get_adapter_resource(&mut configurator, adapter)?; if get_adapter_input_kind(&adapter)? == AdapterInputKind::Single { - adapter.target_resource = Some(resource_name.to_string()); + adapter.target_resource = Some(resource_name.clone()); return adapter.test(expected); } @@ -231,12 +231,12 @@ impl DscResource { Ok(test_result) } - fn invoke_delete_with_adapter(&self, adapter: &FullyQualifiedTypeName, resource_name: &str, filter: &str) -> Result<(), DscError> { + fn invoke_delete_with_adapter(&self, adapter: &FullyQualifiedTypeName, resource_name: &FullyQualifiedTypeName, filter: &str) -> Result<(), DscError> { let mut configurator = self.clone().create_config_for_adapter(adapter, filter)?; let mut adapter = Self::get_adapter_resource(&mut configurator, adapter)?; if get_adapter_input_kind(&adapter)? == AdapterInputKind::Single { if adapter.capabilities.contains(&Capability::Delete) { - adapter.target_resource = Some(resource_name.to_string()); + adapter.target_resource = Some(resource_name.clone()); return adapter.delete(filter); } return Err(DscError::NotSupported(t!("dscresources.dscresource.adapterDoesNotSupportDelete", adapter = adapter.type_name).to_string())); @@ -250,7 +250,7 @@ impl DscResource { let mut configurator = self.clone().create_config_for_adapter(adapter, input)?; let mut adapter = Self::get_adapter_resource(&mut configurator, adapter)?; if get_adapter_input_kind(&adapter)? == AdapterInputKind::Single { - adapter.target_resource = Some(self.type_name.to_string()); + adapter.target_resource = Some(self.type_name.clone()); return adapter.export(input); } @@ -394,7 +394,7 @@ impl Invoke for DscResource { return Err(DscError::MissingManifest(self.type_name.to_string())); }; let resource_manifest = import_manifest(manifest.clone())?; - command_resource::invoke_get(&resource_manifest, &self.directory, filter, self.target_resource.as_deref()) + command_resource::invoke_get(&resource_manifest, &self.directory, filter, self.target_resource.clone()) }, } } @@ -414,7 +414,7 @@ impl Invoke for DscResource { return Err(DscError::MissingManifest(self.type_name.to_string())); }; let resource_manifest = import_manifest(manifest.clone())?; - command_resource::invoke_set(&resource_manifest, &self.directory, desired, skip_test, execution_type, self.target_resource.as_deref()) + command_resource::invoke_set(&resource_manifest, &self.directory, desired, skip_test, execution_type, self.target_resource.clone()) }, } } @@ -462,7 +462,7 @@ impl Invoke for DscResource { Ok(test_result) } else { - command_resource::invoke_test(&resource_manifest, &self.directory, expected, self.target_resource.as_deref()) + command_resource::invoke_test(&resource_manifest, &self.directory, expected, self.target_resource.clone()) } }, } @@ -538,7 +538,7 @@ impl Invoke for DscResource { return Err(DscError::MissingManifest(self.type_name.to_string())); }; let resource_manifest = import_manifest(manifest.clone())?; - command_resource::invoke_export(&resource_manifest, &self.directory, Some(input), self.target_resource.as_deref()) + command_resource::invoke_export(&resource_manifest, &self.directory, Some(input), self.target_resource.clone()) } fn resolve(&self, input: &str) -> Result { diff --git a/tools/test_group_resource/src/main.rs b/tools/test_group_resource/src/main.rs index 02a6c905c..0b6f2999e 100644 --- a/tools/test_group_resource/src/main.rs +++ b/tools/test_group_resource/src/main.rs @@ -25,7 +25,7 @@ fn main() { directory: PathBuf::from("test_directory"), author: Some("Microsoft".to_string()), properties: vec!["Property1".to_string(), "Property2".to_string()], - require_adapter: Some("Test/TestGroup".to_string()), + require_adapter: Some("Test/TestGroup".parse().unwrap()), target_resource: None, manifest: Some(serde_json::to_value(ResourceManifest { description: Some("This is a test resource.".to_string()), @@ -51,7 +51,7 @@ fn main() { directory: PathBuf::from("test_directory"), author: Some("Microsoft".to_string()), properties: vec!["Property1".to_string(), "Property2".to_string()], - require_adapter: Some("Test/TestGroup".to_string()), + require_adapter: Some("Test/TestGroup".parse().unwrap()), target_resource: None, manifest: Some(serde_json::to_value(ResourceManifest { description: Some("This is a test resource.".to_string()), From 3ac4a8cf35cd8de3ad8d63456bb21e75fd6afd5b Mon Sep 17 00:00:00 2001 From: Mikey Lombardi Date: Thu, 8 Jan 2026 15:40:38 -0600 Subject: [PATCH 10/11] (GH-538) Update MCP to use FQTN This change updates the types and functions in the MCP module to expect and accept the `FullyQualifiedTypeName` type instead of strings. --- dsc/src/mcp/invoke_dsc_resource.rs | 8 +++----- dsc/src/mcp/list_dsc_resources.rs | 10 +++++----- dsc/src/mcp/show_dsc_resource.rs | 6 +++--- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/dsc/src/mcp/invoke_dsc_resource.rs b/dsc/src/mcp/invoke_dsc_resource.rs index 9d46d6eab..cf90e9898 100644 --- a/dsc/src/mcp/invoke_dsc_resource.rs +++ b/dsc/src/mcp/invoke_dsc_resource.rs @@ -3,8 +3,7 @@ use crate::mcp::mcp_server::McpServer; use dsc_lib::{ - configure::config_doc::ExecutionKind, - dscresources::{ + DscManager, configure::config_doc::ExecutionKind, dscresources::{ dscresource::Invoke, invoke_result::{ ExportResult, @@ -12,8 +11,7 @@ use dsc_lib::{ SetResult, TestResult, }, - }, - DscManager, + }, types::FullyQualifiedTypeName }; use rmcp::{ErrorData as McpError, Json, tool, tool_router, handler::server::wrapper::Parameters}; use rust_i18n::t; @@ -51,7 +49,7 @@ pub struct InvokeDscResourceRequest { #[schemars(description = "The operation to perform on the DSC resource")] pub operation: DscOperation, #[schemars(description = "The type name of the DSC resource to invoke")] - pub resource_type: String, + pub resource_type: FullyQualifiedTypeName, #[schemars(description = "The properties to pass to the DSC resource as JSON. Must match the resource JSON schema from `show_dsc_resource` tool.")] pub properties_json: String, } diff --git a/dsc/src/mcp/list_dsc_resources.rs b/dsc/src/mcp/list_dsc_resources.rs index 2f15ea489..c25351c66 100644 --- a/dsc/src/mcp/list_dsc_resources.rs +++ b/dsc/src/mcp/list_dsc_resources.rs @@ -6,7 +6,7 @@ use dsc_lib::{ DscManager, discovery::{ command_discovery::ImportedManifest::Resource, discovery_trait::DiscoveryKind, - }, dscresources::resource_manifest::Kind, progress::ProgressFormat + }, dscresources::resource_manifest::Kind, progress::ProgressFormat, types::FullyQualifiedTypeName }; use rmcp::{ErrorData as McpError, Json, tool, tool_router, handler::server::wrapper::Parameters}; use rust_i18n::t; @@ -22,11 +22,11 @@ pub struct ResourceListResult { #[derive(Serialize, JsonSchema)] pub struct ResourceSummary { - pub r#type: String, + pub r#type: FullyQualifiedTypeName, pub kind: Kind, pub description: Option, #[serde(rename = "requireAdapter")] - pub require_adapter: Option, + pub require_adapter: Option, } #[derive(Deserialize, JsonSchema)] @@ -67,10 +67,10 @@ impl McpServer { for resource in dsc.list_available(&DiscoveryKind::Resource, "*", &adapter_filter, ProgressFormat::None) { if let Resource(resource) = resource { let summary = ResourceSummary { - r#type: resource.type_name.to_string(), + r#type: resource.type_name.clone(), kind: resource.kind.clone(), description: resource.description.clone(), - require_adapter: resource.require_adapter.map(|fqtn| fqtn.to_string()), + require_adapter: resource.require_adapter, }; resources.insert(resource.type_name.to_lowercase(), summary); } diff --git a/dsc/src/mcp/show_dsc_resource.rs b/dsc/src/mcp/show_dsc_resource.rs index ea7b89dd5..4a9d11606 100644 --- a/dsc/src/mcp/show_dsc_resource.rs +++ b/dsc/src/mcp/show_dsc_resource.rs @@ -7,7 +7,7 @@ use dsc_lib::{ dscresources::{ dscresource::{Capability, Invoke}, resource_manifest::Kind - }, + }, types::FullyQualifiedTypeName, }; use rmcp::{ErrorData as McpError, Json, tool, tool_router, handler::server::wrapper::Parameters}; use rust_i18n::t; @@ -20,7 +20,7 @@ use tokio::task; pub struct DscResource { /// The namespaced name of the resource. #[serde(rename="type")] - pub type_name: String, + pub type_name: FullyQualifiedTypeName, /// The kind of resource. pub kind: Kind, /// The version of the resource. @@ -66,7 +66,7 @@ impl McpServer { Err(_) => None, }; Ok(DscResource { - type_name: resource.type_name.to_string(), + type_name: resource.type_name.clone(), kind: resource.kind.clone(), version: resource.version.clone(), capabilities: resource.capabilities.clone(), From 2e083b81c86a15dbe11437131c5f8d7d5a342db5 Mon Sep 17 00:00:00 2001 From: "Mikey Lombardi (He/Him)" Date: Thu, 8 Jan 2026 16:40:40 -0600 Subject: [PATCH 11/11] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- lib/dsc-lib/locales/schemas.definitions.yaml | 4 ++-- lib/dsc-lib/src/extensions/discover.rs | 2 +- lib/dsc-lib/src/types/fully_qualified_type_name.rs | 2 +- .../tests/integration/types/fully_qualified_type_name.rs | 2 +- tools/test_group_resource/src/main.rs | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/dsc-lib/locales/schemas.definitions.yaml b/lib/dsc-lib/locales/schemas.definitions.yaml index 64f5e4808..2f50f02d6 100644 --- a/lib/dsc-lib/locales/schemas.definitions.yaml +++ b/lib/dsc-lib/locales/schemas.definitions.yaml @@ -15,11 +15,11 @@ schemas: [....]/ ``` - Where the type may have zero to three namespace segments for organizing the type. The + Where the type may have zero or more namespace segments for organizing the type. The `owner`, `namespace`, and `name` segments must consist only of alphanumeric characters and underscores. - Conventionally, the first character of each segment is capitalized. When a segments + Conventionally, the first character of each segment is capitalized. When a segment contains a brand or proper name, use the correct casing for that word, like `TailspinToys/Settings`, not `Tailspintoys/Settings`. diff --git a/lib/dsc-lib/src/extensions/discover.rs b/lib/dsc-lib/src/extensions/discover.rs index 055fc0bf4..5aff5e121 100644 --- a/lib/dsc-lib/src/extensions/discover.rs +++ b/lib/dsc-lib/src/extensions/discover.rs @@ -68,7 +68,7 @@ impl DscExtension { let Some(discover) = extension.discover else { return Err(DscError::UnsupportedCapability(self.type_name.to_string(), Capability::Discover.to_string())); }; - let args = process_args(discover.args.as_ref(), "", &self.type_name); + let args = process_args(discover.args.as_ref(), "", self.type_name.as_ref()); let (_exit_code, stdout, _stderr) = invoke_command( &discover.executable, args, diff --git a/lib/dsc-lib/src/types/fully_qualified_type_name.rs b/lib/dsc-lib/src/types/fully_qualified_type_name.rs index 4b7247b9c..8c19468e7 100644 --- a/lib/dsc-lib/src/types/fully_qualified_type_name.rs +++ b/lib/dsc-lib/src/types/fully_qualified_type_name.rs @@ -83,7 +83,7 @@ impl FullyQualifiedTypeName { } } - /// Creates a new instance of [`FullyQualifiedName`] from a string if the input is valid for the + /// Creates a new instance of [`FullyQualifiedTypeName`] from a string if the input is valid for the /// [`VALIDATING_PATTERN`]. If the string is invalid, the method raises the /// [`DscError::InvalidTypeName`] error. pub fn new(name: &str) -> Result { diff --git a/lib/dsc-lib/tests/integration/types/fully_qualified_type_name.rs b/lib/dsc-lib/tests/integration/types/fully_qualified_type_name.rs index 86252c1e9..961494d12 100644 --- a/lib/dsc-lib/tests/integration/types/fully_qualified_type_name.rs +++ b/lib/dsc-lib/tests/integration/types/fully_qualified_type_name.rs @@ -103,7 +103,7 @@ fn test_as_ref() { fn test_deref() { let name = "Owner/Name"; let instance = FullyQualifiedTypeName::new(name).unwrap(); - assert_eq!(*name, *instance) + assert_eq!(name, &*instance) } #[test] diff --git a/tools/test_group_resource/src/main.rs b/tools/test_group_resource/src/main.rs index 0b6f2999e..f685eab94 100644 --- a/tools/test_group_resource/src/main.rs +++ b/tools/test_group_resource/src/main.rs @@ -71,7 +71,7 @@ fn main() { }, SubCommand::ListMissingRequires => { let resource1 = DscResource { - type_name: "InvalidResource".parse().unwrap(), + type_name: "Test/InvalidResource".parse().unwrap(), kind: Kind::Resource, version: "1.0.0".to_string(), capabilities: vec![Capability::Get],