diff --git a/dsc/tests/dsc_metadata.tests.ps1 b/dsc/tests/dsc_metadata.tests.ps1 new file mode 100644 index 000000000..8f2ed5851 --- /dev/null +++ b/dsc/tests/dsc_metadata.tests.ps1 @@ -0,0 +1,74 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'metadata tests' { + It 'resource can provide metadata for ' -TestCases @( + @{ operation = 'get' } + @{ operation = 'set' } + @{ operation = 'test' } + ) { + param($operation) + + $configYaml = @' + $schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json + resources: + - name: test + type: Test/Metadata + properties: + _metadata: + hello: world + myNumber: 42 +'@ + + $out = dsc config $operation -i $configYaml 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.results.count | Should -Be 1 + $out.results[0].metadata.hello | Should -BeExactly 'world' + $out.results[0].metadata.myNumber | Should -Be 42 + } + + It 'resource can provide metadata for export' { + $configYaml = @' + $schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json + resources: + - name: test + type: Test/Metadata + properties: + _metadata: + hello: There + myNumber: 16 +'@ + $out = dsc config export -i $configYaml 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.resources.count | Should -Be 3 + $out.resources[0].metadata.hello | Should -BeExactly 'There' + $out.resources[0].metadata.myNumber | Should -Be 16 + $out.resources[0].name | Should -BeExactly 'Metadata example 1' + $out.resources[1].metadata.hello | Should -BeExactly 'There' + $out.resources[1].metadata.myNumber | Should -Be 16 + $out.resources[1].name | Should -BeExactly 'Metadata example 2' + $out.resources[2].metadata.hello | Should -BeExactly 'There' + $out.resources[2].metadata.myNumber | Should -Be 16 + $out.resources[2].name | Should -BeExactly 'Metadata example 3' + } + + It 'resource returning Microsoft.DSC metadata is ignored' { + $configYaml = @' + $schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json + resources: + - name: test + type: Test/Metadata + properties: + _metadata: + Microsoft.DSC: + hello: world + validOne: true +'@ + $out = dsc config get -i $configYaml 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.results.count | Should -Be 1 + $out.results[0].metadata.validOne | Should -BeTrue + $out.results[0].metadata.Microsoft.DSC | Should -BeNullOrEmpty + (Get-Content $TestDrive/error.log) | Should -BeLike "*WARN*Resource returned '_metadata' property 'Microsoft.DSC' which is ignored*" + } +} diff --git a/dsc_lib/locales/en-us.toml b/dsc_lib/locales/en-us.toml index 1fce24a12..7ceea2b6b 100644 --- a/dsc_lib/locales/en-us.toml +++ b/dsc_lib/locales/en-us.toml @@ -67,6 +67,8 @@ parameterNotObject = "Parameter '%{name}' is not an object" invokePropertyExpressions = "Invoke property expressions" invokeExpression = "Invoke property expression for %{name}: %{value}" propertyNotString = "Property '%{name}' with value '%{value}' is not a string" +metadataMicrosoftDscIgnored = "Resource returned '_metadata' property 'Microsoft.DSC' which is ignored" +metadataNotObject = "Resource returned '_metadata' property which is not an object" [discovery.commandDiscovery] couldNotReadSetting = "Could not read 'resourcePath' setting" diff --git a/dsc_lib/src/configure/config_doc.rs b/dsc_lib/src/configure/config_doc.rs index ffc85fc60..902662528 100644 --- a/dsc_lib/src/configure/config_doc.rs +++ b/dsc_lib/src/configure/config_doc.rs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +use chrono::{DateTime, Local}; use rust_i18n::t; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -58,6 +59,26 @@ pub struct MicrosoftDscMetadata { pub security_context: Option, } +impl MicrosoftDscMetadata { + /// Creates a new instance of `MicrosoftDscMetadata` with the duration + /// + /// # Arguments + /// + /// * `start` - The start time of the configuration operation + /// * `end` - The end time of the configuration operation + /// + /// # Returns + /// + /// A new instance of `MicrosoftDscMetadata` with the duration calculated from the start and end times. + #[must_use] + pub fn new_with_duration(start: &DateTime, end: &DateTime) -> Self { + Self { + duration: Some(end.signed_duration_since(*start).to_string()), + ..Default::default() + } + } +} + #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] pub struct Metadata { #[serde(rename = "Microsoft.DSC", skip_serializing_if = "Option::is_none")] diff --git a/dsc_lib/src/configure/mod.rs b/dsc_lib/src/configure/mod.rs index 50f175a30..eabc2600f 100644 --- a/dsc_lib/src/configure/mod.rs +++ b/dsc_lib/src/configure/mod.rs @@ -24,7 +24,7 @@ use security_context_lib::{SecurityContext, get_security_context}; use serde_json::{Map, Value}; use std::path::PathBuf; use std::collections::HashMap; -use tracing::{debug, info, trace}; +use tracing::{debug, info, trace, warn}; pub mod context; pub mod config_doc; pub mod config_result; @@ -75,27 +75,35 @@ pub fn add_resource_export_results_to_configuration(resource: &DscResource, conf } r.kind = kind.as_str().map(std::string::ToString::to_string); } + r.name = if let Some(name) = props.remove("_name") { + name.as_str() + .map(std::string::ToString::to_string) + .ok_or_else(|| DscError::Parser(t!("configure.mod.propertyNotString", name = "_name", value = name).to_string()))? + } else { + format!("{}-{i}", r.resource_type) + }; + let mut metadata = Metadata { + microsoft: None, + other: Map::new(), + }; if let Some(security_context) = props.remove("_securityContext") { let context: SecurityContextKind = serde_json::from_value(security_context)?; - let metadata = Metadata { - microsoft: Some( + metadata.microsoft = Some( MicrosoftDscMetadata { security_context: Some(context), ..Default::default() } - ), - other: Map::new(), - }; - r.metadata = Some(metadata); + ); } - r.name = if let Some(name) = props.remove("_name") { - name.as_str() - .map(std::string::ToString::to_string) - .ok_or_else(|| DscError::Parser(t!("configure.mod.propertyNotString", name = "_name", value = name).to_string()))? + r.properties = escape_property_values(&props)?; + let mut properties = serde_json::to_value(&r.properties)?; + get_metadata_from_result(&mut properties, &mut metadata)?; + r.properties = Some(properties.as_object().cloned().unwrap_or_default()); + r.metadata = if metadata.microsoft.is_some() || !metadata.other.is_empty() { + Some(metadata) } else { - format!("{}-{i}", r.resource_type) + None }; - r.properties = escape_property_values(&props)?; conf.resources.push(r); } @@ -217,6 +225,26 @@ fn check_security_context(metadata: Option<&Metadata>) -> Result<(), DscError> { Ok(()) } +fn get_metadata_from_result(result: &mut Value, metadata: &mut Metadata) -> Result<(), DscError> { + if let Some(metadata_value) = result.get("_metadata") { + if let Some(metadata_map) = metadata_value.as_object() { + for (key, value) in metadata_map { + if key.starts_with("Microsoft.DSC") { + warn!("{}", t!("configure.mod.metadataMicrosoftDscIgnored", key = key)); + continue; + } + metadata.other.insert(key.clone(), value.clone()); + } + } else { + return Err(DscError::Parser(t!("configure.mod.metadataNotObject", value = metadata_value).to_string())); + } + if let Some(value_map) = result.as_object_mut() { + value_map.remove("_metadata"); + } + } + Ok(()) +} + impl Configurator { /// Create a new `Configurator` instance. /// @@ -288,7 +316,7 @@ impl Configurator { let filter = add_metadata(&dsc_resource.kind, properties)?; trace!("filter: {filter}"); let start_datetime = chrono::Local::now(); - let get_result = match dsc_resource.get(&filter) { + let mut get_result = match dsc_resource.get(&filter) { Ok(result) => result, Err(e) => { progress.set_failure(get_failure_from_error(&e)); @@ -297,9 +325,17 @@ impl Configurator { }, }; let end_datetime = chrono::Local::now(); - match &get_result { - GetResult::Resource(resource_result) => { + let mut metadata = Metadata { + microsoft: Some( + MicrosoftDscMetadata::new_with_duration(&start_datetime, &end_datetime) + ), + other: Map::new(), + }; + + match &mut get_result { + GetResult::Resource(ref mut resource_result) => { self.context.references.insert(format!("{}:{}", resource.resource_type, resource.name), serde_json::to_value(&resource_result.actual_state)?); + get_metadata_from_result(&mut resource_result.actual_state, &mut metadata)?; }, GetResult::Group(group) => { let mut results = Vec::::new(); @@ -310,17 +346,7 @@ impl Configurator { }, } let resource_result = config_result::ResourceGetResult { - metadata: Some( - Metadata { - microsoft: Some( - MicrosoftDscMetadata { - duration: Some(end_datetime.signed_duration_since(start_datetime).to_string()), - ..Default::default() - } - ), - other: Map::new(), - } - ), + metadata: Some(metadata), name: resource.name.clone(), resource_type: resource.resource_type.clone(), result: get_result.clone(), @@ -383,7 +409,7 @@ impl Configurator { let start_datetime; let end_datetime; - let set_result; + let mut set_result; if exist || dsc_resource.capabilities.contains(&Capability::SetHandlesExist) { debug!("{}", t!("configure.mod.handlesExist")); start_datetime = chrono::Local::now(); @@ -453,9 +479,16 @@ impl Configurator { return Err(DscError::NotImplemented(t!("configure.mod.deleteNotSupported", resource = resource.resource_type).to_string())); } - match &set_result { + let mut metadata = Metadata { + microsoft: Some( + MicrosoftDscMetadata::new_with_duration(&start_datetime, &end_datetime) + ), + other: Map::new(), + }; + match &mut set_result { SetResult::Resource(resource_result) => { self.context.references.insert(format!("{}:{}", resource.resource_type, resource.name), serde_json::to_value(&resource_result.after_state)?); + get_metadata_from_result(&mut resource_result.after_state, &mut metadata)?; }, SetResult::Group(group) => { let mut results = Vec::::new(); @@ -466,17 +499,7 @@ impl Configurator { }, } let resource_result = config_result::ResourceSetResult { - metadata: Some( - Metadata { - microsoft: Some( - MicrosoftDscMetadata { - duration: Some(end_datetime.signed_duration_since(start_datetime).to_string()), - ..Default::default() - } - ), - other: Map::new(), - } - ), + metadata: Some(metadata), name: resource.name.clone(), resource_type: resource.resource_type.clone(), result: set_result.clone(), @@ -517,7 +540,7 @@ impl Configurator { let expected = add_metadata(&dsc_resource.kind, properties)?; trace!("{}", t!("configure.mod.expectedState", state = expected)); let start_datetime = chrono::Local::now(); - let test_result = match dsc_resource.test(&expected) { + let mut test_result = match dsc_resource.test(&expected) { Ok(result) => result, Err(e) => { progress.set_failure(get_failure_from_error(&e)); @@ -526,9 +549,16 @@ impl Configurator { }, }; let end_datetime = chrono::Local::now(); - match &test_result { + let mut metadata = Metadata { + microsoft: Some( + MicrosoftDscMetadata::new_with_duration(&start_datetime, &end_datetime) + ), + other: Map::new(), + }; + match &mut test_result { TestResult::Resource(resource_test_result) => { self.context.references.insert(format!("{}:{}", resource.resource_type, resource.name), serde_json::to_value(&resource_test_result.actual_state)?); + get_metadata_from_result(&mut resource_test_result.actual_state, &mut metadata)?; }, TestResult::Group(group) => { let mut results = Vec::::new(); @@ -539,17 +569,7 @@ impl Configurator { }, } let resource_result = config_result::ResourceTestResult { - metadata: Some( - Metadata { - microsoft: Some( - MicrosoftDscMetadata { - duration: Some(end_datetime.signed_duration_since(start_datetime).to_string()), - ..Default::default() - } - ), - other: Map::new(), - } - ), + metadata: Some(metadata), name: resource.name.clone(), resource_type: resource.resource_type.clone(), result: test_result.clone(), diff --git a/tools/dsctest/metadata.dsc.resource.json b/tools/dsctest/metadata.dsc.resource.json new file mode 100644 index 000000000..5e5f1b3f2 --- /dev/null +++ b/tools/dsctest/metadata.dsc.resource.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2024/04/bundled/resource/manifest.json", + "type": "Test/Metadata", + "version": "0.1.0", + "get": { + "executable": "dsctest", + "args": [ + "metadata", + { + "jsonInputArg": "--input", + "mandatory": true + } + ] + }, + "set": { + "executable": "dsctest", + "args": [ + "metadata", + { + "jsonInputArg": "--input", + "mandatory": true + } + ], + "return": "state" + }, + "test": { + "executable": "dsctest", + "args": [ + "metadata", + { + "jsonInputArg": "--input", + "mandatory": true + } + ] + }, + "export": { + "executable": "dsctest", + "args": [ + "metadata", + { + "jsonInputArg": "--input", + "mandatory": true + }, + "--export" + ] + }, + "schema": { + "command": { + "executable": "dsctest", + "args": [ + "schema", + "-s", + "metadata" + ] + } + } +} diff --git a/tools/dsctest/src/args.rs b/tools/dsctest/src/args.rs index 0e92764d9..028d2cdc5 100644 --- a/tools/dsctest/src/args.rs +++ b/tools/dsctest/src/args.rs @@ -8,9 +8,10 @@ pub enum Schemas { Delete, Exist, ExitCode, - InDesiredState, Export, Exporter, + InDesiredState, + Metadata, Sleep, Trace, WhatIf, @@ -44,11 +45,6 @@ pub enum SubCommand { input: String, }, - #[clap(name = "in-desired-state", about = "Specify if the resource is in the desired state")] - InDesiredState { - #[clap(name = "input", short, long, help = "The input to the in desired state command as JSON")] - input: String, - }, #[clap(name = "export", about = "Export instances")] Export { #[clap(name = "input", short, long, help = "The input to the export command as JSON")] @@ -61,6 +57,20 @@ pub enum SubCommand { input: String, }, + #[clap(name = "in-desired-state", about = "Specify if the resource is in the desired state")] + InDesiredState { + #[clap(name = "input", short, long, help = "The input to the in desired state command as JSON")] + input: String, + }, + + #[clap(name = "metadata", about = "Return the metadata")] + Metadata { + #[clap(name = "input", short, long, help = "The input to the metadata command as JSON")] + input: String, + #[clap(name = "export", short, long, help = "Use export operation")] + export: bool, + }, + #[clap(name = "schema", about = "Get the JSON schema for a subcommand")] Schema { #[clap(name = "subcommand", short, long, help = "The subcommand to get the schema for")] diff --git a/tools/dsctest/src/main.rs b/tools/dsctest/src/main.rs index 3be103a7e..2d1777a8f 100644 --- a/tools/dsctest/src/main.rs +++ b/tools/dsctest/src/main.rs @@ -5,9 +5,10 @@ mod args; mod delete; mod exist; mod exit_code; -mod in_desired_state; mod export; mod exporter; +mod in_desired_state; +mod metadata; mod sleep; mod trace; mod whatif; @@ -19,9 +20,10 @@ use serde_json::Map; use crate::delete::Delete; use crate::exist::{Exist, State}; use crate::exit_code::ExitCode; -use crate::in_desired_state::InDesiredState; use crate::export::Export; use crate::exporter::{Exporter, Resource}; +use crate::in_desired_state::InDesiredState; +use crate::metadata::Metadata; use crate::sleep::Sleep; use crate::trace::Trace; use crate::whatif::WhatIf; @@ -72,18 +74,6 @@ fn main() { } input }, - SubCommand::InDesiredState { input } => { - let mut in_desired_state = match serde_json::from_str::(&input) { - Ok(in_desired_state) => in_desired_state, - Err(err) => { - eprintln!("Error JSON does not match schema: {err}"); - std::process::exit(1); - } - }; - in_desired_state.value_one = 1; - in_desired_state.value_two = 2; - serde_json::to_string(&in_desired_state).unwrap() - }, SubCommand::Export { input } => { let export = match serde_json::from_str::(&input) { Ok(export) => export, @@ -123,6 +113,38 @@ fn main() { } String::new() }, + SubCommand::InDesiredState { input } => { + let mut in_desired_state = match serde_json::from_str::(&input) { + Ok(in_desired_state) => in_desired_state, + Err(err) => { + eprintln!("Error JSON does not match schema: {err}"); + std::process::exit(1); + } + }; + in_desired_state.value_one = 1; + in_desired_state.value_two = 2; + serde_json::to_string(&in_desired_state).unwrap() + }, + SubCommand::Metadata { input, export } => { + let count = if export { + 3 + } else { + 1 + }; + for i in 0..count { + let mut metadata = match serde_json::from_str::(&input) { + Ok(metadata) => metadata, + Err(err) => { + eprintln!("Error JSON does not match schema: {err}"); + std::process::exit(1); + } + }; + metadata.name = Some(format!("Metadata example {}", i+1)); + metadata.count = Some(i + 1); + println!("{}", serde_json::to_string(&metadata).unwrap()); + } + String::new() + }, SubCommand::Schema { subcommand } => { let schema = match subcommand { Schemas::Delete => { @@ -134,15 +156,18 @@ fn main() { Schemas::ExitCode => { schema_for!(ExitCode) }, - Schemas::InDesiredState => { - schema_for!(InDesiredState) - }, Schemas::Export => { schema_for!(Export) }, Schemas::Exporter => { schema_for!(Exporter) }, + Schemas::InDesiredState => { + schema_for!(InDesiredState) + }, + Schemas::Metadata => { + schema_for!(Metadata) + }, Schemas::Sleep => { schema_for!(Sleep) }, diff --git a/tools/dsctest/src/metadata.rs b/tools/dsctest/src/metadata.rs new file mode 100644 index 000000000..a10ceeab7 --- /dev/null +++ b/tools/dsctest/src/metadata.rs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[allow(clippy::struct_field_names)] +pub struct Metadata { + #[serde(rename="_metadata", skip_serializing_if = "Option::is_none")] + pub metadata: Option>, + #[serde(rename="_name", skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub count: Option, +}