From 7235d302d27310b5a966c95eb2c2b37d34dc3cc5 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Tue, 23 Jun 2026 12:55:23 -0700 Subject: [PATCH 1/5] Add `restartRequired()` function --- lib/dsc-lib/locales/en-us.toml | 7 ++ lib/dsc-lib/src/functions/mod.rs | 1 + lib/dsc-lib/src/functions/restart_required.rs | 99 +++++++++++++++++++ 3 files changed, 107 insertions(+) create mode 100644 lib/dsc-lib/src/functions/restart_required.rs diff --git a/lib/dsc-lib/locales/en-us.toml b/lib/dsc-lib/locales/en-us.toml index 7f4b87f69..5ef3aa175 100644 --- a/lib/dsc-lib/locales/en-us.toml +++ b/lib/dsc-lib/locales/en-us.toml @@ -657,6 +657,13 @@ description = "Constructs a resource ID from the given type and name" syntax = "resourceId( , )" incorrectTypeFormat = "Type argument must contain exactly one slash" +[functions.restartRequired] +description = "Determines if a restart is required. The `name` argument is required for process and service, but not provided for system." +syntax = "restartRequired( , [name] )" +invalidKind = "Invalid kind '%{kind}', must be one of: process, service, system" +nameRequired = "The 'name' argument is required for kind '%{kind}'" +nameNotAllowed = "The 'name' argument is not allowed for kind '%{kind}'" + [functions.secret] description = "Retrieves a secret from a vault" syntax = "secret( , [vault] )" diff --git a/lib/dsc-lib/src/functions/mod.rs b/lib/dsc-lib/src/functions/mod.rs index b361d46f9..d0b583466 100644 --- a/lib/dsc-lib/src/functions/mod.rs +++ b/lib/dsc-lib/src/functions/mod.rs @@ -71,6 +71,7 @@ pub mod path; pub mod range; pub mod reference; pub mod resource_id; +pub mod restart_required; pub mod secret; pub mod shallow_merge; pub mod skip; diff --git a/lib/dsc-lib/src/functions/restart_required.rs b/lib/dsc-lib/src/functions/restart_required.rs new file mode 100644 index 000000000..6e9a001ae --- /dev/null +++ b/lib/dsc-lib/src/functions/restart_required.rs @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::DscError; +use crate::configure::context::Context; +use crate::functions::{FunctionArgKind, Function, FunctionCategory, FunctionMetadata}; +use crate::util::resource_id; +use rust_i18n::t; +use serde_json::Value; + +#[derive(Debug, Default)] +pub struct RestartRequired {} + +#[derive(Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum RestartKind { + Process, + Service, + System, +} + +impl Function for RestartRequired { + fn get_metadata(&self) -> FunctionMetadata { + FunctionMetadata { + name: "restartRequired".to_string(), + description: t!("functions.restartRequired.description").to_string(), + syntax: t!("functions.restartRequired.syntax").to_string(), + category: vec![FunctionCategory::System], + min_args: 1, + max_args: 2, + accepted_arg_ordered_types: vec![ + vec![FunctionArgKind::String], + vec![FunctionArgKind::String], + ], + remaining_arg_accepted_types: None, + return_types: vec![FunctionArgKind::Boolean], + } + } + + fn invoke(&self, args: &[Value], context: &Context) -> Result { + let kind: RestartKind = serde_json::from_value(args[0].clone()) + .map_err(|_| DscError::FunctionArgumentError { + function_name: self.get_metadata().name, + message: t!("functions.restartRequired.invalidKind", kind = args[0]).to_string(), + })?; + + let name = if args.len() > 1 { + Some( + args[1].as_str()?.to_string() + ) + } else { + None + }; + + let restart_required = match kind { + RestartKind::Process => { + if name.is_none() { + return Err(DscError::FunctionArgumentError { + function_name: self.get_metadata().name, + message: t!("functions.restartRequired.nameRequired").to_string(), + }); + } + context + .restart_required + .as_ref() + .map(|rr| rr.iter().any(|r| r.kind == "process" && r.name == name)) + .unwrap_or(false) + }, + RestartKind::Service => { + if name.is_none() { + return Err(DscError::FunctionArgumentError { + function_name: self.get_metadata().name, + message: t!("functions.restartRequired.nameRequired").to_string(), + }); + } + context + .restart_required + .as_ref() + .map(|rr| rr.iter().any(|r| r.kind == "service" && r.name == name)) + .unwrap_or(false) + }, + RestartKind::System => { + if name.is_some() { + return Err(DscError::FunctionArgumentError { + function_name: self.get_metadata().name, + message: t!("functions.restartRequired.nameNotAllowed").to_string(), + }); + } + context + .restart_required + .as_ref() + .map(|rr| rr.iter().any(|r| r.kind == "system")) + .unwrap_or(false) + }, + }; + + Ok(Value::Bool(restart_required)) + } +} From bfc53017bfc585bfd7aaddc5b6ded00d2ba3d078 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Mon, 6 Jul 2026 08:18:20 -0700 Subject: [PATCH 2/5] Add `restartRequired()` and `stateChanged()` functions --- dsc/tests/dsc_restartRequired.tests.ps1 | 113 ++++++++++++++++++ lib/dsc-lib/locales/en-us.toml | 6 + lib/dsc-lib/src/configure/context.rs | 2 + lib/dsc-lib/src/configure/mod.rs | 3 +- lib/dsc-lib/src/dscresources/invoke_result.rs | 19 +++ lib/dsc-lib/src/functions/mod.rs | 3 + lib/dsc-lib/src/functions/restart_required.rs | 69 ++++++----- lib/dsc-lib/src/functions/state_changed.rs | 47 ++++++++ 8 files changed, 226 insertions(+), 36 deletions(-) create mode 100644 lib/dsc-lib/src/functions/state_changed.rs diff --git a/dsc/tests/dsc_restartRequired.tests.ps1 b/dsc/tests/dsc_restartRequired.tests.ps1 index 20e196bd1..64f750f54 100644 --- a/dsc/tests/dsc_restartRequired.tests.ps1 +++ b/dsc/tests/dsc_restartRequired.tests.ps1 @@ -27,6 +27,16 @@ Describe '_restartRequired tests' { - process: name: anotherProcess id: 5678 + outputs: + system: + type: bool + value: "[restartRequired('system')]" + service: + type: bool + value: "[restartRequired('service', 'sshd')]" + process: + type: bool + value: "[restartRequired('process', 'myProcess')]" '@ $out = dsc -l trace config get -i $configYaml 2>$TestDrive/error.log | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw) @@ -49,6 +59,9 @@ Describe '_restartRequired tests' { $out.executionInformation.restartRequired[3].process.id | Should -Be 1234 $out.executionInformation.restartRequired[4].process.name | Should -BeExactly 'anotherProcess' $out.executionInformation.restartRequired[4].process.id | Should -Be 5678 + $out.outputs.system | Should -Be $true -Because ($out | ConvertTo-Json -Depth 10) + $out.outputs.service | Should -Be $true -Because ($out | ConvertTo-Json -Depth 10) + $out.outputs.process | Should -Be $true -Because ($out | ConvertTo-Json -Depth 10) } It 'invalid item in _restartRequired metadata is a warning' { @@ -67,4 +80,104 @@ Describe '_restartRequired tests' { $out.results[0].executionInformation.restartRequired | Should -BeNullOrEmpty $out.executionInformation.restartRequired | Should -BeNullOrEmpty } + + It 'restartRequired function returns false for unknown resource: ' -TestCases @( + @{ type = 'system' } + @{ type = 'service'; name = ", 'unknown'" } + @{ type = 'process'; name = ", 'unknown'" } + ){ + param($type, $name) + + $configYaml = @" + `$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json + resources: + - name: test + type: Test/RestartRequired + properties: + _restartRequired: + - service: myService + - process: + name: myProcess + id: 1234 + outputs: + unknown: + type: bool + value: "[restartRequired('$type'$name)]" +"@ + $out = dsc config get -i $configYaml 2>$TestDrive/error.log | ConvertFrom-Json + $errorContent = Get-Content $TestDrive/error.log -Raw + $LASTEXITCODE | Should -Be 0 -Because $errorContent + $out.outputs.unknown | Should -Be $false -Because ($out | ConvertTo-Json -Depth 10) + } + + It 'restartRequired function returns error if name not specified for: ' -TestCases @( + @{ type = 'service' } + @{ type = 'process' } + ){ + param($type) + + $configYaml = @" + `$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json + resources: + - name: test + type: Test/RestartRequired + properties: + _restartRequired: + - service: myService + - process: + name: myProcess + id: 1234 + outputs: + unknown: + type: bool + value: "[restartRequired('$type')]" +"@ + $null = dsc config get -i $configYaml 2>$TestDrive/error.log | ConvertFrom-Json + $errorContent = Get-Content $TestDrive/error.log -Raw + $LASTEXITCODE | Should -Be 2 -Because $errorContent + $errorContent | Should -BeLike "*ERROR*The 'name' argument is required for kind '$type'*" -Because $errorContent + } + + It 'restartRequired function returns error if invalid kind specified' { + $configYaml = @" + `$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json + resources: + - name: test + type: Test/RestartRequired + properties: + _restartRequired: + - service: myService + - process: + name: myProcess + id: 1234 + outputs: + unknown: + type: bool + value: "[restartRequired('invalidKind')]" +"@ + $null = dsc config get -i $configYaml 2>$TestDrive/error.log | ConvertFrom-Json + $errorContent = Get-Content $TestDrive/error.log -Raw + $LASTEXITCODE | Should -Be 2 -Because $errorContent + $errorContent | Should -BeLike "*ERROR*Invalid kind 'invalidKind', must be one of: process, service, system*" -Because $errorContent + } + + It 'restartRequired function returns an error if name used with system kind' { + $configYaml = @" + `$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json + resources: + - name: test + type: Test/RestartRequired + properties: + _restartRequired: + - system: mySystem + outputs: + unknown: + type: bool + value: "[restartRequired('system', 'nameNotAllowed')]" +"@ + $null = dsc config get -i $configYaml 2>$TestDrive/error.log | ConvertFrom-Json + $errorContent = Get-Content $TestDrive/error.log -Raw + $LASTEXITCODE | Should -Be 2 -Because $errorContent + $errorContent | Should -BeLike "*ERROR*The 'name' argument is not allowed for kind 'system'*" -Because $errorContent + } } diff --git a/lib/dsc-lib/locales/en-us.toml b/lib/dsc-lib/locales/en-us.toml index 5ef3aa175..29470319f 100644 --- a/lib/dsc-lib/locales/en-us.toml +++ b/lib/dsc-lib/locales/en-us.toml @@ -660,6 +660,7 @@ incorrectTypeFormat = "Type argument must contain exactly one slash" [functions.restartRequired] description = "Determines if a restart is required. The `name` argument is required for process and service, but not provided for system." syntax = "restartRequired( , [name] )" +constraints = "The `name` argument is required for process and service, but not allowed for system" invalidKind = "Invalid kind '%{kind}', must be one of: process, service, system" nameRequired = "The 'name' argument is required for kind '%{kind}'" nameNotAllowed = "The 'name' argument is not allowed for kind '%{kind}'" @@ -692,6 +693,11 @@ description = "Checks if a string starts with a specific prefix" invoked = "startsWith function" syntax = "startsWith( , )" +[functions.stateChanged] +description = "Returns true if the state of the resource has changed since the last execution" +syntax = "stateChanged( )" +noStateChangeInformation = "No state change information available for resourceId '%{name}' as it has not executed yet" + [functions.stdout] description = "Returns the standard output from the last executed resource." syntax = "stdout()" diff --git a/lib/dsc-lib/src/configure/context.rs b/lib/dsc-lib/src/configure/context.rs index 5517fcc93..1f66f5fb4 100644 --- a/lib/dsc-lib/src/configure/context.rs +++ b/lib/dsc-lib/src/configure/context.rs @@ -39,6 +39,7 @@ pub struct Context { pub restart_required: Option>, pub security_context: SecurityContextKind, pub start_datetime: DateTime, + pub state_changed: HashMap, pub stdout: Option, pub system_root: PathBuf, pub user_functions: HashMap, @@ -70,6 +71,7 @@ impl Context { SecurityContext::User => SecurityContextKind::Restricted, }, start_datetime: chrono::Local::now(), + state_changed: HashMap::new(), stdout: None, system_root: get_default_os_system_root(), user_functions: HashMap::new(), diff --git a/lib/dsc-lib/src/configure/mod.rs b/lib/dsc-lib/src/configure/mod.rs index f4968ccb2..80d80513f 100644 --- a/lib/dsc-lib/src/configure/mod.rs +++ b/lib/dsc-lib/src/configure/mod.rs @@ -767,11 +767,12 @@ impl Configurator { let resource_result = config_result::ResourceSetResult { execution_information: Some(execution_information), metadata: Some(metadata), - name: evaluated_name, + name: evaluated_name.clone(), resource_type: resource.resource_type.clone(), result: set_result.clone(), }; result.results.push(resource_result); + self.context.state_changed.insert(resource_id(&resource.resource_type, &evaluated_name), set_result.is_changed()); progress.set_result(&serde_json::to_value(set_result)?); progress.write_increment(1); } diff --git a/lib/dsc-lib/src/dscresources/invoke_result.rs b/lib/dsc-lib/src/dscresources/invoke_result.rs index fc65b150e..7d9f61b1d 100644 --- a/lib/dsc-lib/src/dscresources/invoke_result.rs +++ b/lib/dsc-lib/src/dscresources/invoke_result.rs @@ -73,6 +73,25 @@ impl From for SetResult { } } +impl SetResult { + #[must_use] + pub fn is_changed(&self) -> bool { + match self { + SetResult::Resource(resource_set_result) => { + resource_set_result.changed_properties.is_some() + }, + SetResult::Group(group_set_result) => { + for result in group_set_result { + if result.result.is_changed() { + return true; + } + } + false + } + } + } +} + #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, DscRepoSchema)] #[serde(deny_unknown_fields)] #[dsc_repo_schema(base_name = "set.simple", folder_path = "outputs/resource")] diff --git a/lib/dsc-lib/src/functions/mod.rs b/lib/dsc-lib/src/functions/mod.rs index d0b583466..344c421e2 100644 --- a/lib/dsc-lib/src/functions/mod.rs +++ b/lib/dsc-lib/src/functions/mod.rs @@ -76,6 +76,7 @@ pub mod secret; pub mod shallow_merge; pub mod skip; pub mod starts_with; +pub mod state_changed; pub mod stdout; pub mod string; pub mod take; @@ -222,10 +223,12 @@ impl FunctionDispatcher { Box::new(range::Range{}), Box::new(reference::Reference{}), Box::new(resource_id::ResourceId{}), + Box::new(restart_required::RestartRequired{}), Box::new(secret::Secret{}), Box::new(shallow_merge::ShallowMerge{}), Box::new(skip::Skip{}), Box::new(starts_with::StartsWith{}), + Box::new(state_changed::StateChanged{}), Box::new(stdout::Stdout{}), Box::new(string::StringFn{}), Box::new(sub::Sub{}), diff --git a/lib/dsc-lib/src/functions/restart_required.rs b/lib/dsc-lib/src/functions/restart_required.rs index 6e9a001ae..d1627aa39 100644 --- a/lib/dsc-lib/src/functions/restart_required.rs +++ b/lib/dsc-lib/src/functions/restart_required.rs @@ -2,10 +2,11 @@ // Licensed under the MIT License. use crate::DscError; +use crate::configure::config_doc::RestartRequired as RestartRequiredKind; use crate::configure::context::Context; use crate::functions::{FunctionArgKind, Function, FunctionCategory, FunctionMetadata}; -use crate::util::resource_id; use rust_i18n::t; +use serde::Deserialize; use serde_json::Value; #[derive(Debug, Default)] @@ -25,6 +26,7 @@ impl Function for RestartRequired { name: "restartRequired".to_string(), description: t!("functions.restartRequired.description").to_string(), syntax: t!("functions.restartRequired.syntax").to_string(), + constraints: Some(t!("functions.restartRequired.constraints").to_string()), category: vec![FunctionCategory::System], min_args: 1, max_args: 2, @@ -39,14 +41,11 @@ impl Function for RestartRequired { fn invoke(&self, args: &[Value], context: &Context) -> Result { let kind: RestartKind = serde_json::from_value(args[0].clone()) - .map_err(|_| DscError::FunctionArgumentError { - function_name: self.get_metadata().name, - message: t!("functions.restartRequired.invalidKind", kind = args[0]).to_string(), - })?; + .map_err(|_| DscError::Parser(t!("functions.restartRequired.invalidKind", kind = args[0].as_str().unwrap_or("unknown")).to_string()))?; let name = if args.len() > 1 { Some( - args[1].as_str()?.to_string() + args[1].as_str().unwrap().to_string() ) } else { None @@ -54,43 +53,43 @@ impl Function for RestartRequired { let restart_required = match kind { RestartKind::Process => { - if name.is_none() { - return Err(DscError::FunctionArgumentError { - function_name: self.get_metadata().name, - message: t!("functions.restartRequired.nameRequired").to_string(), - }); + if let Some(name) = &name { + for restart_required in context.restart_required.as_ref().unwrap_or(&vec![]) { + if let RestartRequiredKind::Process(p) = restart_required { + if p.name == *name { + return Ok(Value::Bool(true)); + } + } + } + false + } else { + return Err(DscError::Parser(t!("functions.restartRequired.nameRequired", kind = "process").to_string())); } - context - .restart_required - .as_ref() - .map(|rr| rr.iter().any(|r| r.kind == "process" && r.name == name)) - .unwrap_or(false) }, RestartKind::Service => { - if name.is_none() { - return Err(DscError::FunctionArgumentError { - function_name: self.get_metadata().name, - message: t!("functions.restartRequired.nameRequired").to_string(), - }); + if let Some(name) = &name { + for restart_required in context.restart_required.as_ref().unwrap_or(&vec![]) { + if let RestartRequiredKind::Service(service_name) = restart_required { + if service_name == name { + return Ok(Value::Bool(true)); + } + } + } + false + } else { + return Err(DscError::Parser(t!("functions.restartRequired.nameRequired", kind = "service").to_string())); } - context - .restart_required - .as_ref() - .map(|rr| rr.iter().any(|r| r.kind == "service" && r.name == name)) - .unwrap_or(false) }, RestartKind::System => { if name.is_some() { - return Err(DscError::FunctionArgumentError { - function_name: self.get_metadata().name, - message: t!("functions.restartRequired.nameNotAllowed").to_string(), - }); + return Err(DscError::Parser(t!("functions.restartRequired.nameNotAllowed", kind = "system").to_string())); } - context - .restart_required - .as_ref() - .map(|rr| rr.iter().any(|r| r.kind == "system")) - .unwrap_or(false) + for restart_required in context.restart_required.as_ref().unwrap_or(&vec![]) { + if let RestartRequiredKind::System(_) = restart_required { + return Ok(Value::Bool(true)); + } + } + false }, }; diff --git a/lib/dsc-lib/src/functions/state_changed.rs b/lib/dsc-lib/src/functions/state_changed.rs new file mode 100644 index 000000000..6334896ae --- /dev/null +++ b/lib/dsc-lib/src/functions/state_changed.rs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::DscError; +use crate::configure::context::Context; +use crate::functions::{FunctionArgKind, Function, FunctionCategory, FunctionMetadata}; +use rust_i18n::t; +use serde::Deserialize; +use serde_json::Value; + +#[derive(Debug, Default)] +pub struct StateChanged {} + +#[derive(Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum RestartKind { + Process, + Service, + System, +} + +impl Function for StateChanged { + fn get_metadata(&self) -> FunctionMetadata { + FunctionMetadata { + name: "stateChanged".to_string(), + description: t!("functions.stateChanged.description").to_string(), + syntax: t!("functions.stateChanged.syntax").to_string(), + constraints: None, + category: vec![FunctionCategory::System], + min_args: 1, + max_args: 1, + accepted_arg_ordered_types: vec![ + vec![FunctionArgKind::String], + ], + remaining_arg_accepted_types: None, + return_types: vec![FunctionArgKind::Boolean], + } + } + + fn invoke(&self, args: &[Value], context: &Context) -> Result { + let name = args[0].as_str().unwrap(); + if let Some(changed) = context.state_changed.get(name) { + return Ok(Value::Bool(*changed)); + } + Err(DscError::Parser(t!("functions.stateChanged.noStateChangeInformation", name = name).to_string())) + } +} From 02f55a777ff08f31ccaaa7ec6dbe76d7a0003541 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Mon, 6 Jul 2026 13:51:18 -0700 Subject: [PATCH 3/5] add tests --- dsc/tests/dsc_functions.tests.ps1 | 61 +++++++++++++++++++ lib/dsc-lib/locales/en-us.toml | 2 +- lib/dsc-lib/src/dscresources/invoke_result.rs | 6 +- tools/dsctest/dsctest.dsc.manifests.json | 33 ++++++++++ tools/dsctest/src/args.rs | 9 +++ tools/dsctest/src/main.rs | 8 +++ tools/dsctest/src/set.rs | 33 ++++++++++ 7 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 tools/dsctest/src/set.rs diff --git a/dsc/tests/dsc_functions.tests.ps1 b/dsc/tests/dsc_functions.tests.ps1 index 81fc1fad5..a20425350 100644 --- a/dsc/tests/dsc_functions.tests.ps1 +++ b/dsc/tests/dsc_functions.tests.ps1 @@ -1801,4 +1801,65 @@ Describe 'tests for function expressions' { $errorContent = Get-Content $TestDrive/error.log -Raw $errorContent | Should -Match $expectedError } + + It 'stateChanged function returns if resource state had changed: ' -TestCases @( + @{ testName = 'state changed'; value = 'new'; object = @{ property = 'Original' }; expected = $true } + @{ testName = 'state unchanged'; value = 'Original'; object = @{ property = 'Original' }; expected = $false } + @{ testName = 'state changed with nested object'; value = 'Original'; object = @{ property = 'New' }; expected = $true } + @{ testName = 'state unchanged with nested object'; value = 'Original'; object = @{ property = 'Original' }; expected = $false } + ) { + param($value, $object, $expected) + + $config = @{ + '$schema' = 'https://aka.ms/dsc/schemas/v3/bundled/config/document.json' + resources = @( + @{ + name = 'Test' + type = 'Test/Set' + properties = @{ + value = $value + object = $object + } + } + ) + outputs = @{ + stateChanged = @{ + type = 'bool' + value = "[stateChanged(resourceId('Test/Set','Test'))]" + } + } + } + + $config = $config | ConvertTo-Json -Depth 10 -Compress + $out = dsc -l trace config set -i $config 2> $TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw) + $out.outputs.stateChanged | Should -Be $expected -Because ($out | ConvertTo-Json -Depth 10 | Out-String) + } + + It 'stateChanged function returns false for non-existent resource' { + $config = @{ + '$schema' = 'https://aka.ms/dsc/schemas/v3/bundled/config/document.json' + resources = @( + @{ + name = 'Test' + type = 'Test/Set' + properties = @{ + value = 'new' + } + } + ) + outputs = @{ + stateChanged = @{ + type = 'bool' + value = "[stateChanged(resourceId('NonExistent/Resource','Test'))]" + } + } + } + + $config = $config | ConvertTo-Json -Depth 10 -Compress + $null = dsc -l trace config set -i $config 2> $TestDrive/error.log + $errorLog = Get-Content $TestDrive/error.log -Raw + $LASTEXITCODE | Should -Be 2 -Because $errorLog + $errorLog | Should -BeLike "*Error* No state change information available for resourceId 'NonExistent/Resource:Test' as it has not executed yet or does not exist*" + } } diff --git a/lib/dsc-lib/locales/en-us.toml b/lib/dsc-lib/locales/en-us.toml index 29470319f..3d27b3fde 100644 --- a/lib/dsc-lib/locales/en-us.toml +++ b/lib/dsc-lib/locales/en-us.toml @@ -696,7 +696,7 @@ syntax = "startsWith( , )" [functions.stateChanged] description = "Returns true if the state of the resource has changed since the last execution" syntax = "stateChanged( )" -noStateChangeInformation = "No state change information available for resourceId '%{name}' as it has not executed yet" +noStateChangeInformation = "No state change information available for resourceId '%{name}' as it has not executed yet or does not exist" [functions.stdout] description = "Returns the standard output from the last executed resource." diff --git a/lib/dsc-lib/src/dscresources/invoke_result.rs b/lib/dsc-lib/src/dscresources/invoke_result.rs index 7d9f61b1d..1658f56a0 100644 --- a/lib/dsc-lib/src/dscresources/invoke_result.rs +++ b/lib/dsc-lib/src/dscresources/invoke_result.rs @@ -78,7 +78,11 @@ impl SetResult { pub fn is_changed(&self) -> bool { match self { SetResult::Resource(resource_set_result) => { - resource_set_result.changed_properties.is_some() + if let Some(changed_properties) = &resource_set_result.changed_properties { + !changed_properties.is_empty() + } else { + false + } }, SetResult::Group(group_set_result) => { for result in group_set_result { diff --git a/tools/dsctest/dsctest.dsc.manifests.json b/tools/dsctest/dsctest.dsc.manifests.json index a7e35993f..76a86bcf0 100644 --- a/tools/dsctest/dsctest.dsc.manifests.json +++ b/tools/dsctest/dsctest.dsc.manifests.json @@ -746,6 +746,39 @@ } }, { + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "type": "Test/Set", + "version": "0.1.0", + "get": { + "executable": "dsctest", + "args": [ + "set", + "--get" + ] + }, + "set": { + "executable": "dsctest", + "args": [ + "set", + { + "jsonInputArg": "--input", + "mandatory": true + } + ], + "return": "state", + "implementsPretest": true + }, + "schema": { + "command": { + "executable": "dsctest", + "args": [ + "schema", + "-s", + "set" + ] + } + } + }, { "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", "type": "Test/Sleep", "version": "0.1.0", diff --git a/tools/dsctest/src/args.rs b/tools/dsctest/src/args.rs index a0b28ec59..49206daab 100644 --- a/tools/dsctest/src/args.rs +++ b/tools/dsctest/src/args.rs @@ -20,6 +20,7 @@ pub enum Schemas { Operation, RefreshEnv, RestartRequired, + Set, Sleep, StateAndDiff, Trace, @@ -160,6 +161,14 @@ pub enum SubCommand { subcommand: Schemas, }, + #[clap(name = "set", about = "Set a resource")] + Set { + #[clap(name = "get", short, long, help = "Get the current state of the resource before setting it")] + get: bool, + #[clap(name = "input", short, long, help = "The input to the set command as JSON")] + input: Option, + }, + #[clap(name = "sleep", about = "Sleep for a specified number of seconds")] Sleep { #[clap(name = "input", short, long, help = "The input to the sleep command as JSON")] diff --git a/tools/dsctest/src/main.rs b/tools/dsctest/src/main.rs index 39decf60e..4a4156f7c 100644 --- a/tools/dsctest/src/main.rs +++ b/tools/dsctest/src/main.rs @@ -16,6 +16,7 @@ mod operation; mod adapter; mod refresh_env; mod restart_required; +mod set; mod sleep; mod state_and_diff; mod trace; @@ -40,6 +41,7 @@ use crate::metadata::Metadata; use crate::operation::Operation; use crate::refresh_env::RefreshEnv; use crate::restart_required::RestartRequired; +use crate::set::{Set, invoke_set}; use crate::sleep::Sleep; use crate::state_and_diff::StateAndDiff; use crate::trace::Trace; @@ -332,6 +334,9 @@ fn main() { Schemas::RestartRequired => { schema_for!(RestartRequired) }, + Schemas::Set => { + schema_for!(Set) + }, Schemas::Sleep => { schema_for!(Sleep) }, @@ -353,6 +358,9 @@ fn main() { }; serde_json::to_string(&schema).unwrap() }, + SubCommand::Set { get, input } => { + invoke_set( get, input ) + }, SubCommand::Sleep { input } => { let sleep = match serde_json::from_str::(&input) { Ok(sleep) => sleep, diff --git a/tools/dsctest/src/set.rs b/tools/dsctest/src/set.rs new file mode 100644 index 000000000..4ba3ac981 --- /dev/null +++ b/tools/dsctest/src/set.rs @@ -0,0 +1,33 @@ +// 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)] +pub struct Set { + pub value: Option, + pub object: Option>, +} + +impl Default for Set { + fn default() -> Self { + let mut object = Map::new(); + object.insert("property".to_string(), Value::String("Original".to_string())); + Set { + value: Some("Original".to_string()), + object: Some(object) + } + } +} +pub fn invoke_set(get: bool, input: Option) -> String { + let set = if get { + Set::default() + } else { + serde_json::from_str(&input.expect("Input is required")).expect("Failed to parse input JSON") + }; + let result = serde_json::to_string(&set).expect("Failed to serialize result"); + result +} From d8c273f9b47898e3b52995cf571dc82768807ef9 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Mon, 6 Jul 2026 14:03:20 -0700 Subject: [PATCH 4/5] fix build --- lib/dsc-lib/src/functions/restart_required.rs | 12 ++++-------- tools/dsctest/src/set.rs | 3 +-- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/lib/dsc-lib/src/functions/restart_required.rs b/lib/dsc-lib/src/functions/restart_required.rs index d1627aa39..04f303ebc 100644 --- a/lib/dsc-lib/src/functions/restart_required.rs +++ b/lib/dsc-lib/src/functions/restart_required.rs @@ -55,10 +55,8 @@ impl Function for RestartRequired { RestartKind::Process => { if let Some(name) = &name { for restart_required in context.restart_required.as_ref().unwrap_or(&vec![]) { - if let RestartRequiredKind::Process(p) = restart_required { - if p.name == *name { - return Ok(Value::Bool(true)); - } + if let RestartRequiredKind::Process(p) = restart_required && p.name == *name { + return Ok(Value::Bool(true)); } } false @@ -69,10 +67,8 @@ impl Function for RestartRequired { RestartKind::Service => { if let Some(name) = &name { for restart_required in context.restart_required.as_ref().unwrap_or(&vec![]) { - if let RestartRequiredKind::Service(service_name) = restart_required { - if service_name == name { - return Ok(Value::Bool(true)); - } + if let RestartRequiredKind::Service(service_name) = restart_required && service_name == name { + return Ok(Value::Bool(true)); } } false diff --git a/tools/dsctest/src/set.rs b/tools/dsctest/src/set.rs index 4ba3ac981..e39d036ba 100644 --- a/tools/dsctest/src/set.rs +++ b/tools/dsctest/src/set.rs @@ -28,6 +28,5 @@ pub fn invoke_set(get: bool, input: Option) -> String { } else { serde_json::from_str(&input.expect("Input is required")).expect("Failed to parse input JSON") }; - let result = serde_json::to_string(&set).expect("Failed to serialize result"); - result + serde_json::to_string(&set).expect("Failed to serialize result") } From a72c8dd8b999e4e7a412790b5c92af715e784610 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Mon, 6 Jul 2026 16:30:45 -0700 Subject: [PATCH 5/5] address copilot feedback --- lib/dsc-lib/locales/en-us.toml | 4 ++-- tools/dsctest/dsctest.dsc.manifests.json | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/dsc-lib/locales/en-us.toml b/lib/dsc-lib/locales/en-us.toml index 3d27b3fde..968c2c674 100644 --- a/lib/dsc-lib/locales/en-us.toml +++ b/lib/dsc-lib/locales/en-us.toml @@ -658,7 +658,7 @@ syntax = "resourceId( , )" incorrectTypeFormat = "Type argument must contain exactly one slash" [functions.restartRequired] -description = "Determines if a restart is required. The `name` argument is required for process and service, but not provided for system." +description = "Determines if a restart is required. The `name` argument is required for process and service, but not allowed for system." syntax = "restartRequired( , [name] )" constraints = "The `name` argument is required for process and service, but not allowed for system" invalidKind = "Invalid kind '%{kind}', must be one of: process, service, system" @@ -694,7 +694,7 @@ invoked = "startsWith function" syntax = "startsWith( , )" [functions.stateChanged] -description = "Returns true if the state of the resource has changed since the last execution" +description = "Returns true if the state of the resource has changed since the last execution of the configuration, otherwise returns false. If the resource has not executed yet or does not exist, an error is returned." syntax = "stateChanged( )" noStateChangeInformation = "No state change information available for resourceId '%{name}' as it has not executed yet or does not exist" diff --git a/tools/dsctest/dsctest.dsc.manifests.json b/tools/dsctest/dsctest.dsc.manifests.json index 76a86bcf0..82c91dd54 100644 --- a/tools/dsctest/dsctest.dsc.manifests.json +++ b/tools/dsctest/dsctest.dsc.manifests.json @@ -778,7 +778,8 @@ ] } } - }, { + }, + { "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", "type": "Test/Sleep", "version": "0.1.0",