Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
5d7597c
Enhance copy loop functionality by allowing `reference()` usage in co…
Gijsreyn Nov 25, 2025
5566b37
Enhance copy loop functionality by allowing `reference()` usage in co…
Gijsreyn Nov 25, 2025
c36a3f9
Merge branch 'gh-1286/main/fix-reference-copy' of https://github.com/…
Gijsreyn Dec 11, 2025
8aed886
Merge branch 'main' of https://github.com/Gijsreyn/operation-methods …
Gijsreyn Dec 11, 2025
0335f31
Remove dead code
Gijsreyn Dec 11, 2025
bf3f5a6
Remove comment
Gijsreyn Dec 11, 2025
c6c28df
Clear copy loop context after processing resource
Gijsreyn Dec 11, 2025
ba2ac01
Merge branch 'main' into gh-1286/main/fix-reference-copy
SteveL-MSFT Dec 11, 2025
9bb8d94
Enhance dependency resolution for copy loop resources in `get_resourc…
Gijsreyn Dec 12, 2025
3a25f7f
Merge branch 'main' into gh-1286/main/fix-reference-copy
Gijsreyn Dec 12, 2025
346d493
Add comment
Gijsreyn Dec 12, 2025
42c1acb
Merge branch 'main' into gh-1286/main/fix-reference-copy
Gijsreyn Dec 19, 2025
422234a
Merge branch 'main' into gh-1286/main/fix-reference-copy
Gijsreyn Jan 12, 2026
c6a7c25
Refactor copy loop handling to use resource metadata instead of tags
Gijsreyn Jan 12, 2026
79370ea
Add test and new copyLoops property
Gijsreyn Jan 15, 2026
fb4c3ee
Merge branch 'main' into gh-1286/main/fix-reference-copy
Gijsreyn Jan 15, 2026
fac05a6
Add metadata field
Gijsreyn Jan 15, 2026
d9a32c1
Merge branch 'main' into gh-1286/main/fix-reference-copy
Gijsreyn Jan 21, 2026
1a2504a
Remove test from main
Gijsreyn Jan 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions dsc/tests/dsc_copy.tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -334,4 +334,110 @@ resources:
$out.results[1].name | Should -Be 'Server-1'
$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'
}
}
1 change: 0 additions & 1 deletion lib/dsc-lib/locales/en-us.toml
Original file line number Diff line number Diff line change
Expand Up @@ -531,7 +531,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]
Expand Down
3 changes: 3 additions & 0 deletions lib/dsc-lib/src/configure/config_doc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ pub struct MicrosoftDscMetadata {
/// Indicates what needs to be restarted after the configuration operation
#[serde(rename = "restartRequired", skip_serializing_if = "Option::is_none")]
pub restart_required: Option<Vec<RestartRequired>>,
/// Copy loop context for resources expanded from copy loops
#[serde(rename = "copyLoops", skip_serializing_if = "Option::is_none")]
pub copy_loops: Option<Map<String, Value>>,
/// The security context of the configuration operation, can be specified to be required
#[serde(rename = "securityContext", skip_serializing_if = "Option::is_none")]
pub security_context: Option<SecurityContextKind>,
Expand Down
182 changes: 152 additions & 30 deletions lib/dsc-lib/src/configure/depends_on.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
// 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;
use crate::types::FullyQualifiedTypeName;

use rust_i18n::t;
use serde_json::Value;
use serde_json::{Map, Value};
use super::context::Context;
use tracing::debug;

Expand All @@ -35,36 +35,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;
};
Expand All @@ -86,14 +89,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: &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: &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 = &copy.name, count = copy.count));
Expand All @@ -111,15 +155,72 @@ fn unroll_and_push(order: &mut Vec<Resource>, 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
let Some(dependency_resource) = config.resources.iter().find(|r| r.resource_type == resource_type) else {
return Err(DscError::Validation(t!("configure.dependsOn.dependencyNotFound", dependency_name = resource_name, resource_name = resource.name).to_string()));
};

// 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 metadata under Microsoft.DSC for later use by reference()
let mut metadata = new_resource.metadata.clone().unwrap_or_else(|| {
use crate::configure::config_doc::Metadata;
Metadata {
microsoft: None,
other: Map::new(),
}
});

let mut microsoft = metadata.microsoft.clone().unwrap_or_default();
let mut copy_loops = microsoft.copy_loops.clone().unwrap_or_default();
copy_loops.insert(copy.name.clone(), Value::Number(i.into()));
microsoft.copy_loops = Some(copy_loops);
metadata.microsoft = Some(microsoft);
new_resource.metadata = Some(metadata);

new_resource.copy = None;
copy_resources.push(new_resource);
Expand Down Expand Up @@ -341,4 +442,25 @@ mod tests {
assert_eq!(order[2].name, "Third");
assert_eq!(order[3].name, "Fourth");
}

#[test]
fn test_copy_loop_missing_dependency() {
let config_yaml: &str = r#"
$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
resources:
- name: "[format('Permission-{0}', copyIndex())]"
type: Test/Permission
copy:
name: permissionCopy
count: 2
dependsOn:
- "[resourceId('Test/Policy', format('Policy-{0}', copyIndex()))]"
"#;

let config: Configuration = serde_yaml::from_str(config_yaml).unwrap();
let mut parser = parser::Statement::new().unwrap();
let mut context = Context::new();
let order = get_resource_invocation_order(&config, &mut parser, &mut context);
assert!(order.is_err());
}
}
Loading