Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -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
Expand Down
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 @@ -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]
Expand Down
163 changes: 134 additions & 29 deletions lib/dsc-lib/src/configure/depends_on.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
};
Expand All @@ -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: &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 @@ -110,15 +154,59 @@ 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
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);
Expand All @@ -131,6 +219,23 @@ fn unroll_and_push(order: &mut Vec<Resource>, 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 {
Expand Down
22 changes: 20 additions & 2 deletions lib/dsc-lib/src/configure/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -329,15 +329,33 @@ impl Configurator {
}

fn get_properties(&mut self, resource: &Resource, resource_kind: &Kind) -> Result<Option<Map<String, Value>>, 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())
},
_ => {
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.
Expand Down
2 changes: 1 addition & 1 deletion lib/dsc-lib/src/functions/copy_index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ impl Function for CopyIndex {

fn invoke(&self, args: &[Value], context: &Context) -> Result<Value, DscError> {
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() {
Expand Down
3 changes: 0 additions & 3 deletions lib/dsc-lib/src/functions/reference.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,6 @@ impl Function for Reference {
fn invoke(&self, args: &[Value], context: &Context) -> Result<Value, DscError> {
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()));
}
Expand Down