diff --git a/dsc/tests/dsc_expressions.tests.ps1 b/dsc/tests/dsc_expressions.tests.ps1 index bba61abd8..34d4b3e11 100644 --- a/dsc/tests/dsc_expressions.tests.ps1 +++ b/dsc/tests/dsc_expressions.tests.ps1 @@ -242,4 +242,258 @@ resources: $log | Should -BeLike "*ERROR* Arguments must be of the same type*" } + + Context 'Resource name expression evaluation' { + It 'Simple parameter expression in resource name: ' -TestCases @( + @{ expression = "[parameters('resourceName')]"; paramValue = 'TestResource'; expected = 'TestResource' } + @{ expression = "[parameters('serviceName')]"; paramValue = 'MyService'; expected = 'MyService' } + ) { + param($expression, $paramValue, $expected) + $yaml = @" +`$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + resourceName: + type: string + defaultValue: $paramValue + serviceName: + type: string + defaultValue: $paramValue +resources: +- name: "$expression" + type: Microsoft/OSInfo + properties: {} +"@ + $out = dsc config get -i $yaml 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw | Out-String) + $out.results[0].name | Should -Be $expected + } + + It 'Concat function in resource name: ' -TestCases @( + @{ expression = "[concat('prefix-', parameters('name'))]"; paramValue = 'test'; expected = 'prefix-test' } + @{ expression = "[concat(parameters('prefix'), '-', parameters('suffix'))]"; expected = 'start-end' } + @{ expression = "[concat('Resource-', string(parameters('index')))]"; expected = 'Resource-42' } + ) { + param($expression, $paramValue, $expected) + $yaml = @" +`$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + name: + type: string + defaultValue: ${paramValue} + prefix: + type: string + defaultValue: start + suffix: + type: string + defaultValue: end + index: + type: int + defaultValue: 42 +resources: +- name: "$expression" + type: Microsoft/OSInfo + properties: {} +"@ + $out = dsc config get -i $yaml 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw | Out-String) + $out.results[0].name | Should -Be $expected + } + + It 'Format function in resource name: ' -TestCases @( + @{ expression = "[format('Service-{0}', parameters('id'))]"; expected = 'Service-123' } + @{ expression = "[format('{0}-{1}-{2}', parameters('env'), parameters('app'), parameters('ver'))]"; expected = 'prod-web-v1' } + ) { + param($expression, $expected) + $yaml = @" +`$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + id: + type: string + defaultValue: '123' + env: + type: string + defaultValue: prod + app: + type: string + defaultValue: web + ver: + type: string + defaultValue: v1 + num: + type: int + defaultValue: 5 +resources: +- name: "$expression" + type: Microsoft/OSInfo + properties: {} +"@ + $out = dsc config get -i $yaml 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw | Out-String) + $out.results[0].name | Should -Be $expected + } + + It 'Complex expression in resource name: ' -TestCases @( + @{ expression = "[concat(parameters('prefix'), '-', string(add(parameters('base'), parameters('offset'))))]"; expected = 'server-105' } + @{ expression = "[format('{0}-{1}', parameters('type'), if(equals(parameters('env'), 'prod'), 'production', 'development'))]"; expected = 'web-production' } + + ) { + param($expression, $expected) + $yaml = @" +`$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + prefix: + type: string + defaultValue: server + base: + type: int + defaultValue: 100 + offset: + type: int + defaultValue: 5 + type: + type: string + defaultValue: web + env: + type: string + defaultValue: prod + region: + type: string + defaultValue: EASTUS + service: + type: string + defaultValue: WebApp +resources: +- name: "$expression" + type: Microsoft/OSInfo + properties: {} +"@ + $out = dsc config get -i $yaml 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw | Out-String) + $out.results[0].name | Should -Be $expected + } + + It 'Expression with object parameter access: ' -TestCases @( + @{ expression = "[parameters('config').name]"; expected = 'MyApp' } + @{ expression = "[concat(parameters('config').prefix, '-', parameters('config').id)]"; expected = 'app-001' } + @{ expression = "[parameters('servers')[0]]"; expected = 'web01' } + @{ expression = "[parameters('servers')[parameters('config').index]]"; expected = 'db01' } + ) { + param($expression, $expected) + $yaml = @" +`$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + config: + type: object + defaultValue: + name: MyApp + prefix: app + id: '001' + index: 1 + servers: + type: array + defaultValue: + - web01 + - db01 + - cache01 +resources: +- name: "$expression" + type: Microsoft/OSInfo + properties: {} +"@ + $out = dsc config get -i $yaml 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw | Out-String) + $out.results[0].name | Should -Be $expected + } + + It 'Resource name expression error cases: ' -TestCases @( + @{ expression = "[parameters('nonexistent')]"; errorPattern = "*Parameter 'nonexistent' not found*" } + @{ expression = "[concat()]"; errorPattern = "*requires at least 2 arguments*" } + @{ expression = "[add('text', 'more')]"; errorPattern = "*Function 'add' does not accept string arguments, accepted types are: Number*" } + @{ expression = "[parameters('config').nonexistent]"; errorPattern = "*Parser: Member 'nonexistent' not found*" } + @{ expression = "[parameters('array')[10]]"; errorPattern = "*Parser: Index is out of bounds*" } + ) { + param($expression, $errorPattern) + $yaml = @" +`$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + config: + type: object + defaultValue: + name: test + array: + type: array + defaultValue: + - item1 + - item2 +resources: +- name: "$expression" + type: Microsoft/OSInfo + properties: {} +"@ + dsc config get -i $yaml 2>$TestDrive/error.log | Out-Null + $LASTEXITCODE | Should -Be 2 + $errorLog = Get-Content $TestDrive/error.log -Raw + $errorLog | Should -BeLike $errorPattern + } + + It 'Resource name expression must evaluate to string' { + $yaml = @' +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + number: + type: int + defaultValue: 42 +resources: +- name: "[parameters('number')]" + type: Microsoft/OSInfo + properties: {} +'@ + dsc config get -i $yaml 2>$TestDrive/error.log | Out-Null + $LASTEXITCODE | Should -Be 2 + $errorLog = Get-Content $TestDrive/error.log -Raw + $errorLog | Should -BeLike "*Resource name result is not a string*" + } + + It 'Resource name expression with conditional logic' { + $yaml = @' +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + isProd: + type: bool + defaultValue: true + serviceName: + type: string + defaultValue: api +resources: +- name: "[concat(parameters('serviceName'), if(parameters('isProd'), '-prod', '-dev'))]" + type: Microsoft/OSInfo + properties: {} +'@ + $out = dsc config get -i $yaml 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw | Out-String) + $out.results[0].name | Should -Be 'api-prod' + } + + It 'Resource name with nested function calls' { + $yaml = @' +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +parameters: + config: + type: object + defaultValue: + services: + - web + - api + - db + selectedIndex: 1 +resources: +- name: "[concat('SERVICE-', parameters('config').services[parameters('config').selectedIndex])]" + type: Microsoft/OSInfo + properties: {} +'@ + $out = dsc config get -i $yaml 2>$TestDrive/error.log | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw | Out-String) + $out.results[0].name | Should -Be 'SERVICE-api' + } + } } diff --git a/dsc_lib/locales/en-us.toml b/dsc_lib/locales/en-us.toml index 9e301148c..c78e0fcc5 100644 --- a/dsc_lib/locales/en-us.toml +++ b/dsc_lib/locales/en-us.toml @@ -76,6 +76,7 @@ unrollingCopy = "Unrolling copy for resource '%{name}' with count %{count}" 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" 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 d279e48cb..db143eff3 100644 --- a/dsc_lib/src/configure/mod.rs +++ b/dsc_lib/src/configure/mod.rs @@ -346,8 +346,10 @@ impl Configurator { let mut progress = ProgressBar::new(resources.len() as u64, self.progress_format)?; let discovery = &mut self.discovery.clone(); for resource in resources { - progress.set_resource(&resource.name, &resource.resource_type); - progress.write_activity(format!("Get '{}'", resource.name).as_str()); + let evaluated_name = self.evaluate_resource_name(&resource.name)?; + + progress.set_resource(&evaluated_name, &resource.resource_type); + progress.write_activity(format!("Get '{evaluated_name}'").as_str()); if self.skip_resource(&resource)? { progress.write_increment(1); continue; @@ -376,7 +378,7 @@ impl Configurator { 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)?); + self.context.references.insert(format!("{}:{}", resource.resource_type, evaluated_name), serde_json::to_value(&resource_result.actual_state)?); get_metadata_from_result(Some(&mut self.context), &mut resource_result.actual_state, &mut metadata)?; }, GetResult::Group(group) => { @@ -384,12 +386,12 @@ impl Configurator { for result in group { results.push(serde_json::to_value(&result.result)?); } - self.context.references.insert(format!("{}:{}", resource.resource_type, resource.name), Value::Array(results.clone())); + self.context.references.insert(format!("{}:{}", resource.resource_type, evaluated_name), Value::Array(results.clone())); }, } let resource_result = config_result::ResourceGetResult { metadata: Some(metadata), - name: resource.name.clone(), + name: evaluated_name, resource_type: resource.resource_type.clone(), result: get_result.clone(), }; @@ -424,8 +426,10 @@ impl Configurator { let mut progress = ProgressBar::new(resources.len() as u64, self.progress_format)?; let discovery = &mut self.discovery.clone(); for resource in resources { - progress.set_resource(&resource.name, &resource.resource_type); - progress.write_activity(format!("Set '{}'", resource.name).as_str()); + let evaluated_name = self.evaluate_resource_name(&resource.name)?; + + progress.set_resource(&evaluated_name, &resource.resource_type); + progress.write_activity(format!("Set '{evaluated_name}'").as_str()); if self.skip_resource(&resource)? { progress.write_increment(1); continue; @@ -533,7 +537,7 @@ impl Configurator { }; 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)?); + self.context.references.insert(format!("{}:{}", resource.resource_type, evaluated_name), serde_json::to_value(&resource_result.after_state)?); get_metadata_from_result(Some(&mut self.context), &mut resource_result.after_state, &mut metadata)?; }, SetResult::Group(group) => { @@ -541,12 +545,12 @@ impl Configurator { for result in group { results.push(serde_json::to_value(&result.result)?); } - self.context.references.insert(format!("{}:{}", resource.resource_type, resource.name), Value::Array(results.clone())); + self.context.references.insert(format!("{}:{}", resource.resource_type, evaluated_name), Value::Array(results.clone())); }, } let resource_result = config_result::ResourceSetResult { metadata: Some(metadata), - name: resource.name.clone(), + name: evaluated_name, resource_type: resource.resource_type.clone(), result: set_result.clone(), }; @@ -576,8 +580,10 @@ impl Configurator { let mut progress = ProgressBar::new(resources.len() as u64, self.progress_format)?; let discovery = &mut self.discovery.clone(); for resource in resources { - progress.set_resource(&resource.name, &resource.resource_type); - progress.write_activity(format!("Test '{}'", resource.name).as_str()); + let evaluated_name = self.evaluate_resource_name(&resource.name)?; + + progress.set_resource(&evaluated_name, &resource.resource_type); + progress.write_activity(format!("Test '{evaluated_name}'").as_str()); if self.skip_resource(&resource)? { progress.write_increment(1); continue; @@ -607,7 +613,7 @@ impl Configurator { }; 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)?); + self.context.references.insert(format!("{}:{}", resource.resource_type, evaluated_name), serde_json::to_value(&resource_test_result.actual_state)?); get_metadata_from_result(Some(&mut self.context), &mut resource_test_result.actual_state, &mut metadata)?; }, TestResult::Group(group) => { @@ -615,12 +621,12 @@ impl Configurator { for result in group { results.push(serde_json::to_value(&result.result)?); } - self.context.references.insert(format!("{}:{}", resource.resource_type, resource.name), Value::Array(results.clone())); + self.context.references.insert(format!("{}:{}", resource.resource_type, evaluated_name), Value::Array(results.clone())); }, } let resource_result = config_result::ResourceTestResult { metadata: Some(metadata), - name: resource.name.clone(), + name: evaluated_name, resource_type: resource.resource_type.clone(), result: test_result.clone(), }; @@ -653,8 +659,10 @@ impl Configurator { let resources = self.config.resources.clone(); let discovery = &mut self.discovery.clone(); for resource in &resources { - progress.set_resource(&resource.name, &resource.resource_type); - progress.write_activity(format!("Export '{}'", resource.name).as_str()); + let evaluated_name = self.evaluate_resource_name(&resource.name)?; + + progress.set_resource(&evaluated_name, &resource.resource_type); + progress.write_activity(format!("Export '{evaluated_name}'").as_str()); if self.skip_resource(resource)? { progress.write_increment(1); continue; @@ -673,7 +681,7 @@ impl Configurator { return Err(e); }, }; - self.context.references.insert(format!("{}:{}", resource.resource_type, resource.name), serde_json::to_value(&export_result.actual_state)?); + self.context.references.insert(format!("{}:{}", resource.resource_type, evaluated_name), serde_json::to_value(&export_result.actual_state)?); progress.set_result(&serde_json::to_value(export_result)?); progress.write_increment(1); } @@ -912,6 +920,36 @@ impl Configurator { Ok(()) } + /// Evaluate resource name expression and return the resolved name. + /// + /// This method evaluates DSC expressions in a resource name, handling both + /// expressions and literals appropriately. + /// + /// # Arguments + /// * `name` - The resource name that should be evaluated + /// + /// # Returns + /// * `String` - The evaluated resource name + /// + /// # Errors + /// + /// This function will return an error if: + /// - Resource name expression evaluation fails + /// - Expression does not result in a string value + /// - Statement parser encounters invalid syntax + fn evaluate_resource_name(&mut self, name: &str) -> Result { + if self.context.process_mode == ProcessMode::Copy { + return Ok(name.to_string()); + } + + // evaluate the resource name (handles both expressions and literals) + let Value::String(evaluated_name) = self.statement_parser.parse_and_execute(name, &self.context)? else { + return Err(DscError::Parser(t!("configure.mod.nameResultNotString").to_string())) + }; + + Ok(evaluated_name) + } + fn invoke_property_expressions(&mut self, properties: Option<&Map>) -> Result>, DscError> { debug!("{}", t!("configure.mod.invokePropertyExpressions")); if properties.is_none() {