Skip to content

Commit bbec365

Browse files
committed
Enable user functions in configurations
1 parent 1cc08d6 commit bbec365

File tree

16 files changed

+357
-28
lines changed

16 files changed

+357
-28
lines changed
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
Describe 'user function tests' {
5+
It 'user function working with expression: <expression>' -TestCases @(
6+
@{ expression = "[MyFunction.ComboFunction('test', 42, true)]"; expected = 'test-42-True' }
7+
@{ expression = "[MyOtherNamespace.ArrayFunction(createArray('a','b','c','d'))]"; expected = @('["b","c","d"]-a') }
8+
) {
9+
param($expression, $expected)
10+
11+
$configYaml = @"
12+
`$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
13+
functions:
14+
- namespace: MyFunction
15+
members:
16+
ComboFunction:
17+
parameters:
18+
- name: StringParam
19+
type: string
20+
- name: NumberParam
21+
type: int
22+
- name: BoolParam
23+
type: bool
24+
output:
25+
type: string
26+
value: "[format('{0}-{1}-{2}', parameters('StringParam'), parameters('NumberParam'), parameters('BoolParam'))]"
27+
- namespace: MyOtherNamespace
28+
members:
29+
ArrayFunction:
30+
parameters:
31+
- name: ArrayParam
32+
type: array
33+
output:
34+
type: array
35+
value: "[array(format('{0}-{1}', string(skip(parameters('ArrayParam'),1)), first(parameters('ArrayParam'))))]"
36+
resources:
37+
- name: test
38+
type: Microsoft.DSC.Debug/Echo
39+
properties:
40+
output: "$expression"
41+
"@
42+
43+
$out = dsc -l trace config get -i $configYaml 2>$testdrive/error.log | ConvertFrom-Json
44+
$LASTEXITCODE | Should -Be 0 -Because (Get-Content $testdrive/error.log | Out-String)
45+
$out.results[0].result.actualState.output | Should -Be $expected -Because ($out | ConvertTo-Json -Depth 10 | Out-String)
46+
}
47+
48+
It 'user function returning object works' {
49+
$configYaml = @"
50+
`$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
51+
functions:
52+
- namespace: MyObjectFunction
53+
members:
54+
ObjectFunction:
55+
parameters:
56+
- name: ObjectParam
57+
type: object
58+
output:
59+
type: object
60+
value: "[createObject('myKey', concat('#', string(parameters('ObjectParam'))))]"
61+
resources:
62+
- name: test
63+
type: Microsoft.DSC.Debug/Echo
64+
properties:
65+
output: "[MyObjectFunction.ObjectFunction(createObject('key','value'))]"
66+
"@
67+
68+
$out = dsc -l trace config get -i $configYaml 2>$testdrive/error.log | ConvertFrom-Json
69+
$LASTEXITCODE | Should -Be 0 -Because (Get-Content $testdrive/error.log | Out-String)
70+
$out.results[0].result.actualState.output.myKey | Should -Be '#{"key":"value"}' -Because ($out | ConvertTo-Json -Depth 10 | Out-String)
71+
}
72+
73+
It 'user functions cannot call function with expression: <expression>' -TestCases @(
74+
@{ expression = "[reference('foo/bar')]"; errorText = "The 'reference()' function is not available in user-defined functions" }
75+
@{ expression = "[utcNow()]"; errorText = "The 'utcNow()' function can only be used as a parameter default" }
76+
@{ expression = "[variables('myVar')]"; errorText = "The 'variables()' function is not available in user-defined functions" }
77+
@{ expression = "[MyFunction.OtherFunction()]"; errorText = "Unknown user function: MyFunction.OtherFunctio" }
78+
@{ expression = "[MyFunction.BadFunction()]"; errorText = "Unknown user function: MyFunction.BadFunction" }
79+
) {
80+
param($expression, $errorText)
81+
82+
$configYaml = @"
83+
`$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
84+
variables:
85+
myVar: someValue
86+
functions:
87+
- namespace: MyFunction
88+
members:
89+
BadFunction:
90+
output:
91+
type: string
92+
value: "$expression"
93+
OtherFunction:
94+
output:
95+
type: string
96+
value: "test"
97+
resources:
98+
- name: test
99+
type: Microsoft.DSC.Debug/Echo
100+
properties:
101+
output: "[MyFunction.BadFunction()]"
102+
"@
103+
104+
dsc -l trace config get -i $configYaml 2>$testdrive/error.log | Out-Null
105+
$LASTEXITCODE | Should -Be 2 -Because (Get-Content $testdrive/error.log | Out-String)
106+
(Get-Content $testdrive/error.log -Raw) | Should -BeLike "*$errorText*" -Because (Get-Content $testdrive/error.log | Out-String)
107+
}
108+
109+
It 'user function with invalid parameter fails' {
110+
$configYaml = @"
111+
`$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
112+
functions:
113+
- namespace: MyFunction
114+
members:
115+
BadFunction:
116+
parameters:
117+
- name: Param1
118+
type: string
119+
output:
120+
type: string
121+
value: "[parameters('BadParam')]"
122+
resources:
123+
- name: test
124+
type: Microsoft.DSC.Debug/Echo
125+
properties:
126+
output: "[MyFunction.BadFunction('test')]"
127+
"@
128+
129+
dsc -l trace config get -i $configYaml 2>$testdrive/error.log | Out-Null
130+
$LASTEXITCODE | Should -Be 2 -Because (Get-Content $testdrive/error.log | Out-String)
131+
(Get-Content $testdrive/error.log -Raw) | Should -BeLike "*Parameter 'BadParam' not found in context*" -Because (Get-Content $testdrive/error.log | Out-String)
132+
}
133+
}

dsc_lib/locales/en-us.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ unrollingCopy = "Unrolling copy for resource '%{name}' with count %{count}"
7676
copyModeNotSupported = "Copy mode is not supported"
7777
copyBatchSizeNotSupported = "Copy batch size is not supported"
7878
copyNameResultNotString = "Copy name result is not a string"
79+
userFunctionAlreadyDefined = "User function '%{name}' in namespace '%{namespace}' is already defined"
80+
addingUserFunction = "Adding user function '%{name}'"
7981

8082
[discovery.commandDiscovery]
8183
couldNotReadSetting = "Could not read 'resourcePath' setting"
@@ -426,6 +428,7 @@ description = "Retrieves the output of a previously executed resource"
426428
invoked = "reference function"
427429
keyNotFound = "Invalid resourceId or resource has not executed yet: %{key}"
428430
cannotUseInCopyMode = "The 'reference()' function cannot be used when processing a 'Copy' loop"
431+
unavailableInUserFunction = "The 'reference()' function is not available in user-defined functions"
429432

430433
[functions.resourceId]
431434
description = "Constructs a resource ID from the given type and name"
@@ -479,12 +482,13 @@ invoked = "uniqueString function"
479482
[functions.utcNow]
480483
description = "Returns the current UTC time"
481484
invoked = "utcNow function"
482-
onlyUsedAsParameterDefault = "utcNow function can only be used as a parameter default"
485+
onlyUsedAsParameterDefault = "The 'utcNow()' function can only be used as a parameter default"
483486

484487
[functions.variables]
485488
description = "Retrieves the value of a variable"
486489
invoked = "variables function"
487490
keyNotFound = "Variable '%{key}' does not exist or has not been initialized yet"
491+
unavailableInUserFunction = "The 'variables()' function is not available in user-defined functions"
488492

489493
[parser.expression]
490494
functionNodeNotFound = "Function node not found"

dsc_lib/src/configure/config_doc.rs

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,30 @@ pub struct Metadata {
105105
pub other: Map<String, Value>,
106106
}
107107

108+
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)]
109+
pub struct UserFunction {
110+
pub namespace: String,
111+
pub members: HashMap<String, UserFunctionDefinition>,
112+
}
113+
114+
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)]
115+
pub struct UserFunctionDefinition {
116+
pub parameters: Option<Vec<UserFunctionParameter>>,
117+
pub output: UserFunctionOutput,
118+
}
119+
120+
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)]
121+
pub struct UserFunctionParameter {
122+
pub name: String,
123+
pub r#type: DataType,
124+
}
125+
126+
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)]
127+
pub struct UserFunctionOutput {
128+
pub r#type: DataType,
129+
pub value: String,
130+
}
131+
108132
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)]
109133
#[serde(deny_unknown_fields)]
110134
pub struct Configuration {
@@ -114,6 +138,8 @@ pub struct Configuration {
114138
#[serde(rename = "contentVersion")]
115139
pub content_version: Option<String>,
116140
#[serde(skip_serializing_if = "Option::is_none")]
141+
pub functions: Option<Vec<UserFunction>>,
142+
#[serde(skip_serializing_if = "Option::is_none")]
117143
pub parameters: Option<HashMap<String, Parameter>>,
118144
#[serde(skip_serializing_if = "Option::is_none")]
119145
pub variables: Option<Map<String, Value>>,
@@ -296,10 +322,11 @@ impl Configuration {
296322
Self {
297323
schema: Self::default_schema_id_uri(),
298324
content_version: Some("1.0.0".to_string()),
325+
metadata: None,
299326
parameters: None,
300-
variables: None,
301327
resources: Vec::new(),
302-
metadata: None,
328+
functions: None,
329+
variables: None,
303330
}
304331
}
305332
}

dsc_lib/src/configure/context.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// Licensed under the MIT License.
33

44
use chrono::{DateTime, Local};
5-
use crate::{configure::config_doc::ExecutionKind, extensions::dscextension::DscExtension};
5+
use crate::{configure::config_doc::{ExecutionKind, UserFunctionDefinition}, extensions::dscextension::DscExtension};
66
use security_context_lib::{get_security_context, SecurityContext};
77
use serde_json::{Map, Value};
88
use std::{collections::HashMap, path::PathBuf};
@@ -34,6 +34,7 @@ pub struct Context {
3434
pub security_context: SecurityContextKind,
3535
pub start_datetime: DateTime<Local>,
3636
pub system_root: PathBuf,
37+
pub user_functions: HashMap<String, UserFunctionDefinition>,
3738
pub variables: Map<String, Value>,
3839
}
3940

@@ -58,6 +59,7 @@ impl Context {
5859
},
5960
start_datetime: chrono::Local::now(),
6061
system_root: get_default_os_system_root(),
62+
user_functions: HashMap::new(),
6163
variables: Map::new(),
6264
}
6365
}

dsc_lib/src/configure/mod.rs

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use crate::DscResource;
1616
use crate::discovery::Discovery;
1717
use crate::parser::Statement;
1818
use crate::progress::{Failure, ProgressBar, ProgressFormat};
19-
use self::context::Context;
19+
use self::context::{Context, ProcessMode};
2020
use self::config_doc::{Configuration, DataType, MicrosoftDscMetadata, Operation, SecurityContextKind};
2121
use self::depends_on::get_resource_invocation_order;
2222
use self::config_result::{ConfigurationExportResult, ConfigurationGetResult, ConfigurationSetResult, ConfigurationTestResult};
@@ -729,6 +729,7 @@ impl Configurator {
729729
self.context.extensions = self.discovery.extensions.values().cloned().collect();
730730
self.set_parameters(parameters_input, &config)?;
731731
self.set_variables(&config)?;
732+
self.set_user_functions(&config)?;
732733
Ok(())
733734
}
734735

@@ -749,9 +750,9 @@ impl Configurator {
749750
// default values can be expressions
750751
let value = if default_value.is_string() {
751752
if let Some(value) = default_value.as_str() {
752-
self.context.processing_parameter_defaults = true;
753+
self.context.process_mode = ProcessMode::ParametersDefault;
753754
let result = self.statement_parser.parse_and_execute(value, &self.context)?;
754-
self.context.processing_parameter_defaults = false;
755+
self.context.process_mode = ProcessMode::Normal;
755756
result
756757
} else {
757758
return Err(DscError::Parser(t!("configure.mod.defaultStringNotDefined").to_string()));
@@ -824,6 +825,23 @@ impl Configurator {
824825
Ok(())
825826
}
826827

828+
fn set_user_functions(&mut self, config: &Configuration) -> Result<(), DscError> {
829+
let Some(functions) = &config.functions else {
830+
return Ok(());
831+
};
832+
833+
for user_function in functions {
834+
for (function_name, function_definition) in &user_function.members {
835+
if self.context.user_functions.contains_key(&format!("{}.{}", user_function.namespace, function_name)) {
836+
return Err(DscError::Validation(t!("configure.mod.userFunctionAlreadyDefined", name = function_name, namespace = user_function.namespace).to_string()));
837+
}
838+
debug!("{}", t!("configure.mod.addingUserFunction", name = format!("{}.{}", user_function.namespace, function_name)));
839+
self.context.user_functions.insert(format!("{}.{}", user_function.namespace.to_lowercase(), function_name.to_lowercase()), function_definition.clone());
840+
}
841+
}
842+
Ok(())
843+
}
844+
827845
fn get_result_metadata(&self, operation: Operation) -> Metadata {
828846
let end_datetime = chrono::Local::now();
829847
let version = self

dsc_lib/src/dscresources/dscresource.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT License.
33

4-
use crate::{configure::{config_doc::{Configuration, ExecutionKind, Resource}, Configurator}, dscresources::resource_manifest::Kind};
4+
use crate::{configure::{config_doc::{Configuration, ExecutionKind, Resource}, Configurator, context::ProcessMode}, dscresources::resource_manifest::Kind};
55
use crate::dscresources::invoke_result::{ResourceGetResponse, ResourceSetResponse};
66
use dscerror::DscError;
77
use jsonschema::Validator;
@@ -126,7 +126,7 @@ impl DscResource {
126126
let config_json = serde_json::to_string(&configuration)?;
127127
let mut configurator = Configurator::new(&config_json, crate::progress::ProgressFormat::None)?;
128128
// don't process expressions again as they would have already been processed before being passed to the adapter
129-
configurator.context.process_expressions = false;
129+
configurator.context.process_mode = ProcessMode::NoExpressionEvaluation;
130130
Ok(configurator)
131131
}
132132
}

dsc_lib/src/functions/mod.rs

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use std::collections::HashMap;
55

66
use crate::DscError;
77
use crate::configure::context::Context;
8+
use crate::functions::user_function::invoke_user_function;
89
use rust_i18n::t;
910
use schemars::JsonSchema;
1011
use serde::Serialize;
@@ -60,6 +61,7 @@ pub mod system_root;
6061
pub mod r#true;
6162
pub mod union;
6263
pub mod unique_string;
64+
pub mod user_function;
6365
pub mod utc_now;
6466
pub mod variables;
6567

@@ -197,6 +199,10 @@ impl FunctionDispatcher {
197199
/// This function will return an error if the function fails to execute.
198200
pub fn invoke(&self, name: &str, args: &[Value], context: &Context) -> Result<Value, DscError> {
199201
let Some(function) = self.functions.get(name) else {
202+
// if function name contains a period, it might be a user function
203+
if name.contains('.') {
204+
return invoke_user_function(name, args, context);
205+
}
200206
return Err(DscError::Parser(t!("functions.unknownFunction", name = name).to_string()));
201207
};
202208

@@ -224,32 +230,32 @@ impl FunctionDispatcher {
224230
break;
225231
}
226232

227-
Self::check_arg_against_expected_types(value, &metadata.accepted_arg_ordered_types[index])?;
233+
Self::check_arg_against_expected_types(name, value, &metadata.accepted_arg_ordered_types[index])?;
228234
}
229235

230236
// if we have remaining args, they must match one of the remaining_arg_types
231237
if let Some(remaining_arg_types) = metadata.remaining_arg_accepted_types {
232238
for value in args.iter().skip(metadata.accepted_arg_ordered_types.len()) {
233-
Self::check_arg_against_expected_types(value, &remaining_arg_types)?;
239+
Self::check_arg_against_expected_types(name, value, &remaining_arg_types)?;
234240
}
235241
}
236242

237243
function.invoke(args, context)
238244
}
239245

240-
fn check_arg_against_expected_types(arg: &Value, expected_types: &[FunctionArgKind]) -> Result<(), DscError> {
246+
fn check_arg_against_expected_types(name: &str, arg: &Value, expected_types: &[FunctionArgKind]) -> Result<(), DscError> {
241247
if arg.is_array() && !expected_types.contains(&FunctionArgKind::Array) {
242-
return Err(DscError::Parser(t!("functions.noArrayArgs", accepted_args_string = expected_types.iter().map(std::string::ToString::to_string).collect::<Vec<_>>().join(", ")).to_string()));
248+
return Err(DscError::Parser(t!("functions.noArrayArgs", name = name, accepted_args_string = expected_types.iter().map(std::string::ToString::to_string).collect::<Vec<_>>().join(", ")).to_string()));
243249
} else if arg.is_boolean() && !expected_types.contains(&FunctionArgKind::Boolean) {
244-
return Err(DscError::Parser(t!("functions.noBooleanArgs", accepted_args_string = expected_types.iter().map(std::string::ToString::to_string).collect::<Vec<_>>().join(", ")).to_string()));
250+
return Err(DscError::Parser(t!("functions.noBooleanArgs", name = name, accepted_args_string = expected_types.iter().map(std::string::ToString::to_string).collect::<Vec<_>>().join(", ")).to_string()));
245251
} else if arg.is_null() && !expected_types.contains(&FunctionArgKind::Null) {
246-
return Err(DscError::Parser(t!("functions.noNullArgs", accepted_args_string = expected_types.iter().map(std::string::ToString::to_string).collect::<Vec<_>>().join(", ")).to_string()));
252+
return Err(DscError::Parser(t!("functions.noNullArgs", name = name, accepted_args_string = expected_types.iter().map(std::string::ToString::to_string).collect::<Vec<_>>().join(", ")).to_string()));
247253
} else if arg.is_number() && !expected_types.contains(&FunctionArgKind::Number) {
248-
return Err(DscError::Parser(t!("functions.noNumberArgs", accepted_args_string = expected_types.iter().map(std::string::ToString::to_string).collect::<Vec<_>>().join(", ")).to_string()));
254+
return Err(DscError::Parser(t!("functions.noNumberArgs", name = name, accepted_args_string = expected_types.iter().map(std::string::ToString::to_string).collect::<Vec<_>>().join(", ")).to_string()));
249255
} else if arg.is_object() && !expected_types.contains(&FunctionArgKind::Object) {
250-
return Err(DscError::Parser(t!("functions.noObjectArgs", accepted_args_string = expected_types.iter().map(std::string::ToString::to_string).collect::<Vec<_>>().join(", ")).to_string()));
256+
return Err(DscError::Parser(t!("functions.noObjectArgs", name = name, accepted_args_string = expected_types.iter().map(std::string::ToString::to_string).collect::<Vec<_>>().join(", ")).to_string()));
251257
} else if arg.is_string() && !expected_types.contains(&FunctionArgKind::String) {
252-
return Err(DscError::Parser(t!("functions.noStringArgs", accepted_args_string = expected_types.iter().map(std::string::ToString::to_string).collect::<Vec<_>>().join(", ")).to_string()));
258+
return Err(DscError::Parser(t!("functions.noStringArgs", name = name, accepted_args_string = expected_types.iter().map(std::string::ToString::to_string).collect::<Vec<_>>().join(", ")).to_string()));
253259
}
254260
Ok(())
255261
}

dsc_lib/src/functions/reference.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ impl Function for Reference {
3737
if context.process_mode == ProcessMode::Copy {
3838
return Err(DscError::Parser(t!("functions.reference.cannotUseInCopyMode").to_string()));
3939
}
40+
if context.process_mode == ProcessMode::UserFunction {
41+
return Err(DscError::Parser(t!("functions.reference.unavailableInUserFunction").to_string()));
42+
}
4043

4144
if let Some(key) = args[0].as_str() {
4245
if context.references.contains_key(key) {

0 commit comments

Comments
 (0)