diff --git a/dsc/tests/dsc_parameters.tests.ps1 b/dsc/tests/dsc_parameters.tests.ps1 index 2b57672fd..d8b48b018 100644 --- a/dsc/tests/dsc_parameters.tests.ps1 +++ b/dsc/tests/dsc_parameters.tests.ps1 @@ -405,4 +405,176 @@ Describe 'Parameters tests' { $errorMessage = Get-Content -Path $TestDrive/error.log -Raw $errorMessage | Should -BeLike "*ERROR*Parameter input failure: JSON: missing field ````parameters````*" } + + It 'Parameters can reference other parameters in defaultValue: simple nested' { + $config_yaml = @" + `$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json + parameters: + basePrefix: + type: string + defaultValue: base + computedPrefix: + type: string + defaultValue: "[concat(parameters('basePrefix'), '-computed')]" + resources: + - name: Echo + type: Microsoft.DSC.Debug/Echo + properties: + output: "[parameters('computedPrefix')]" +"@ + + $out = $config_yaml | dsc config get -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.results[0].result.actualState.output | Should -BeExactly 'base-computed' + } + + It 'Parameters can reference other parameters in defaultValue: multi-level nested' { + $config_yaml = @" + `$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json + parameters: + environment: + type: string + defaultValue: dev + appName: + type: string + defaultValue: "[concat(parameters('environment'), '-myapp')]" + instanceName: + type: string + defaultValue: "[concat(parameters('appName'), '-001')]" + fullInstanceName: + type: string + defaultValue: "[concat('Instance: ', parameters('instanceName'))]" + resources: + - name: Echo + type: Microsoft.DSC.Debug/Echo + properties: + output: "[parameters('fullInstanceName')]" +"@ + + $out = $config_yaml | dsc config get -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.results[0].result.actualState.output | Should -BeExactly 'Instance: dev-myapp-001' + } + + It 'Parameters with circular dependencies are detected' { + $config_yaml = @" + `$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json + parameters: + paramA: + type: string + defaultValue: "[parameters('paramB')]" + paramB: + type: string + defaultValue: "[parameters('paramA')]" + resources: + - name: Echo + type: Microsoft.DSC.Debug/Echo + properties: + output: "[parameters('paramA')]" +"@ + + $testError = & {$config_yaml | dsc config get -f - 2>&1} + $LASTEXITCODE | Should -Be 4 + $testError | Should -Match 'Circular dependency or unresolvable parameter references detected in parameters' + } + + It 'Parameters with complex circular dependencies are detected' { + $config_yaml = @" + `$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json + parameters: + paramA: + type: string + defaultValue: "[parameters('paramB')]" + paramB: + type: string + defaultValue: "[parameters('paramC')]" + paramC: + type: string + defaultValue: "[parameters('paramA')]" + resources: + - name: Echo + type: Microsoft.DSC.Debug/Echo + properties: + output: "[parameters('paramA')]" +"@ + + $testError = & {$config_yaml | dsc config get -f - 2>&1} + $LASTEXITCODE | Should -Be 4 + $testError | Should -Match 'Circular dependency or unresolvable parameter references detected in parameters' + } + + It 'Parameters with nested references can be overridden by input' { + $config_yaml = @" + `$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json + parameters: + basePrefix: + type: string + defaultValue: base + computedPrefix: + type: string + defaultValue: "[concat(parameters('basePrefix'), '-computed')]" + resources: + - name: Echo + type: Microsoft.DSC.Debug/Echo + properties: + output: "[parameters('computedPrefix')]" +"@ + $params_json = @{ parameters = @{ basePrefix = 'override' }} | ConvertTo-Json + + $out = $config_yaml | dsc config -p $params_json get -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.results[0].result.actualState.output | Should -BeExactly 'override-computed' + } + + It 'Parameters nested references work with different data types: ' -TestCases @( + @{ type = 'string'; baseValue = 'test'; expectedOutput = 'prefix-test-suffix' } + @{ type = 'int'; baseValue = 42; expectedOutput = 'value-42' } + ) { + param($type, $baseValue, $expectedOutput) + + $config_yaml = @" + `$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json + parameters: + baseParam: + type: $type + defaultValue: $baseValue + computedParam: + type: string + defaultValue: "[concat('prefix-', string(parameters('baseParam')), '-suffix')]" + resources: + - name: Echo + type: Microsoft.DSC.Debug/Echo + properties: + output: "[parameters('computedParam')]" +"@ + + if ($type -eq 'string') { + $expectedOutput = 'prefix-test-suffix' + } else { + $expectedOutput = 'prefix-42-suffix' + } + + $out = $config_yaml | dsc config get -f - | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.results[0].result.actualState.output | Should -BeExactly $expectedOutput + } + + It 'Parameters with unresolvable references produce error' { + $config_yaml = @" + `$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json + parameters: + computedParam: + type: string + defaultValue: "[parameters('nonExistentParam')]" + resources: + - name: Echo + type: Microsoft.DSC.Debug/Echo + properties: + output: "[parameters('computedParam')]" +"@ + + $testError = & {$config_yaml | dsc config get -f - 2>&1} + $LASTEXITCODE | Should -Be 4 + $testError | Should -Match 'Circular dependency or unresolvable parameter references detected in parameters' + } } diff --git a/dsc_lib/locales/en-us.toml b/dsc_lib/locales/en-us.toml index 39547ddc6..312f7d782 100644 --- a/dsc_lib/locales/en-us.toml +++ b/dsc_lib/locales/en-us.toml @@ -54,7 +54,6 @@ noParametersDefined = "No parameters defined in configuration" processingParameter = "Processing parameter '%{name}'" setDefaultParameter = "Set default parameter '%{name}'" defaultStringNotDefined = "Default value as string is not defined" -noParametersInput = "No parameters input" setSecureParameter = "Set secure parameter '%{name}'" setParameter = "Set parameter '%{name}' to '%{value}'" parameterNotDefined = "Parameter '%{name}' is not defined in configuration" @@ -78,6 +77,7 @@ copyModeNotSupported = "Copy mode is not supported" copyBatchSizeNotSupported = "Copy batch size is not supported" copyNameResultNotString = "Copy name result is not a string" nameResultNotString = "Resource name result is not a string" +circularDependency = "Circular dependency or unresolvable parameter references detected in parameters: %{parameters}" userFunctionAlreadyDefined = "User function '%{name}' in namespace '%{namespace}' is already defined" addingUserFunction = "Adding user function '%{name}'" diff --git a/dsc_lib/src/configure/mod.rs b/dsc_lib/src/configure/mod.rs index 4d5ecc1b0..3014999c8 100644 --- a/dsc_lib/src/configure/mod.rs +++ b/dsc_lib/src/configure/mod.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -use crate::configure::config_doc::{ExecutionKind, Metadata, Resource}; +use crate::configure::config_doc::{ExecutionKind, Metadata, Resource, Parameter}; use crate::configure::context::{Context, ProcessMode}; use crate::configure::{config_doc::RestartRequired, parameters::Input}; use crate::discovery::discovery_trait::DiscoveryFilter; @@ -757,7 +757,6 @@ impl Configurator { } fn set_parameters(&mut self, parameters_input: Option<&Value>, config: &Configuration) -> Result<(), DscError> { - // set default parameters first let Some(parameters) = &config.parameters else { if parameters_input.is_none() { info!("{}", t!("configure.mod.noParameters")); @@ -766,66 +765,87 @@ impl Configurator { return Err(DscError::Validation(t!("configure.mod.noParametersDefined").to_string())); }; - for (name, parameter) in parameters { - debug!("{}", t!("configure.mod.processingParameter", name = name)); - if let Some(default_value) = ¶meter.default_value { - debug!("{}", t!("configure.mod.setDefaultParameter", name = name)); - // default values can be expressions - let value = if default_value.is_string() { - if let Some(value) = default_value.as_str() { - self.context.process_mode = ProcessMode::ParametersDefault; - let result = self.statement_parser.parse_and_execute(value, &self.context)?; - self.context.process_mode = ProcessMode::Normal; - result + // process input parameters first + if let Some(parameters_input) = parameters_input { + trace!("parameters_input: {parameters_input}"); + let input_parameters: HashMap = serde_json::from_value::(parameters_input.clone())?.parameters; + + for (name, value) in input_parameters { + if let Some(constraint) = parameters.get(&name) { + debug!("Validating parameter '{name}'"); + check_length(&name, &value, constraint)?; + check_allowed_values(&name, &value, constraint)?; + check_number_limits(&name, &value, constraint)?; + // TODO: additional array constraints + // TODO: object constraints + + validate_parameter_type(&name, &value, &constraint.parameter_type)?; + if constraint.parameter_type == DataType::SecureString || constraint.parameter_type == DataType::SecureObject { + info!("{}", t!("configure.mod.setSecureParameter", name = name)); } else { - return Err(DscError::Parser(t!("configure.mod.defaultStringNotDefined").to_string())); + info!("{}", t!("configure.mod.setParameter", name = name, value = value)); } - } else { - default_value.clone() - }; - validate_parameter_type(name, &value, ¶meter.parameter_type)?; - self.context.parameters.insert(name.clone(), (value, parameter.parameter_type.clone())); + + self.context.parameters.insert(name.clone(), (value.clone(), constraint.parameter_type.clone())); + if let Some(parameters) = &mut self.config.parameters { + if let Some(parameter) = parameters.get_mut(&name) { + parameter.default_value = Some(value); + } + } + } + else { + return Err(DscError::Validation(t!("configure.mod.parameterNotDefined", name = name).to_string())); + } } } - let Some(parameters_input) = parameters_input else { - debug!("{}", t!("configure.mod.noParametersInput")); - return Ok(()); - }; + // Now process default values for parameters that weren't provided in input + let mut unresolved_parameters: HashMap = parameters + .iter() + .filter(|(name, _)| !self.context.parameters.contains_key(*name)) + .map(|(k, v)| (k.clone(), v)) + .collect(); + + while !unresolved_parameters.is_empty() { + let mut resolved_in_this_pass = Vec::new(); + + for (name, parameter) in &unresolved_parameters { + debug!("{}", t!("configure.mod.processingParameter", name = name)); + if let Some(default_value) = ¶meter.default_value { + debug!("{}", t!("configure.mod.setDefaultParameter", name = name)); + let value_result = if default_value.is_string() { + if let Some(value) = default_value.as_str() { + self.context.process_mode = ProcessMode::ParametersDefault; + let result = self.statement_parser.parse_and_execute(value, &self.context); + self.context.process_mode = ProcessMode::Normal; + result + } else { + return Err(DscError::Parser(t!("configure.mod.defaultStringNotDefined").to_string())); + } + } else { + Ok(default_value.clone()) + }; - trace!("parameters_input: {parameters_input}"); - let parameters: HashMap = serde_json::from_value::(parameters_input.clone())?.parameters; - let Some(parameters_constraints) = &config.parameters else { - return Err(DscError::Validation(t!("configure.mod.noParametersDefined").to_string())); - }; - for (name, value) in parameters { - if let Some(constraint) = parameters_constraints.get(&name) { - debug!("Validating parameter '{name}'"); - check_length(&name, &value, constraint)?; - check_allowed_values(&name, &value, constraint)?; - check_number_limits(&name, &value, constraint)?; - // TODO: additional array constraints - // TODO: object constraints - - validate_parameter_type(&name, &value, &constraint.parameter_type)?; - if constraint.parameter_type == DataType::SecureString || constraint.parameter_type == DataType::SecureObject { - info!("{}", t!("configure.mod.setSecureParameter", name = name)); + if let Ok(value) = value_result { + validate_parameter_type(name, &value, ¶meter.parameter_type)?; + self.context.parameters.insert(name.to_string(), (value, parameter.parameter_type.clone())); + resolved_in_this_pass.push(name.clone()); + } } else { - info!("{}", t!("configure.mod.setParameter", name = name, value = value)); + resolved_in_this_pass.push(name.clone()); } + } - self.context.parameters.insert(name.clone(), (value.clone(), constraint.parameter_type.clone())); - // also update the configuration with the parameter value - if let Some(parameters) = &mut self.config.parameters { - if let Some(parameter) = parameters.get_mut(&name) { - parameter.default_value = Some(value); - } - } + if resolved_in_this_pass.is_empty() { + let unresolved_names: Vec<_> = unresolved_parameters.keys().map(std::string::String::as_str).collect(); + return Err(DscError::Validation(t!("configure.mod.circularDependency", parameters = unresolved_names.join(", ")).to_string())); } - else { - return Err(DscError::Validation(t!("configure.mod.parameterNotDefined", name = name).to_string())); + + for name in &resolved_in_this_pass { + unresolved_parameters.remove(name); } } + Ok(()) }