Skip to content

Commit 8929bb5

Browse files
authored
Merge pull request #1096 from SteveL-MSFT/user-functions
Enable user functions in configurations
2 parents 1cc08d6 + 44270aa commit 8929bb5

File tree

17 files changed

+428
-66
lines changed

17 files changed

+428
-66
lines changed

dsc/tests/dsc_functions.tests.ps1

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,7 @@ Describe 'tests for function expressions' {
295295
$out = dsc -l trace config get -i $config_yaml 2>$TestDrive/error.log | ConvertFrom-Json
296296
$LASTEXITCODE | Should -Be 2 -Because (Get-Content $TestDrive/error.log -Raw)
297297
$out | Should -BeNullOrEmpty -Because "Output should be null or empty"
298-
(Get-Content $TestDrive/error.log -Raw) | Should -Match 'utcNow function can only be used as a parameter default'
298+
(Get-Content $TestDrive/error.log -Raw) | Should -Match "The 'utcNow\(\)' function can only be used as a parameter default"
299299
}
300300

301301
It 'uniqueString function works for: <expression>' -TestCases @(
@@ -428,7 +428,7 @@ Describe 'tests for function expressions' {
428428
$LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw)
429429
($out.results[0].result.actualState.output | Out-String) | Should -BeExactly ($expected | Out-String)
430430
}
431-
431+
432432
It 'skip function works for: <expression>' -TestCases @(
433433
@{ expression = "[skip(createArray('a','b','c','d'), 2)]"; expected = @('c', 'd') }
434434
@{ expression = "[skip('hello', 2)]"; expected = 'llo' }
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
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.OtherFunction'" }
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+
134+
It 'user function with wrong output type fails' {
135+
$configYaml = @"
136+
`$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
137+
functions:
138+
- namespace: MyFunction
139+
members:
140+
BadFunction:
141+
output:
142+
type: int
143+
value: "'this is a string'"
144+
resources:
145+
- name: test
146+
type: Microsoft.DSC.Debug/Echo
147+
properties:
148+
output: "[MyFunction.BadFunction()]"
149+
"@
150+
dsc -l trace config get -i $configYaml 2>$testdrive/error.log | Out-Null
151+
$LASTEXITCODE | Should -Be 2 -Because (Get-Content $testdrive/error.log | Out-String)
152+
(Get-Content $testdrive/error.log -Raw) | Should -BeLike "*Output of user function 'MyFunction.BadFunction' did not return expected type 'int'*" -Because (Get-Content $testdrive/error.log | Out-String)
153+
}
154+
}

dsc_lib/locales/en-us.toml

Lines changed: 11 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"
@@ -476,15 +479,22 @@ invalidArgType = "All arguments must either be arrays or objects"
476479
description = "Returns a deterministic unique string from the given strings"
477480
invoked = "uniqueString function"
478481

482+
[functions.userFunction]
483+
expectedNoParameters = "User function '%{name}' does not accept parameters"
484+
unknownUserFunction = "Unknown user function '%{name}'"
485+
wrongParamCount = "User function '%{name}' expects %{expected} parameters, but %{got} were provided"
486+
incorrectOutputType = "Output of user function '%{name}' did not return expected type '%{expected_type}'"
487+
479488
[functions.utcNow]
480489
description = "Returns the current UTC time"
481490
invoked = "utcNow function"
482-
onlyUsedAsParameterDefault = "utcNow function can only be used as a parameter default"
491+
onlyUsedAsParameterDefault = "The 'utcNow()' function can only be used as a parameter default"
483492

484493
[functions.variables]
485494
description = "Retrieves the value of a variable"
486495
invoked = "variables function"
487496
keyNotFound = "Variable '%{key}' does not exist or has not been initialized yet"
497+
unavailableInUserFunction = "The 'variables()' function is not available in user-defined functions"
488498

489499
[parser.expression]
490500
functionNodeNotFound = "Function node not found"

dsc_lib/src/configure/config_doc.rs

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use rust_i18n::t;
66
use schemars::{JsonSchema, json_schema};
77
use serde::{Deserialize, Serialize};
88
use serde_json::{Map, Value};
9-
use std::collections::HashMap;
9+
use std::{collections::HashMap, fmt::Display};
1010

1111
use crate::{dscerror::DscError, schemas::DscRepoSchema};
1212

@@ -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>>,
@@ -162,6 +188,21 @@ pub enum DataType {
162188
Array,
163189
}
164190

191+
impl Display for DataType {
192+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
193+
let type_str = match self {
194+
DataType::String => "string",
195+
DataType::SecureString => "secureString",
196+
DataType::Int => "int",
197+
DataType::Bool => "bool",
198+
DataType::Object => "object",
199+
DataType::SecureObject => "secureObject",
200+
DataType::Array => "array",
201+
};
202+
write!(f, "{type_str}")
203+
}
204+
}
205+
165206
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)]
166207
pub enum CopyMode {
167208
#[serde(rename = "serial")]
@@ -296,10 +337,11 @@ impl Configuration {
296337
Self {
297338
schema: Self::default_schema_id_uri(),
298339
content_version: Some("1.0.0".to_string()),
340+
metadata: None,
299341
parameters: None,
300-
variables: None,
301342
resources: Vec::new(),
302-
metadata: None,
343+
functions: None,
344+
variables: None,
303345
}
304346
}
305347
}

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
}

0 commit comments

Comments
 (0)