From f82360b8c4f5c00dfed1589192ebd0ded5a81428 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Tue, 19 Sep 2023 13:50:49 -0700 Subject: [PATCH 1/3] add support to pass json as env var --- dsc/tests/dsc_resource_input.tests.ps1 | 120 ++++++++++++++++ dsc_lib/src/discovery/command_discovery.rs | 2 +- dsc_lib/src/dscresources/command_resource.rs | 129 ++++++++++++++++-- dsc_lib/src/dscresources/resource_manifest.rs | 6 +- 4 files changed, 240 insertions(+), 17 deletions(-) create mode 100644 dsc/tests/dsc_resource_input.tests.ps1 diff --git a/dsc/tests/dsc_resource_input.tests.ps1 b/dsc/tests/dsc_resource_input.tests.ps1 new file mode 100644 index 000000000..23a196b5e --- /dev/null +++ b/dsc/tests/dsc_resource_input.tests.ps1 @@ -0,0 +1,120 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'tests for resource input' { + BeforeAll { + $manifest = @' + { + "manifestVersion": "1.0.0", + "type": "Test/EnvVarInput", + "version": "0.1.0", + "get": { + "executable": "pwsh", + "input": "env", + "args": [ + "-NoLogo", + "-NonInteractive", + "-NoProfile", + "-Command", + "\"{ `\"Hello`\": `\"$env:Hello`\", `\"World`\": `\"$env:World`\", `\"Boolean`\": `\"$env:Boolean`\", `\"StringArray`\": `\"$env:StringArray`\", `\"NumberArray`\": `\"$env:NumberArray`\" }\"" + ] + }, + "set": { + "executable": "pwsh", + "input": "env", + "args": [ + "-NoLogo", + "-NonInteractive", + "-NoProfile", + "-Command", + "\"{ `\"Hello`\": `\"$env:Hello`\", `\"World`\": `\"$env:World`\", `\"Boolean`\": `\"$env:Boolean`\", `\"StringArray`\": `\"$env:StringArray`\", `\"NumberArray`\": `\"$env:NumberArray`\" }\"" + ], + "return": "state", + "implementsPretest": true + }, + "test": { + "executable": "pwsh", + "input": "env", + "args": [ + "-NoLogo", + "-NonInteractive", + "-NoProfile", + "-Command", + "\"{ `\"Hello`\": `\"$env:Hello`\", `\"World`\": `\"$env:World`\", `\"Boolean`\": `\"$env:Boolean`\", `\"StringArray`\": `\"$env:StringArray`\", `\"NumberArray`\": `\"$env:NumberArray`\" }\"" + ] + }, + "schema": { + "embedded": { + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://test", + "title": "test", + "description": "test", + "type": "object", + "required": [], + "additionalProperties": false, + "properties": { + "Hello": { + "type": "string", + "description": "test" + }, + "World": { + "type": "number", + "description": "test" + }, + "Boolean": { + "type": "boolean", + "description": "test" + }, + "StringArray": { + "type": "array", + "description": "test", + "items": { + "type": "string" + } + }, + "NumberArray": { + "type": "array", + "description": "test", + "items": { + "type": "number" + } + } + } + } + } + } +'@ + $oldPath = $env:DSC_RESOURCE_PATH + $env:DSC_RESOURCE_PATH = $TestDrive + Set-Content $TestDrive/EnvVarInput.dsc.resource.json -Value $manifest + } + + AfterAll { + $env:DSC_RESOURCE_PATH = $oldPath + } + + It 'Input can be sent to the resource for: ' -TestCases @( + @{ operation = 'get'; member = 'actualState' } + @{ operation = 'set'; member = 'afterState' } + @{ operation = 'test'; member = 'actualState' } + ) { + param($operation, $member) + + $json = @" + { + "Hello": "foo", + "World": 2, + "Boolean": true, + "StringArray": ["foo", "bar"], + "NumberArray": [1, 2, 3] + } +"@ + + $result = $json | dsc resource $operation -r Test/EnvVarInput | ConvertFrom-Json + $result.$member.Hello | Should -BeExactly 'foo' + $result.$member.World | Should -Be 2 + $result.$member.Boolean | Should -Be 'true' + $result.$member.StringArray | Should -BeExactly 'foo,bar' + $result.$member.NumberArray | Should -BeExactly '1,2,3' + } +} diff --git a/dsc_lib/src/discovery/command_discovery.rs b/dsc_lib/src/discovery/command_discovery.rs index 5f22ac16d..ee9121228 100644 --- a/dsc_lib/src/discovery/command_discovery.rs +++ b/dsc_lib/src/discovery/command_discovery.rs @@ -93,7 +93,7 @@ impl ResourceDiscovery for CommandDiscovery { let manifest = serde_json::from_value::(provider_resource.manifest.clone().unwrap())?; // invoke the list command let list_command = manifest.provider.unwrap().list; - let (exit_code, stdout, stderr) = match invoke_command(&list_command.executable, list_command.args, None, Some(&provider_resource.directory)) + let (exit_code, stdout, stderr) = match invoke_command(&list_command.executable, list_command.args, None, Some(&provider_resource.directory), None) { Ok((exit_code, stdout, stderr)) => (exit_code, stdout, stderr), Err(e) => { diff --git a/dsc_lib/src/dscresources/command_resource.rs b/dsc_lib/src/dscresources/command_resource.rs index 56403a27f..a8512aa65 100644 --- a/dsc_lib/src/dscresources/command_resource.rs +++ b/dsc_lib/src/dscresources/command_resource.rs @@ -3,10 +3,9 @@ use jsonschema::JSONSchema; use serde_json::Value; -use std::{process::Command, io::{Write, Read}, process::Stdio}; - +use std::{collections::HashMap, process::Command, io::{Write, Read}, process::Stdio}; use crate::dscerror::DscError; -use super::{dscresource::get_diff,resource_manifest::{ResourceManifest, ReturnKind, SchemaKind}, invoke_result::{GetResult, SetResult, TestResult, ValidateResult, ExportResult}}; +use super::{dscresource::get_diff,resource_manifest::{ResourceManifest, InputKind, ReturnKind, SchemaKind}, invoke_result::{GetResult, SetResult, TestResult, ValidateResult, ExportResult}}; pub const EXIT_PROCESS_TERMINATED: i32 = 0x102; @@ -21,12 +20,27 @@ pub const EXIT_PROCESS_TERMINATED: i32 = 0x102; /// /// Error returned if the resource does not successfully get the current state pub fn invoke_get(resource: &ResourceManifest, cwd: &str, filter: &str) -> Result { - if !filter.is_empty() && resource.get.input.is_some() { + let input_kind = if let Some(input_kind) = &resource.get.input { + input_kind.clone() + } + else { + InputKind::Stdin + }; + + let mut env: Option> = None; + let mut input_filter: Option<&str> = None; + if !filter.is_empty() { verify_json(resource, cwd, filter)?; + + if input_kind == InputKind::Env { + env = Some(json_to_hashmap(filter)?); + } + else { + input_filter = Some(filter); + } } - let (exit_code, stdout, stderr) = invoke_command(&resource.get.executable, resource.get.args.clone(), Some(filter), Some(cwd))?; - //println!("{stdout}"); + let (exit_code, stdout, stderr) = invoke_command(&resource.get.executable, resource.get.args.clone(), input_filter, Some(cwd), env)?; if exit_code != 0 { return Err(DscError::Command(resource.resource_type.clone(), exit_code, stderr)); } @@ -59,6 +73,16 @@ pub fn invoke_set(resource: &ResourceManifest, cwd: &str, desired: &str, skip_te return Err(DscError::NotImplemented("set".to_string())); }; verify_json(resource, cwd, desired)?; + + let mut env: Option> = None; + let mut input_desired: Option<&str> = None; + if set.input == InputKind::Env { + env = Some(json_to_hashmap(desired)?); + } + else { + input_desired = Some(desired); + } + // if resource doesn't implement a pre-test, we execute test first to see if a set is needed if !skip_test && !set.pre_test.unwrap_or_default() { let test_result = invoke_test(resource, cwd, desired)?; @@ -70,14 +94,34 @@ pub fn invoke_set(resource: &ResourceManifest, cwd: &str, desired: &str, skip_te }); } } - let (exit_code, stdout, stderr) = invoke_command(&resource.get.executable, resource.get.args.clone(), Some(desired), Some(cwd))?; + + let mut get_env: Option> = None; + let mut get_input: Option<&str> = None; + match &resource.get.input { + Some(InputKind::Env) => { + get_env = Some(json_to_hashmap(desired)?); + }, + Some(InputKind::Stdin) => { + get_input = Some(desired); + }, + None => { + // leave input as none + }, + } + + let (exit_code, stdout, stderr) = invoke_command(&resource.get.executable, resource.get.args.clone(), get_input, Some(cwd), get_env)?; + if exit_code != 0 { + return Err(DscError::Command(resource.resource_type.clone(), exit_code, stderr)); + } + let pre_state: Value = if exit_code == 0 { serde_json::from_str(&stdout)? } else { return Err(DscError::Command(resource.resource_type.clone(), exit_code, stderr)); }; - let (exit_code, stdout, stderr) = invoke_command(&set.executable, set.args.clone(), Some(desired), Some(cwd))?; + + let (exit_code, stdout, stderr) = invoke_command(&set.executable, set.args.clone(), input_desired, Some(cwd), env)?; if exit_code != 0 { return Err(DscError::Command(resource.resource_type.clone(), exit_code, stderr)); } @@ -147,7 +191,17 @@ pub fn invoke_test(resource: &ResourceManifest, cwd: &str, expected: &str) -> Re }; verify_json(resource, cwd, expected)?; - let (exit_code, stdout, stderr) = invoke_command(&test.executable, test.args.clone(), Some(expected), Some(cwd))?; + + let mut env: Option> = None; + let mut input_expected: Option<&str> = None; + if test.input == InputKind::Env { + env = Some(json_to_hashmap(expected)?); + } + else { + input_expected = Some(expected); + } + + let (exit_code, stdout, stderr) = invoke_command(&test.executable, test.args.clone(), input_expected, Some(cwd), env)?; if exit_code != 0 { return Err(DscError::Command(resource.resource_type.clone(), exit_code, stderr)); } @@ -222,7 +276,7 @@ pub fn invoke_validate(resource: &ResourceManifest, cwd: &str, config: &str) -> return Err(DscError::NotImplemented("validate".to_string())); }; - let (exit_code, stdout, stderr) = invoke_command(&validate.executable, validate.args.clone(), Some(config), Some(cwd))?; + let (exit_code, stdout, stderr) = invoke_command(&validate.executable, validate.args.clone(), Some(config), Some(cwd), None)?; if exit_code != 0 { return Err(DscError::Command(resource.resource_type.clone(), exit_code, stderr)); } @@ -247,7 +301,7 @@ pub fn get_schema(resource: &ResourceManifest, cwd: &str) -> Result { - let (exit_code, stdout, stderr) = invoke_command(&command.executable, command.args.clone(), None, Some(cwd))?; + let (exit_code, stdout, stderr) = invoke_command(&command.executable, command.args.clone(), None, Some(cwd), None)?; if exit_code != 0 { return Err(DscError::Command(resource.resource_type.clone(), exit_code, stderr)); } @@ -291,7 +345,7 @@ pub fn invoke_export(resource: &ResourceManifest, cwd: &str) -> Result Result>, input: Option<&str>, cwd: Option<&str>) -> Result<(i32, String, String), DscError> { +#[allow(clippy::implicit_hasher)] +pub fn invoke_command(executable: &str, args: Option>, input: Option<&str>, cwd: Option<&str>, env: Option>) -> Result<(i32, String, String), DscError> { let mut command = Command::new(executable); if input.is_some() { command.stdin(Stdio::piped()); @@ -337,6 +392,9 @@ pub fn invoke_command(executable: &str, args: Option>, input: Option if let Some(cwd) = cwd { command.current_dir(cwd); } + if let Some(env) = env { + command.envs(env); + } let mut child = command.spawn()?; if input.is_some() { @@ -399,3 +457,48 @@ fn verify_json(resource: &ResourceManifest, cwd: &str, json: &str) -> Result<(), }; result } + +fn json_to_hashmap(json: &str) -> Result, DscError> { + let mut map = HashMap::new(); + let json: Value = serde_json::from_str(json)?; + if let Value::Object(obj) = json { + for (key, value) in obj { + match value { + Value::String(s) => { + map.insert(key, s); + }, + Value::Bool(b) => { + map.insert(key, b.to_string()); + }, + Value::Number(n) => { + map.insert(key, n.to_string()); + }, + Value::Array(a) => { + // only array of number or strings is supported + let mut array = Vec::new(); + for v in a { + match v { + Value::String(s) => { + array.push(s); + }, + Value::Number(n) => { + array.push(n.to_string()); + }, + _ => { + return Err(DscError::Operation(format!("Unsupported array value for key {key}. Only string and number is supported."))); + }, + } + } + map.insert(key, array.join(",")); + }, + Value::Null => { + continue; + } + Value::Object(_) => { + return Err(DscError::Operation(format!("Unsupported value for key {key}. Only string, bool, number, and array is supported."))); + }, + } + } + } + Ok(map) +} diff --git a/dsc_lib/src/dscresources/resource_manifest.rs b/dsc_lib/src/dscresources/resource_manifest.rs index 1ee852d61..93f6f3850 100644 --- a/dsc_lib/src/dscresources/resource_manifest.rs +++ b/dsc_lib/src/dscresources/resource_manifest.rs @@ -48,9 +48,9 @@ pub struct ResourceManifest { #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] pub enum InputKind { - /// The input is accepted as named parameters. - #[serde(rename = "args")] - Args, + /// The input is accepted as environmental variables. + #[serde(rename = "env")] + Env, /// The input is accepted as a JSON object via STDIN. #[serde(rename = "stdin")] Stdin, From 1a12a19cf55fec3b88062c7195a999e7d11e66e6 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Tue, 19 Sep 2023 14:16:33 -0700 Subject: [PATCH 2/3] fix osinfo test --- osinfo/tests/osinfo.tests.ps1 | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/osinfo/tests/osinfo.tests.ps1 b/osinfo/tests/osinfo.tests.ps1 index c0eba9c12..b88ed02c5 100644 --- a/osinfo/tests/osinfo.tests.ps1 +++ b/osinfo/tests/osinfo.tests.ps1 @@ -25,7 +25,13 @@ Describe 'osinfo resource tests' { } It 'should perform synthetic test' { - $out = '{"family": "does_not_exist"}' | dsc resource test -r '*osinfo' | ConvertFrom-Json + if ($IsWindows) { + $invalid = 'Linux' + } + else { + $invalid = 'Windows' + } + $out = "{`"family`": `"$invalid`"}" | dsc resource test -r '*osinfo' | ConvertFrom-Json $actual = dsc resource get -r Microsoft/OSInfo | ConvertFrom-Json $out.actualState.family | Should -BeExactly $actual.actualState.family $out.actualState.version | Should -BeExactly $actual.actualState.version From 5e37379a493b6c1d0e4fc12114f8fe3fad551cc6 Mon Sep 17 00:00:00 2001 From: Steve Lee Date: Tue, 19 Sep 2023 15:29:14 -0700 Subject: [PATCH 3/3] make PSGroupResource get explicitly expect stdin input --- powershellgroup/powershellgroup.dsc.resource.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/powershellgroup/powershellgroup.dsc.resource.json b/powershellgroup/powershellgroup.dsc.resource.json index 340197ec8..f34191a65 100644 --- a/powershellgroup/powershellgroup.dsc.resource.json +++ b/powershellgroup/powershellgroup.dsc.resource.json @@ -27,7 +27,8 @@ "-NoProfile", "-Command", "$Input | ./powershellgroup.resource.ps1 Get" - ] + ], + "input": "stdin" }, "set": { "executable": "pwsh",