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/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 7f4b87f69..968c2c674 100644 --- a/lib/dsc-lib/locales/en-us.toml +++ b/lib/dsc-lib/locales/en-us.toml @@ -657,6 +657,14 @@ 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 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" +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] )" @@ -685,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 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" + [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..1658f56a0 100644 --- a/lib/dsc-lib/src/dscresources/invoke_result.rs +++ b/lib/dsc-lib/src/dscresources/invoke_result.rs @@ -73,6 +73,29 @@ impl From for SetResult { } } +impl SetResult { + #[must_use] + pub fn is_changed(&self) -> bool { + match self { + SetResult::Resource(resource_set_result) => { + 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 { + 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 b361d46f9..344c421e2 100644 --- a/lib/dsc-lib/src/functions/mod.rs +++ b/lib/dsc-lib/src/functions/mod.rs @@ -71,10 +71,12 @@ 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; pub mod starts_with; +pub mod state_changed; pub mod stdout; pub mod string; pub mod take; @@ -221,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 new file mode 100644 index 000000000..04f303ebc --- /dev/null +++ b/lib/dsc-lib/src/functions/restart_required.rs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. +// 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 rust_i18n::t; +use serde::Deserialize; +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(), + constraints: Some(t!("functions.restartRequired.constraints").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::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().unwrap().to_string() + ) + } else { + None + }; + + let restart_required = match kind { + 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 && p.name == *name { + return Ok(Value::Bool(true)); + } + } + false + } else { + return Err(DscError::Parser(t!("functions.restartRequired.nameRequired", kind = "process").to_string())); + } + }, + 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 && service_name == name { + return Ok(Value::Bool(true)); + } + } + false + } else { + return Err(DscError::Parser(t!("functions.restartRequired.nameRequired", kind = "service").to_string())); + } + }, + RestartKind::System => { + if name.is_some() { + return Err(DscError::Parser(t!("functions.restartRequired.nameNotAllowed", kind = "system").to_string())); + } + for restart_required in context.restart_required.as_ref().unwrap_or(&vec![]) { + if let RestartRequiredKind::System(_) = restart_required { + return Ok(Value::Bool(true)); + } + } + false + }, + }; + + Ok(Value::Bool(restart_required)) + } +} 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())) + } +} diff --git a/tools/dsctest/dsctest.dsc.manifests.json b/tools/dsctest/dsctest.dsc.manifests.json index a7e35993f..82c91dd54 100644 --- a/tools/dsctest/dsctest.dsc.manifests.json +++ b/tools/dsctest/dsctest.dsc.manifests.json @@ -745,6 +745,40 @@ } } }, + { + "$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", 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..e39d036ba --- /dev/null +++ b/tools/dsctest/src/set.rs @@ -0,0 +1,32 @@ +// 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") + }; + serde_json::to_string(&set).expect("Failed to serialize result") +}