diff --git a/dsc/include.dsc.resource.json b/dsc/include.dsc.resource.json index 2ee13406..85c99da0 100644 --- a/dsc/include.dsc.resource.json +++ b/dsc/include.dsc.resource.json @@ -1,34 +1,57 @@ { - "$schema": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2024/04/bundled/resource/manifest.json", - "type": "Microsoft.DSC/Include", - "version": "0.1.0", - "description": "Allows including a configuration file contents into current configuration.", - "kind": "Import", - "resolve": { - "executable": "dsc", - "args": [ - "config", - "resolve" - ], - "input": "stdin" - }, - "exitCodes": { - "0": "Success", - "1": "Invalid argument", - "2": "Resource error", - "3": "JSON Serialization error", - "4": "Invalid input format", - "5": "Resource instance failed schema validation", - "6": "Command cancelled" - }, - "schema": { - "command": { - "executable": "dsc", - "args": [ - "schema", - "--type", - "include" - ] - } - } + "$schema": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2024/04/bundled/resource/manifest.json", + "type": "Microsoft.DSC/Include", + "version": "0.1.0", + "description": "Allows including a configuration file with optional parameter file.", + "kind": "Import", + "get": { + "executable": "dsc", + "args": [ + "config", + "--as-include", + "--as-group", + "get" + ], + "input": "stdin" + }, + "set": { + "executable": "dsc", + "args": [ + "config", + "--as-include", + "--as-group", + "set" + ], + "input": "stdin", + "implementsPretest": true, + "return": "state" + }, + "test": { + "executable": "dsc", + "args": [ + "config", + "--as-include", + "--as-group", + "test" + ], + "input": "stdin" + }, + "exitCodes": { + "0": "Success", + "1": "Invalid argument", + "2": "Resource error", + "3": "JSON Serialization error", + "4": "Invalid input format", + "5": "Resource instance failed schema validation", + "6": "Command cancelled" + }, + "validate": { + "executable": "dsc", + "args": [ + "config", + "--as-include", + "validate" + ], + "input": "stdin" } +} diff --git a/dsc/src/args.rs b/dsc/src/args.rs index e6dd75ec..cf1fc78d 100644 --- a/dsc/src/args.rs +++ b/dsc/src/args.rs @@ -57,6 +57,9 @@ pub enum SubCommand { // Used to inform when DSC is used as a group resource to modify it's output #[clap(long, hide = true)] as_group: bool, + // Used to inform when DSC is used as a include group resource + #[clap(long, hide = true)] + as_include: bool, }, #[clap(name = "resource", about = "Invoke a specific DSC resource")] Resource { diff --git a/dsc/src/main.rs b/dsc/src/main.rs index d516c727..4db7beb6 100644 --- a/dsc/src/main.rs +++ b/dsc/src/main.rs @@ -69,11 +69,11 @@ fn main() { let mut cmd = Args::command(); generate(shell, &mut cmd, "dsc", &mut io::stdout()); }, - SubCommand::Config { subcommand, parameters, parameters_file, as_group } => { + SubCommand::Config { subcommand, parameters, parameters_file, as_group, as_include } => { if let Some(file_name) = parameters_file { info!("Reading parameters from file {file_name}"); match std::fs::read_to_string(&file_name) { - Ok(parameters) => subcommand::config(&subcommand, &Some(parameters), &input, &as_group), + Ok(parameters) => subcommand::config(&subcommand, &Some(parameters), &input, &as_group, &as_include), Err(err) => { error!("Error: Failed to read parameters file '{file_name}': {err}"); exit(util::EXIT_INVALID_INPUT); @@ -81,7 +81,7 @@ fn main() { } } else { - subcommand::config(&subcommand, ¶meters, &input, &as_group); + subcommand::config(&subcommand, ¶meters, &input, &as_group, &as_include); } }, SubCommand::Resource { subcommand } => { diff --git a/dsc/src/subcommand.rs b/dsc/src/subcommand.rs index 0161a33f..fc264383 100644 --- a/dsc/src/subcommand.rs +++ b/dsc/src/subcommand.rs @@ -2,12 +2,12 @@ // Licensed under the MIT License. use crate::args::{ConfigSubCommand, DscType, OutputFormat, ResourceSubCommand}; -use crate::resolve::get_contents; +use crate::resolve::{get_contents, Include}; use crate::resource_command::{get_resource, self}; use crate::Stream; use crate::tablewriter::Table; -use crate::util::{DSC_CONFIG_ROOT, EXIT_DSC_ERROR, EXIT_INVALID_INPUT, EXIT_JSON_ERROR, EXIT_VALIDATION_FAILED, get_schema, write_output, get_input, set_dscconfigroot, validate_json}; -use dsc_lib::configure::{Configurator, config_doc::ExecutionKind, config_result::ResourceGetResult}; +use crate::util::{DSC_CONFIG_ROOT, EXIT_DSC_ERROR, EXIT_INVALID_INPUT, EXIT_JSON_ERROR, get_schema, write_output, get_input, set_dscconfigroot, validate_json}; +use dsc_lib::configure::{Configurator, config_doc::{Configuration, ExecutionKind}, config_result::ResourceGetResult}; use dsc_lib::dscerror::DscError; use dsc_lib::dscresources::invoke_result::ResolveResult; use dsc_lib::{ @@ -186,7 +186,7 @@ fn initialize_config_root(path: &Option) -> Option { } #[allow(clippy::too_many_lines)] -pub fn config(subcommand: &ConfigSubCommand, parameters: &Option, stdin: &Option, as_group: &bool) { +pub fn config(subcommand: &ConfigSubCommand, parameters: &Option, stdin: &Option, as_group: &bool, as_include: &bool) { let (new_parameters, json_string) = match subcommand { ConfigSubCommand::Get { document, path, .. } | ConfigSubCommand::Set { document, path, .. } | @@ -194,7 +194,19 @@ pub fn config(subcommand: &ConfigSubCommand, parameters: &Option, stdin: ConfigSubCommand::Validate { document, path, .. } | ConfigSubCommand::Export { document, path, .. } => { let new_path = initialize_config_root(path); - (None, get_input(document, stdin, &new_path)) + let input = get_input(document, stdin, &new_path); + if *as_include { + let (new_parameters, config_json) = match get_contents(&input) { + Ok((parameters, config_json)) => (parameters, config_json), + Err(err) => { + error!("{err}"); + exit(EXIT_DSC_ERROR); + } + }; + (new_parameters, config_json) + } else { + (None, input) + } }, ConfigSubCommand::Resolve { document, path, .. } => { let new_path = initialize_config_root(path); @@ -273,21 +285,34 @@ pub fn config(subcommand: &ConfigSubCommand, parameters: &Option, stdin: ConfigSubCommand::Test { format, as_get, .. } => { config_test(&mut configurator, format, as_group, as_get); }, - ConfigSubCommand::Validate { format, .. } => { + ConfigSubCommand::Validate { document, path, format} => { let mut result = ValidateResult { valid: true, reason: None, }; - let valid = match validate_config(&json_string) { - Ok(()) => { - true - }, - Err(err) => { - error!("{err}"); - result.valid = false; - false + if *as_include { + let new_path = initialize_config_root(path); + let input = get_input(document, stdin, &new_path); + match serde_json::from_str::(&input) { + Ok(_) => { + // valid, so do nothing + }, + Err(err) => { + error!("Error: Failed to deserialize Include input: {err}"); + result.valid = false; + } } - }; + } else { + match validate_config(configurator.get_config()) { + Ok(()) => { + // valid, so do nothing + }, + Err(err) => { + error!("{err}"); + result.valid = false; + } + }; + } let Ok(json) = serde_json::to_string(&result) else { error!("Failed to convert validation result to JSON"); @@ -295,9 +320,6 @@ pub fn config(subcommand: &ConfigSubCommand, parameters: &Option, stdin: }; write_output(&json, format); - if !valid { - exit(EXIT_VALIDATION_FAILED); - } }, ConfigSubCommand::Export { format, .. } => { config_export(&mut configurator, format); @@ -349,11 +371,11 @@ pub fn config(subcommand: &ConfigSubCommand, parameters: &Option, stdin: /// # Errors /// /// * `DscError` - The error that occurred. -pub fn validate_config(config: &str) -> Result<(), DscError> { +pub fn validate_config(config: &Configuration) -> Result<(), DscError> { // first validate against the config schema debug!("Validating configuration against schema"); let schema = serde_json::to_value(get_schema(DscType::Configuration))?; - let config_value = serde_json::from_str(config)?; + let config_value = serde_json::to_value(config)?; validate_json("Configuration", &schema, &config_value)?; let mut dsc = DscManager::new()?; diff --git a/dsc/src/util.rs b/dsc/src/util.rs index e9b9d002..8a160986 100644 --- a/dsc/src/util.rs +++ b/dsc/src/util.rs @@ -425,8 +425,8 @@ pub fn get_input(input: &Option, stdin: &Option, path: &Option $TestDrive/error.txt $err = Get-Content $testdrive/error.txt -Raw $err.Length | Should -Not -Be 0 - $LASTEXITCODE | Should -Be 1 + $LASTEXITCODE | Should -Be 4 } It 'input cannot be empty if neither stdin or path is provided' { dsc resource set -r Microsoft/OSInfo --input " " 2> $TestDrive/error.txt $err = Get-Content $testdrive/error.txt -Raw $err.Length | Should -Not -Be 0 - $LASTEXITCODE | Should -Be 1 + $LASTEXITCODE | Should -Be 4 } It 'path contents cannot be empty if neither stdin or input is provided' { @@ -201,7 +201,7 @@ resources: dsc resource set -r Microsoft/OSInfo --path $TestDrive/empty.yaml 2> $TestDrive/error.txt $err = Get-Content $testdrive/error.txt -Raw $err.Length | Should -Not -Be 0 - $LASTEXITCODE | Should -Be 1 + $LASTEXITCODE | Should -Be 4 } It 'document cannot be empty if neither stdin or path is provided' { diff --git a/dsc/tests/dsc_include.tests.ps1 b/dsc/tests/dsc_include.tests.ps1 index bcce188d..1f6a2c3e 100644 --- a/dsc/tests/dsc_include.tests.ps1 +++ b/dsc/tests/dsc_include.tests.ps1 @@ -170,9 +170,9 @@ resources: $out.results[0].result[0].result.actualState.output | Should -Be 'one' $out.results[1].result[0].name | Should -Be 'nested' $out.results[1].result[0].type | Should -Be 'Microsoft.DSC/Include' - $out.results[1].result[0].result[0].name | Should -Be 'one' - $out.results[1].result[0].result[0].type | Should -Be 'Test/Echo' - $out.results[1].result[0].result[0].result[0].actualState.output | Should -Be 'one' + $out.results[1].result[0].result.actualState.name | Should -Be 'one' + $out.results[1].result[0].result.actualState.type | Should -Be 'Test/Echo' + $out.results[1].result[0].result.actualState.result.actualState.output | Should -Be 'one' } It 'Set with include works' { @@ -203,9 +203,39 @@ resources: $out = dsc config set -d $includeConfig | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 - $out.results[0].result[0].name | Should -Be 'one' - $out.results[0].result[0].type | Should -Be 'Test/Echo' - $out.results[0].result[0].result.afterState.output | Should -Be 'Hello World' + $out.results[0].result.beforeState[0].name | Should -Be 'one' + $out.results[0].result.beforeState[0].type | Should -Be 'Test/Echo' + $out.results[0].result.afterState[0].result.afterState.output | Should -Be 'Hello World' $out.hadErrors | Should -Be $false } + + It 'Test with include works' { + $includeYaml = Join-Path $PSScriptRoot ../../dsc/examples/include.dsc.yaml + $out = dsc config test -p $includeYaml | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.results[0].type | Should -BeExactly 'Microsoft.DSC/Include' + $out.results[0].result[0].name | Should -BeExactly 'os' + $out.results[0].result[0].type | Should -BeExactly 'Microsoft/OSInfo' + $out.results[0].result[0].result.desiredState.family | Should -BeExactly 'macOS' + + $family = if ($isWindows) { + 'Windows' + } elseif ($IsLinux) { + 'Linux' + } elseif ($IsMacOS) { + 'macOS' + } else { + 'Unknown' + } + + $out.results[0].result[0].result.actualState.family | Should -BeExactly $family + ($expectedState, $expectedDiff) = if ($IsMacOS) { + $true, 0 + } else { + $false, 1 + } + + $out.results[0].result[0].result.inDesiredState | Should -Be $expectedState + $out.results[0].result[0].result.differingProperties.Count | Should -Be $expectedDiff + } } diff --git a/dsc_lib/src/configure/mod.rs b/dsc_lib/src/configure/mod.rs index 8bee22fd..2678104f 100644 --- a/dsc_lib/src/configure/mod.rs +++ b/dsc_lib/src/configure/mod.rs @@ -31,7 +31,8 @@ pub mod depends_on; pub mod parameters; pub struct Configurator { - config: String, + json: String, + config: Configuration, pub context: Context, discovery: Discovery, statement_parser: Statement, @@ -200,14 +201,27 @@ impl Configurator { /// # Errors /// /// This function will return an error if the configuration is invalid or the underlying discovery fails. - pub fn new(config: &str) -> Result { + pub fn new(json: &str) -> Result { let discovery = Discovery::new()?; - Ok(Configurator { - config: config.to_owned(), + let mut config = Configurator { + json: json.to_owned(), + config: Configuration::new(), context: Context::new(), discovery, statement_parser: Statement::new()?, - }) + }; + config.validate_config()?; + Ok(config) + } + + /// Get the configuration. + /// + /// # Returns + /// + /// * `&Configuration` - The configuration. + #[must_use] + pub fn get_config(&self) -> &Configuration { + &self.config } /// Invoke the get operation on a resource. @@ -220,9 +234,8 @@ impl Configurator { /// /// This function will return an error if the underlying resource fails. pub fn invoke_get(&mut self) -> Result { - let config = self.validate_config()?; let mut result = ConfigurationGetResult::new(); - let resources = get_resource_invocation_order(&config, &mut self.statement_parser, &self.context)?; + let resources = get_resource_invocation_order(&self.config, &mut self.statement_parser, &self.context)?; let pb_span = get_progress_bar_span(resources.len() as u64)?; let pb_span_enter = pb_span.enter(); for resource in resources { @@ -279,9 +292,8 @@ impl Configurator { /// /// This function will return an error if the underlying resource fails. pub fn invoke_set(&mut self, skip_test: bool) -> Result { - let config = self.validate_config()?; let mut result = ConfigurationSetResult::new(); - let resources = get_resource_invocation_order(&config, &mut self.statement_parser, &self.context)?; + let resources = get_resource_invocation_order(&self.config, &mut self.statement_parser, &self.context)?; let pb_span = get_progress_bar_span(resources.len() as u64)?; let pb_span_enter = pb_span.enter(); for resource in resources { @@ -388,9 +400,8 @@ impl Configurator { /// /// This function will return an error if the underlying resource fails. pub fn invoke_test(&mut self) -> Result { - let config = self.validate_config()?; let mut result = ConfigurationTestResult::new(); - let resources = get_resource_invocation_order(&config, &mut self.statement_parser, &self.context)?; + let resources = get_resource_invocation_order(&self.config, &mut self.statement_parser, &self.context)?; let pb_span = get_progress_bar_span(resources.len() as u64)?; let pb_span_enter = pb_span.enter(); for resource in resources { @@ -443,14 +454,13 @@ impl Configurator { /// /// This function will return an error if the underlying resource fails. pub fn invoke_export(&mut self) -> Result { - let config = self.validate_config()?; - let mut result = ConfigurationExportResult::new(); let mut conf = config_doc::Configuration::new(); - let pb_span = get_progress_bar_span(config.resources.len() as u64)?; + let pb_span = get_progress_bar_span(self.config.resources.len() as u64)?; let pb_span_enter = pb_span.enter(); - for resource in config.resources { + let resources = self.config.resources.clone(); + for resource in &resources { Span::current().pb_inc(1); pb_span.pb_set_message(format!("Export '{}'", resource.name).as_str()); let properties = self.invoke_property_expressions(&resource.properties)?; @@ -480,7 +490,7 @@ impl Configurator { /// This function will return an error if the parameters are invalid. pub fn set_parameters(&mut self, parameters_input: &Option) -> Result<(), DscError> { // set default parameters first - let config = serde_json::from_str::(self.config.as_str())?; + let config = serde_json::from_str::(self.json.as_str())?; let Some(parameters) = &config.parameters else { if parameters_input.is_none() { debug!("No parameters defined in configuration and no parameters input"); @@ -534,6 +544,12 @@ impl Configurator { info!("Set parameter '{name}' to '{value}'"); } self.context.parameters.insert(name.clone(), (value.clone(), constraint.parameter_type.clone())); + // also update the configuration with the parameter value + if let Some(parameters) = &mut self.config.parameters { + if let Some(parameter) = parameters.get_mut(&name) { + parameter.default_value = Some(value); + } + } } else { return Err(DscError::Validation(format!("Parameter '{name}' not defined in configuration"))); @@ -592,14 +608,15 @@ impl Configurator { Ok(()) } - fn validate_config(&mut self) -> Result { - let config: Configuration = serde_json::from_str(self.config.as_str())?; + fn validate_config(&mut self) -> Result<(), DscError> { + let config: Configuration = serde_json::from_str(self.json.as_str())?; check_security_context(&config.metadata)?; // Perform discovery of resources used in config let required_resources = config.resources.iter().map(|p| p.resource_type.clone()).collect::>(); self.discovery.find_resources(&required_resources); - Ok(config) + self.config = config; + Ok(()) } fn invoke_property_expressions(&mut self, properties: &Option>) -> Result>, DscError> { diff --git a/dsc_lib/src/dscresources/command_resource.rs b/dsc_lib/src/dscresources/command_resource.rs index 30f87b66..30a44952 100644 --- a/dsc_lib/src/dscresources/command_resource.rs +++ b/dsc_lib/src/dscresources/command_resource.rs @@ -4,7 +4,7 @@ use jsonschema::JSONSchema; use serde_json::Value; use std::{collections::HashMap, env, process::Stdio}; -use crate::{configure::{config_doc::ExecutionKind, {config_result::ResourceGetResult, parameters, Configurator}}, util::parse_input_to_json}; +use crate::configure::{config_doc::ExecutionKind, config_result::{ResourceGetResult, ResourceTestResult}}; use crate::dscerror::DscError; use super::{dscresource::get_diff, invoke_result::{ExportResult, GetResult, ResolveResult, SetResult, TestResult, ValidateResult, ResourceGetResponse, ResourceSetResponse, ResourceTestResponse, get_in_desired_state}, resource_manifest::{ArgKind, InputKind, Kind, ResourceManifest, ReturnKind, SchemaKind}}; use tracing::{error, warn, info, debug, trace}; @@ -12,23 +12,6 @@ use tokio::{io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, process::Command}; pub const EXIT_PROCESS_TERMINATED: i32 = 0x102; -fn get_configurator(resource: &ResourceManifest, cwd: &str, filter: &str) -> Result { - let resolve_result = invoke_resolve(resource, cwd, filter)?; - let configuration = serde_json::to_string(&resolve_result.configuration)?; - let configuration_json = parse_input_to_json(&configuration)?; - let mut configurator = Configurator::new(&configuration_json)?; - let parameters = if let Some(parameters) = resolve_result.parameters { - let parameters_input = parameters::Input { - parameters, - }; - Some(serde_json::to_value(parameters_input)?) - } else { - None - }; - configurator.set_parameters(¶meters)?; - Ok(configurator) -} - /// Invoke the get operation on a resource /// /// # Arguments @@ -41,12 +24,6 @@ fn get_configurator(resource: &ResourceManifest, cwd: &str, filter: &str) -> Res /// Error returned if the resource does not successfully get the current state pub fn invoke_get(resource: &ResourceManifest, cwd: &str, filter: &str) -> Result { debug!("Invoking get for '{}'", &resource.resource_type); - if resource.kind == Some(Kind::Import) { - let mut configurator = get_configurator(resource, cwd, filter)?; - let config_result = configurator.invoke_get()?; - return Ok(GetResult::Group(config_result.results)); - } - let mut command_input = CommandInput { env: None, stdin: None }; let Some(get) = &resource.get else { return Err(DscError::NotImplemented("get".to_string())); @@ -96,12 +73,6 @@ pub fn invoke_get(resource: &ResourceManifest, cwd: &str, filter: &str) -> Resul #[allow(clippy::too_many_lines)] pub fn invoke_set(resource: &ResourceManifest, cwd: &str, desired: &str, skip_test: bool, execution_type: &ExecutionKind) -> Result { debug!("Invoking set for '{}'", &resource.resource_type); - if resource.kind == Some(Kind::Import) { - let mut configurator = get_configurator(resource, cwd, desired)?; - let config_result = configurator.invoke_set(skip_test)?; - return Ok(SetResult::Group(config_result.results)); - } - let operation_type: String; let mut is_synthetic_what_if = false; let set_method = match execution_type { @@ -276,12 +247,6 @@ pub fn invoke_set(resource: &ResourceManifest, cwd: &str, desired: &str, skip_te /// Error is returned if the underlying command returns a non-zero exit code. pub fn invoke_test(resource: &ResourceManifest, cwd: &str, expected: &str) -> Result { debug!("Invoking test for '{}'", &resource.resource_type); - if resource.kind == Some(Kind::Import) { - let mut configurator = get_configurator(resource, cwd, expected)?; - let config_result = configurator.invoke_test()?; - return Ok(TestResult::Group(config_result.results)); - } - let Some(test) = &resource.test else { info!("Resource '{}' does not implement test, performing synthetic test", &resource.resource_type); return invoke_synthetic_test(resource, cwd, expected); @@ -300,6 +265,12 @@ pub fn invoke_test(resource: &ResourceManifest, cwd: &str, expected: &str) -> Re verify_json(resource, cwd, &stdout)?; } + if resource.kind == Some(Kind::Import) { + debug!("Import resource kind, returning group test response"); + let group_test_response: Vec = serde_json::from_str(&stdout)?; + return Ok(TestResult::Group(group_test_response)); + } + let expected_value: Value = serde_json::from_str(expected)?; match test.returns { Some(ReturnKind::State) => { @@ -642,7 +613,7 @@ async fn run_process_async(executable: &str, args: Option>, input: O } filtered_stderr }); - + let exit_code = child_task.await.unwrap()?.code(); let stdout_result = stdout_task.await.unwrap(); let stderr_result = stderr_task.await.unwrap(); @@ -676,7 +647,7 @@ async fn run_process_async(executable: &str, args: Option>, input: O /// * `cwd` - Optional working directory to execute the command in /// * `env` - Optional environment variable mappings to add or update /// * `exit_codes` - Optional descriptions of exit codes -/// +/// /// # Errors /// /// Error is returned if the command fails to execute or stdin/stdout/stderr cannot be opened. diff --git a/dsc_lib/src/dscresources/dscresource.rs b/dsc_lib/src/dscresources/dscresource.rs index c0678d38..b72ab43d 100644 --- a/dsc_lib/src/dscresources/dscresource.rs +++ b/dsc_lib/src/dscresources/dscresource.rs @@ -234,15 +234,7 @@ impl Invoke for DscResource { let resource_manifest = import_manifest(manifest.clone())?; if resource_manifest.test.is_none() { let get_result = self.get(expected)?; - let desired_state = if self.kind == Kind::Import { - let config = self.resolve(expected)?.configuration; - // TODO: implement way to resolve entire config doc including expressions and parameters - // as the raw configuration (desired state) won't match the result, also convert the desired - // state to a TestResult so the comparison is consistent - serde_json::to_value(config["resources"].clone())? - } else { - serde_json::from_str(expected)? - }; + let desired_state = serde_json::from_str(expected)?; let actual_state = match get_result { GetResult::Group(results) => { let mut result_array: Vec = Vec::new();