diff --git a/dsc/tests/dsc_copy.tests.ps1 b/dsc/tests/dsc_copy.tests.ps1 index 101f93408..707feaaaf 100644 --- a/dsc/tests/dsc_copy.tests.ps1 +++ b/dsc/tests/dsc_copy.tests.ps1 @@ -335,6 +335,112 @@ resources: $out.results[1].result.actualState.output | Should -Be 'web-2' } + It 'Copy works with reference() to previous copy loop resource' { + $configYaml = @' +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: "[format('Policy-{0}', copyIndex())]" + copy: + name: policyLoop + count: 2 + type: Microsoft.DSC.Debug/Echo + properties: + output: "[format('PolicyId-{0}', copyIndex())]" +- name: "[format('Permission-{0}', copyIndex())]" + copy: + name: permissionLoop + count: 2 + type: Microsoft.DSC.Debug/Echo + properties: + output: "[reference(resourceId('Microsoft.DSC.Debug/Echo', format('Policy-{0}', copyIndex()))).output]" + dependsOn: + - "[resourceId('Microsoft.DSC.Debug/Echo', format('Policy-{0}', copyIndex()))]" +'@ + $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 | Out-String) + $out.results.Count | Should -Be 4 + $out.results[0].name | Should -Be 'Policy-0' + $out.results[0].result.actualState.output | Should -Be 'PolicyId-0' + $out.results[1].name | Should -Be 'Policy-1' + $out.results[1].result.actualState.output | Should -Be 'PolicyId-1' + $out.results[2].name | Should -Be 'Permission-0' + $out.results[2].result.actualState.output | Should -Be 'PolicyId-0' + $out.results[3].name | Should -Be 'Permission-1' + $out.results[3].result.actualState.output | Should -Be 'PolicyId-1' + } + + It 'Copy works with reference() accessing nested property from previous loop' { + $configYaml = @' +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: "[format('Source-{0}', copyIndex())]" + copy: + name: sourceLoop + count: 2 + type: Microsoft.DSC.Debug/Echo + properties: + output: "[concat('Value-', string(copyIndex(100)))]" +- name: "[format('Target-{0}', copyIndex())]" + copy: + name: targetLoop + count: 2 + type: Microsoft.DSC.Debug/Echo + properties: + output: "[concat('Copied: ', reference(resourceId('Microsoft.DSC.Debug/Echo', format('Source-{0}', copyIndex()))).output)]" + dependsOn: + - "[resourceId('Microsoft.DSC.Debug/Echo', format('Source-{0}', copyIndex()))]" +'@ + $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 | Out-String) + $out.results.Count | Should -Be 4 + $out.results[0].name | Should -Be 'Source-0' + $out.results[0].result.actualState.output | Should -Be 'Value-100' + $out.results[1].name | Should -Be 'Source-1' + $out.results[1].result.actualState.output | Should -Be 'Value-101' + $out.results[2].name | Should -Be 'Target-0' + $out.results[2].result.actualState.output | Should -Be 'Copied: Value-100' + $out.results[3].name | Should -Be 'Target-1' + $out.results[3].result.actualState.output | Should -Be 'Copied: Value-101' + } + + It 'Copy with multiple nested copyIndex() calls in reference()' { + $configYaml = @' +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +resources: +- name: "[format('Primary-{0}', copyIndex())]" + copy: + name: primaryLoop + count: 3 + type: Microsoft.DSC.Debug/Echo + properties: + output: "[format('Data-{0}', add(copyIndex(), 1000))]" +- name: "[format('Secondary-{0}', copyIndex())]" + copy: + name: secondaryLoop + count: 3 + type: Microsoft.DSC.Debug/Echo + properties: + output: "[format('From {0}: {1}', copyIndex(), reference(resourceId('Microsoft.DSC.Debug/Echo', format('Primary-{0}', copyIndex()))).output)]" + dependsOn: + - "[resourceId('Microsoft.DSC.Debug/Echo', format('Primary-{0}', copyIndex()))]" +'@ + $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 | Out-String) + $out.results.Count | Should -Be 6 + $out.results[0].name | Should -Be 'Primary-0' + $out.results[0].result.actualState.output | Should -Be 'Data-1000' + $out.results[1].name | Should -Be 'Primary-1' + $out.results[1].result.actualState.output | Should -Be 'Data-1001' + $out.results[2].name | Should -Be 'Primary-2' + $out.results[2].result.actualState.output | Should -Be 'Data-1002' + $out.results[3].name | Should -Be 'Secondary-0' + $out.results[3].result.actualState.output | Should -Be 'From 0: Data-1000' + $out.results[4].name | Should -Be 'Secondary-1' + $out.results[4].result.actualState.output | Should -Be 'From 1: Data-1001' + $out.results[5].name | Should -Be 'Secondary-2' + $out.results[5].result.actualState.output | Should -Be 'From 2: Data-1002' + } + It 'Symbolic name loop works' { $configYaml = @' $schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json diff --git a/lib/dsc-lib/locales/en-us.toml b/lib/dsc-lib/locales/en-us.toml index d900998e7..2fbb61abc 100644 --- a/lib/dsc-lib/locales/en-us.toml +++ b/lib/dsc-lib/locales/en-us.toml @@ -519,7 +519,6 @@ sumOverflow = "Sum of startIndex and count causes overflow" description = "Retrieves the output of a previously executed resource" invoked = "reference function" keyNotFound = "Invalid resourceId or resource has not executed yet: %{key}" -cannotUseInCopyMode = "The 'reference()' function cannot be used when processing a 'Copy' loop" unavailableInUserFunction = "The 'reference()' function is not available in user-defined functions" [functions.resourceId] diff --git a/lib/dsc-lib/src/configure/depends_on.rs b/lib/dsc-lib/src/configure/depends_on.rs index 2364804f3..765579529 100644 --- a/lib/dsc-lib/src/configure/depends_on.rs +++ b/lib/dsc-lib/src/configure/depends_on.rs @@ -2,7 +2,7 @@ // Licensed under the MIT License. use crate::configure::config_doc::Resource; -use crate::configure::{Configuration, IntOrExpression, ProcessMode, invoke_property_expressions}; +use crate::configure::{Configuration, IntOrExpression, ProcessMode}; use crate::DscError; use crate::parser::Statement; @@ -34,36 +34,39 @@ pub fn get_resource_invocation_order(config: &Configuration, parser: &mut Statem } let mut dependency_already_in_order = true; - if let Some(depends_on) = resource.depends_on.clone() { - for dependency in depends_on { - let statement = parser.parse_and_execute(&dependency, context)?; - let Some(string_result) = statement.as_str() else { - return Err(DscError::Validation(t!("configure.dependsOn.syntaxIncorrect", dependency = dependency).to_string())); - }; - let (resource_type, resource_name) = get_type_and_name(string_result)?; + // Skip dependency validation for copy loop resources here - it will be handled in unroll_and_push + // where the copy context is properly set up for copyIndex() expressions in dependsOn + if resource.copy.is_none() { + if let Some(depends_on) = resource.depends_on.clone() { + for dependency in depends_on { + let statement = parser.parse_and_execute(&dependency, context)?; + let Some(string_result) = statement.as_str() else { + return Err(DscError::Validation(t!("configure.dependsOn.syntaxIncorrect", dependency = dependency).to_string())); + }; + let (resource_type, resource_name) = get_type_and_name(string_result)?; - // find the resource by name - let Some(dependency_resource) = config.resources.iter().find(|r| r.name.eq(&resource_name)) else { - return Err(DscError::Validation(t!("configure.dependsOn.dependencyNotFound", dependency_name = resource_name, resource_name = resource.name).to_string())); - }; - // validate the type matches - if dependency_resource.resource_type != resource_type { - return Err(DscError::Validation(t!("configure.dependsOn.dependencyTypeMismatch", resource_type = resource_type, dependency_type = dependency_resource.resource_type, resource_name = resource.name).to_string())); - } - // see if the dependency is already in the order - if order.iter().any(|r| r.name == resource_name && r.resource_type == resource_type) { - continue; + if order.iter().any(|r| r.name == resource_name && r.resource_type == resource_type) { + continue; + } + + let Some(dependency_resource) = config.resources.iter().find(|r| r.name.eq(&resource_name)) else { + return Err(DscError::Validation(t!("configure.dependsOn.dependencyNotFound", dependency_name = resource_name, resource_name = resource.name).to_string())); + }; + + if dependency_resource.resource_type != resource_type { + return Err(DscError::Validation(t!("configure.dependsOn.dependencyTypeMismatch", resource_type = resource_type, dependency_type = dependency_resource.resource_type, resource_name = resource.name).to_string())); + } + + unroll_and_push(&mut order, dependency_resource, parser, context, config)?; + dependency_already_in_order = false; } - // add the dependency to the order - unroll_and_push(&mut order, dependency_resource, parser, context)?; - dependency_already_in_order = false; } } - // make sure the resource is not already in the order if order.iter().any(|r| r.name == resource.name && r.resource_type == resource.resource_type) { // if dependencies were already in the order, then this might be a circular dependency - if dependency_already_in_order { + // Skip this check for copy loop resources as their expanded names are different + if dependency_already_in_order && resource.copy.is_none() { let Some(ref depends_on) = resource.depends_on else { continue; }; @@ -85,14 +88,55 @@ pub fn get_resource_invocation_order(config: &Configuration, parser: &mut Statem continue; } - unroll_and_push(&mut order, resource, parser, context)?; + unroll_and_push(&mut order, resource, parser, context, config)?; } debug!("{}: {order:?}", t!("configure.dependsOn.invocationOrder")); Ok(order) } -fn unroll_and_push(order: &mut Vec, resource: &Resource, parser: &mut Statement, context: &mut Context) -> Result<(), DscError> { +/// Unrolls a resource (expanding copy loops if present) and pushes it to the order list. +/// +/// This function handles both regular resources and copy loop resources. For copy loop resources, +/// it expands the loop by creating individual resource instances with resolved names and properties. +/// +/// # Copy Loop Handling +/// +/// When a resource has a `copy` block, this function: +/// 1. Sets up the copy context (`ProcessMode::Copy` and loop name) +/// 2. Iterates `count` times, setting `copyIndex()` for each iteration +/// 3. For each iteration: +/// - Resolves dependencies that may use `copyIndex()` in their `dependsOn` expressions +/// - Evaluates the resource name expression (e.g., `[format('Policy-{0}', copyIndex())]` -> `Policy-0`) +/// - Stores the copy loop context in resource tags for later use by `reference()` function +/// 4. Clears the copy context after expansion +/// +/// # Dependency Resolution in Copy Loops +/// +/// When a copy loop resource depends on another copy loop resource (e.g., `Permission-0` depends on `Policy-0`), +/// the dependency must be resolved during the copy expansion phase where `copyIndex()` has the correct value. +/// This function handles this by: +/// - Evaluating `dependsOn` expressions with the current copy context +/// - Recursively expanding dependency copy loops if they haven't been expanded yet +/// - Preserving and restoring the copy context when recursing into dependencies +/// +/// # Arguments +/// +/// * `order` - The mutable list of resources in invocation order +/// * `resource` - The resource to unroll and push +/// * `parser` - The statement parser for evaluating expressions +/// * `context` - The evaluation context containing copy loop state +/// * `config` - The full configuration for finding dependency resources +/// +/// # Returns +/// +/// * `Result<(), DscError>` - Ok if successful, or an error if expansion fails +/// +/// # Errors +/// +/// * `DscError::Parser` - If copy count or name expressions fail to evaluate +/// * `DscError::Validation` - If dependency syntax is incorrect +fn unroll_and_push(order: &mut Vec, resource: &Resource, parser: &mut Statement, context: &mut Context, config: &Configuration) -> Result<(), DscError> { // if the resource contains `Copy`, unroll it if let Some(copy) = &resource.copy { debug!("{}", t!("configure.mod.unrollingCopy", name = ©.name, count = copy.count)); @@ -110,15 +154,59 @@ fn unroll_and_push(order: &mut Vec, resource: &Resource, parser: &mut }; for i in 0..count { context.copy.insert(copy.name.clone(), i); + + // Handle dependencies for this copy iteration + if let Some(depends_on) = &resource.depends_on { + for dependency in depends_on { + let statement = parser.parse_and_execute(dependency, context)?; + let Some(string_result) = statement.as_str() else { + return Err(DscError::Validation(t!("configure.dependsOn.syntaxIncorrect", dependency = dependency).to_string())); + }; + let (resource_type, resource_name) = get_type_and_name(string_result)?; + + // Check if the dependency is already in the order (expanded) + if order.iter().any(|r| r.name == resource_name && r.resource_type == resource_type) { + continue; + } + + // Check if the dependency is also in copy_resources we're building + if copy_resources.iter().any(|r| r.name == resource_name && r.resource_type == resource_type) { + continue; + } + + // Find the dependency in config.resources - it might be a copy loop template + // We need to find by type since the name is the template expression + if let Some(dependency_resource) = config.resources.iter().find(|r| r.resource_type == resource_type) { + // If it's a copy loop resource, we need to expand it first + if dependency_resource.copy.is_some() { + // Save current copy context + let saved_loop_name = context.copy_current_loop_name.clone(); + let saved_copy = context.copy.clone(); + + // Recursively unroll the dependency + unroll_and_push(order, dependency_resource, parser, context, config)?; + + // Restore copy context + context.copy_current_loop_name = saved_loop_name; + context.copy = saved_copy; + context.process_mode = ProcessMode::Copy; + } else { + order.push(dependency_resource.clone()); + } + } + } + } + let mut new_resource = resource.clone(); let Value::String(new_name) = parser.parse_and_execute(&resource.name, context)? else { return Err(DscError::Parser(t!("configure.mod.copyNameResultNotString").to_string())) }; new_resource.name = new_name.to_string(); - if let Some(properties) = &resource.properties { - new_resource.properties = invoke_property_expressions(parser, context, Some(properties))?; - } + // Store copy loop context in resource tags for later use by reference() + let mut tags = new_resource.tags.clone().unwrap_or_default(); + tags.insert(format!("__dsc_copy_loop_{}", copy.name), Value::Number(i.into())); + new_resource.tags = Some(tags); new_resource.copy = None; copy_resources.push(new_resource); @@ -131,6 +219,23 @@ fn unroll_and_push(order: &mut Vec, resource: &Resource, parser: &mut Ok(()) } +/// Parses a resource reference statement into type and name components. +/// +/// Resource references in dependsOn and resourceId use the format "Type:Name" where +/// the name portion is URL-encoded to handle special characters. +/// +/// # Arguments +/// * `statement` - A resource reference in the format "Microsoft.Resource/Type:EncodedName" +/// +/// # Returns +/// A tuple of (resource_type, decoded_name) on success. +/// +/// # Errors +/// Returns `DscError::Validation` if the statement doesn't contain exactly one colon +/// separator or if the name portion cannot be URL-decoded. +/// +/// # Examples +/// - Input: `"Microsoft.DSC.Debug/Echo:Policy%2D0"` → Output: `("Microsoft.DSC.Debug/Echo", "Policy-0")` fn get_type_and_name(statement: &str) -> Result<(&str, String), DscError> { let parts: Vec<&str> = statement.split(':').collect(); if parts.len() != 2 { diff --git a/lib/dsc-lib/src/configure/mod.rs b/lib/dsc-lib/src/configure/mod.rs index 04e96a144..a23cf7f1f 100644 --- a/lib/dsc-lib/src/configure/mod.rs +++ b/lib/dsc-lib/src/configure/mod.rs @@ -329,7 +329,19 @@ impl Configurator { } fn get_properties(&mut self, resource: &Resource, resource_kind: &Kind) -> Result>, DscError> { - match resource_kind { + // Restore copy loop context from resource tags if present + if let Some(tags) = &resource.tags { + for (key, value) in tags { + if let Some(loop_name) = key.strip_prefix("__dsc_copy_loop_") { + if let Some(index) = value.as_i64() { + self.context.copy.insert(loop_name.to_string(), index); + self.context.copy_current_loop_name = loop_name.to_string(); + } + } + } + } + + let result = match resource_kind { Kind::Group => { // if Group resource, we leave it to the resource to handle expressions Ok(resource.properties.clone()) @@ -337,7 +349,13 @@ impl Configurator { _ => { Ok(invoke_property_expressions(&mut self.statement_parser, &self.context, resource.properties.as_ref())?) }, - } + }; + + // Clear copy loop context after processing resource + self.context.copy.clear(); + self.context.copy_current_loop_name.clear(); + + result } /// Invoke the get operation on a resource. diff --git a/lib/dsc-lib/src/functions/copy_index.rs b/lib/dsc-lib/src/functions/copy_index.rs index 4503b20ed..48762356b 100644 --- a/lib/dsc-lib/src/functions/copy_index.rs +++ b/lib/dsc-lib/src/functions/copy_index.rs @@ -30,7 +30,7 @@ impl Function for CopyIndex { fn invoke(&self, args: &[Value], context: &Context) -> Result { debug!("{}", t!("functions.copyIndex.invoked")); - if context.process_mode != ProcessMode::Copy { + if context.process_mode != ProcessMode::Copy && context.copy.is_empty() { return Err(DscError::Parser(t!("functions.copyIndex.cannotUseOutsideCopy").to_string())); } match args.len() { diff --git a/lib/dsc-lib/src/functions/reference.rs b/lib/dsc-lib/src/functions/reference.rs index 07e0b37a6..5dbcb3b32 100644 --- a/lib/dsc-lib/src/functions/reference.rs +++ b/lib/dsc-lib/src/functions/reference.rs @@ -34,9 +34,6 @@ impl Function for Reference { fn invoke(&self, args: &[Value], context: &Context) -> Result { debug!("{}", t!("functions.reference.invoked")); - if context.process_mode == ProcessMode::Copy { - return Err(DscError::Parser(t!("functions.reference.cannotUseInCopyMode").to_string())); - } if context.process_mode == ProcessMode::UserFunction { return Err(DscError::Parser(t!("functions.reference.unavailableInUserFunction").to_string())); }