diff --git a/dsc/assertion.dsc.resource.json b/dsc/assertion.dsc.resource.json index f95c1780..1449585b 100644 --- a/dsc/assertion.dsc.resource.json +++ b/dsc/assertion.dsc.resource.json @@ -7,7 +7,9 @@ "executable": "dsc", "args": [ "config", - "test" + "--as-group", + "test", + "--as-get" ], "input": "stdin" }, @@ -15,6 +17,7 @@ "executable": "dsc", "args": [ "config", + "--as-group", "test" ], "input": "stdin", @@ -25,7 +28,9 @@ "executable": "dsc", "args": [ "config", - "test" + "--as-group", + "test", + "--as-get" ], "input": "stdin", "return": "state" diff --git a/dsc/examples/brew.dsc.yaml b/dsc/examples/brew.dsc.yaml index bf1a94f7..4c9860db 100644 --- a/dsc/examples/brew.dsc.yaml +++ b/dsc/examples/brew.dsc.yaml @@ -9,7 +9,7 @@ resources: - name: os_check type: Microsoft/OSInfo properties: - family: MacOS + family: macOS - name: brew type: DSC.PackageManagement/Brew properties: diff --git a/dsc/examples/groups.dsc.yaml b/dsc/examples/groups.dsc.yaml new file mode 100644 index 00000000..734cfbe8 --- /dev/null +++ b/dsc/examples/groups.dsc.yaml @@ -0,0 +1,38 @@ +# Example for grouping and groups in groups +$schema: https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2023/08/config/document.json +resources: +- name: Last Group + type: DSC/Group + properties: + $schema: https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2023/08/config/document.json + resources: + - name: Last + type: Test/Echo + properties: + output: Last + dependsOn: + - "[resourceId('DSC/Group','First Group')]" +- name: First Group + type: DSC/Group + properties: + $schema: https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2023/08/config/document.json + resources: + - name: First + type: Test/Echo + properties: + output: First + - name: Nested Group + type: DSC/Group + properties: + $schema: https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2023/08/config/document.json + resources: + - name: Nested Second + type: Test/Echo + properties: + output: Nested Second + dependsOn: + - "[resourceId('Test/Echo','Nested First')]" + - name: Nested First + type: Test/Echo + properties: + output: Nested First diff --git a/dsc/group.dsc.resource.json b/dsc/group.dsc.resource.json index 5ecf8d7c..6e3fcfca 100644 --- a/dsc/group.dsc.resource.json +++ b/dsc/group.dsc.resource.json @@ -7,6 +7,7 @@ "executable": "dsc", "args": [ "config", + "--as-group", "get" ], "input": "stdin" @@ -15,6 +16,7 @@ "executable": "dsc", "args": [ "config", + "--as-group", "set" ], "input": "stdin", @@ -25,6 +27,7 @@ "executable": "dsc", "args": [ "config", + "--as-group", "test" ], "input": "stdin", diff --git a/dsc/parallel.dsc.resource.json b/dsc/parallel.dsc.resource.json index a74ac490..6f644eba 100644 --- a/dsc/parallel.dsc.resource.json +++ b/dsc/parallel.dsc.resource.json @@ -8,6 +8,7 @@ "args": [ "config", "--parallel", + "--as-group", "get" ], "input": "stdin" @@ -17,6 +18,7 @@ "args": [ "config", "--parallel", + "--as-group", "set" ], "input": "stdin", @@ -28,6 +30,7 @@ "args": [ "config", "--parallel", + "--as-group", "test" ], "input": "stdin", diff --git a/dsc/src/args.rs b/dsc/src/args.rs index ca74549b..0f470b1e 100644 --- a/dsc/src/args.rs +++ b/dsc/src/args.rs @@ -54,6 +54,8 @@ pub enum SubCommand { parameters: Option, #[clap(short = 'f', long, help = "Parameters to pass to the configuration as a JSON or YAML file", conflicts_with = "parameters")] parameters_file: Option, + #[clap(long, hide = true)] + as_group: bool, }, #[clap(name = "resource", about = "Invoke a specific DSC resource")] Resource { @@ -97,6 +99,8 @@ pub enum ConfigSubCommand { path: Option, #[clap(short = 'f', long, help = "The output format to use")] format: Option, + #[clap(long, hide = true)] + as_get: bool, }, #[clap(name = "validate", about = "Validate the current configuration", hide = true)] Validate { @@ -104,6 +108,8 @@ pub enum ConfigSubCommand { document: Option, #[clap(short = 'p', long, help = "The path to a file used as input to the configuration or resource", conflicts_with = "document")] path: Option, + #[clap(short = 'f', long, help = "The output format to use")] + format: Option, }, #[clap(name = "export", about = "Export the current configuration")] Export { diff --git a/dsc/src/main.rs b/dsc/src/main.rs index 4e3a5e01..cce6c0da 100644 --- a/dsc/src/main.rs +++ b/dsc/src/main.rs @@ -65,11 +65,11 @@ fn main() { let mut cmd = Args::command(); generate(shell, &mut cmd, "dsc", &mut io::stdout()); }, - SubCommand::Config { subcommand, parameters, parameters_file } => { + SubCommand::Config { subcommand, parameters, parameters_file, as_group } => { 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), + Ok(parameters) => subcommand::config(&subcommand, &Some(parameters), &input, &as_group), Err(err) => { error!("Error: Failed to read parameters file: {err}"); exit(util::EXIT_INVALID_INPUT); @@ -77,7 +77,7 @@ fn main() { } } else { - subcommand::config(&subcommand, ¶meters, &input); + subcommand::config(&subcommand, ¶meters, &input, &as_group); } }, SubCommand::Resource { subcommand } => { @@ -136,7 +136,13 @@ fn check_debug() { if env::var("DEBUG_DSC").is_ok() { eprintln!("attach debugger to pid {} and press a key to continue", std::process::id()); loop { - let event = event::read().unwrap(); + let event = match event::read() { + Ok(event) => event, + Err(err) => { + eprintln!("Error: Failed to read event: {err}"); + break; + } + }; if let event::Event::Key(key) = event { // workaround bug in 0.26+ https://github.com/crossterm-rs/crossterm/issues/752#issuecomment-1414909095 if key.kind == event::KeyEventKind::Press { diff --git a/dsc/src/resource_command.rs b/dsc/src/resource_command.rs index 2b75a09e..427a796d 100644 --- a/dsc/src/resource_command.rs +++ b/dsc/src/resource_command.rs @@ -5,7 +5,7 @@ use crate::args::OutputFormat; use crate::util::{EXIT_DSC_ERROR, EXIT_INVALID_ARGS, EXIT_JSON_ERROR, add_type_name_to_json, write_output}; use dsc_lib::configure::config_doc::Configuration; use dsc_lib::configure::add_resource_export_results_to_configuration; -use dsc_lib::dscresources::invoke_result::GetResult; +use dsc_lib::dscresources::invoke_result::{GetResult, ResourceGetResponse}; use dsc_lib::dscerror::DscError; use tracing::{error, debug}; @@ -79,9 +79,9 @@ pub fn get_all(dsc: &DscManager, resource_type: &str, format: &Option json, diff --git a/dsc/src/subcommand.rs b/dsc/src/subcommand.rs index 9145384f..dd9dbf15 100644 --- a/dsc/src/subcommand.rs +++ b/dsc/src/subcommand.rs @@ -3,35 +3,54 @@ use crate::args::{ConfigSubCommand, DscType, OutputFormat, ResourceSubCommand}; use crate::resource_command::{get_resource, self}; +use crate::Stream; use crate::tablewriter::Table; -use crate::util::{EXIT_DSC_ERROR, EXIT_INVALID_INPUT, EXIT_JSON_ERROR, EXIT_SUCCESS, EXIT_VALIDATION_FAILED, get_schema, write_output, get_input, set_dscconfigroot}; -use tracing::error; - -use atty::Stream; +use crate::util::{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, ErrorAction, config_result::ResourceGetResult}; +use dsc_lib::dscerror::DscError; +use dsc_lib::dscresources::invoke_result::{ + GroupResourceSetResponse, GroupResourceTestResponse, TestResult +}; use dsc_lib::{ - configure::{Configurator, ErrorAction}, DscManager, + dscresources::invoke_result::ValidateResult, dscresources::dscresource::{ImplementedAs, Invoke}, dscresources::resource_manifest::{import_manifest, ResourceManifest}, }; -use jsonschema::JSONSchema; use serde_yaml::Value; use std::process::exit; +use tracing::{debug, error, trace}; -pub fn config_get(configurator: &mut Configurator, format: &Option) +pub fn config_get(configurator: &mut Configurator, format: &Option, as_group: &bool) { match configurator.invoke_get(ErrorAction::Continue, || { /* code */ }) { Ok(result) => { - let json = match serde_json::to_string(&result) { - Ok(json) => json, - Err(err) => { - error!("JSON Error: {err}"); - exit(EXIT_JSON_ERROR); + if *as_group { + let mut group_result = Vec::::new(); + for result in result.results { + group_result.push(result); + }; + let json = match serde_json::to_string(&group_result) { + Ok(json) => json, + Err(err) => { + error!("JSON Error: {err}"); + exit(EXIT_JSON_ERROR); + } + }; + write_output(&json, format); + } + else { + let json = match serde_json::to_string(&result) { + Ok(json) => json, + Err(err) => { + error!("JSON Error: {err}"); + exit(EXIT_JSON_ERROR); + } + }; + write_output(&json, format); + if result.had_errors { + exit(EXIT_DSC_ERROR); } - }; - write_output(&json, format); - if result.had_errors { - exit(EXIT_DSC_ERROR); } }, Err(err) => { @@ -41,20 +60,35 @@ pub fn config_get(configurator: &mut Configurator, format: &Option } } -pub fn config_set(configurator: &mut Configurator, format: &Option) +pub fn config_set(configurator: &mut Configurator, format: &Option, as_group: &bool) { match configurator.invoke_set(false, ErrorAction::Continue, || { /* code */ }) { Ok(result) => { - let json = match serde_json::to_string(&result) { - Ok(json) => json, - Err(err) => { - error!("JSON Error: {err}"); - exit(EXIT_JSON_ERROR); + if *as_group { + let group_result = GroupResourceSetResponse { + results: result.results + }; + let json = match serde_json::to_string(&group_result) { + Ok(json) => json, + Err(err) => { + error!("JSON Error: {err}"); + exit(EXIT_JSON_ERROR); + } + }; + write_output(&json, format); + } + else { + let json = match serde_json::to_string(&result) { + Ok(json) => json, + Err(err) => { + error!("JSON Error: {err}"); + exit(EXIT_JSON_ERROR); + } + }; + write_output(&json, format); + if result.had_errors { + exit(EXIT_DSC_ERROR); } - }; - write_output(&json, format); - if result.had_errors { - exit(EXIT_DSC_ERROR); } }, Err(err) => { @@ -64,20 +98,68 @@ pub fn config_set(configurator: &mut Configurator, format: &Option } } -pub fn config_test(configurator: &mut Configurator, format: &Option) +pub fn config_test(configurator: &mut Configurator, format: &Option, as_group: &bool, as_get: &bool) { match configurator.invoke_test(ErrorAction::Continue, || { /* code */ }) { Ok(result) => { - let json = match serde_json::to_string(&result) { - Ok(json) => json, - Err(err) => { - error!("JSON Error: {err}"); - exit(EXIT_JSON_ERROR); + if *as_group { + let mut in_desired_state = true; + for test_result in &result.results { + match &test_result.result { + TestResult::Resource(resource_test_result) => { + if !resource_test_result.in_desired_state { + in_desired_state = false; + break; + } + }, + TestResult::Group(group_resource_test_result) => { + if !group_resource_test_result.in_desired_state { + in_desired_state = false; + break; + } + } + } + } + let json = if *as_get { + let mut group_result = Vec::::new(); + for test_result in result.results { + group_result.push(test_result.into()); + } + match serde_json::to_string(&group_result) { + Ok(json) => json, + Err(err) => { + error!("JSON Error: {err}"); + exit(EXIT_JSON_ERROR); + } + } + } + else { + let group_result = GroupResourceTestResponse { + results: result.results, + in_desired_state + }; + match serde_json::to_string(&group_result) { + Ok(json) => json, + Err(err) => { + error!("JSON Error: {err}"); + exit(EXIT_JSON_ERROR); + } + } + }; + write_output(&json, format); + } + else { + let json = match serde_json::to_string(&result) { + Ok(json) => json, + Err(err) => { + error!("JSON Error: {err}"); + exit(EXIT_JSON_ERROR); + } + }; + write_output(&json, format); + if result.had_errors { + exit(EXIT_DSC_ERROR); } - }; - write_output(&json, format); - if result.had_errors { - exit(EXIT_DSC_ERROR); } }, Err(err) => { @@ -116,7 +198,7 @@ pub fn config_export(configurator: &mut Configurator, format: &Option, stdin: &Option) { +pub fn config(subcommand: &ConfigSubCommand, parameters: &Option, stdin: &Option, as_group: &bool) { let json_string = match subcommand { ConfigSubCommand::Get { document, path, .. } | ConfigSubCommand::Set { document, path, .. } | @@ -170,16 +252,39 @@ pub fn config(subcommand: &ConfigSubCommand, parameters: &Option, stdin: match subcommand { ConfigSubCommand::Get { format, .. } => { - config_get(&mut configurator, format); + config_get(&mut configurator, format, as_group); }, ConfigSubCommand::Set { format, .. } => { - config_set(&mut configurator, format); + config_set(&mut configurator, format, as_group); }, - ConfigSubCommand::Test { format, .. } => { - config_test(&mut configurator, format); + ConfigSubCommand::Test { format, as_get, .. } => { + config_test(&mut configurator, format, as_group, as_get); }, - ConfigSubCommand::Validate { .. } => { - validate_config(&json_string); + ConfigSubCommand::Validate { 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 + } + }; + + let Ok(json) = serde_json::to_string(&result) else { + error!("Failed to convert validation result to JSON"); + exit(EXIT_JSON_ERROR); + }; + + write_output(&json, format); + if !valid { + exit(EXIT_VALIDATION_FAILED); + } }, ConfigSubCommand::Export { format, .. } => { config_export(&mut configurator, format); @@ -188,125 +293,92 @@ pub fn config(subcommand: &ConfigSubCommand, parameters: &Option, stdin: } /// Validate configuration. -#[allow(clippy::too_many_lines)] -pub fn validate_config(config: &str) { +/// +/// # Arguments +/// +/// * `config` - The configuration to validate. +/// +/// # Returns +/// +/// Nothing on success. +/// +/// # Errors +/// +/// * `DscError` - The error that occurred. +pub fn validate_config(config: &str) -> Result<(), DscError> { // first validate against the config schema - let schema = match serde_json::to_value(get_schema(DscType::Configuration)) { - Ok(schema) => schema, - Err(e) => { - error!("Error: Failed to convert schema to JSON: {e}"); - exit(EXIT_DSC_ERROR); - }, - }; - let compiled_schema = match JSONSchema::compile(&schema) { - Ok(schema) => schema, - Err(e) => { - error!("Error: Failed to compile schema: {e}"); - exit(EXIT_DSC_ERROR); - }, - }; - let config_value = match serde_json::from_str(config) { - Ok(config) => config, - Err(e) => { - error!("Error: Failed to parse configuration: {e}"); - exit(EXIT_INVALID_INPUT); - }, - }; - if let Err(err) = compiled_schema.validate(&config_value) { - let mut error = "Configuration failed validation: ".to_string(); - for e in err { - error.push_str(&format!("\n{e} ")); - } - error!("{error}"); - exit(EXIT_INVALID_INPUT); - }; - - let dsc = match DscManager::new() { - Ok(dsc) => dsc, - Err(err) => { - error!("Error: {err}"); - exit(EXIT_DSC_ERROR); - } - }; + debug!("Validating configuration against schema"); + let schema = serde_json::to_value(get_schema(DscType::Configuration))?; + let config_value = serde_json::from_str(config)?; + validate_json("Configuration", &schema, &config_value)?; + let mut dsc = DscManager::new()?; // then validate each resource let Some(resources) = config_value["resources"].as_array() else { - error!("Error: Resources not specified"); - exit(EXIT_INVALID_INPUT); + return Err(DscError::Validation("Error: Resources not specified".to_string())); }; + + // discover the resources + let mut resource_types = Vec::new(); for resource_block in resources { - let type_name = resource_block["type"].as_str().unwrap_or_else(|| { - error!("Error: Resource type not specified"); - exit(EXIT_INVALID_INPUT); - }); + let Some(type_name) = resource_block["type"].as_str() else { + return Err(DscError::Validation("Error: Resource type not specified".to_string())); + }; + + if resource_types.contains(&type_name.to_lowercase()) { + continue; + } + + resource_types.push(type_name.to_lowercase().to_string()); + } + dsc.discover_resources(&resource_types); + + for resource_block in resources { + let Some(type_name) = resource_block["type"].as_str() else { + return Err(DscError::Validation("Error: Resource type not specified".to_string())); + }; + + trace!("Validating resource named '{}'", resource_block["name"].as_str().unwrap_or_default()); + // get the actual resource let Some(resource) = get_resource(&dsc, type_name) else { - error!("Error: Resource type not found"); - exit(EXIT_DSC_ERROR); + return Err(DscError::Validation(format!("Error: Resource type '{type_name}' not found"))); }; + // see if the resource is command based if resource.implemented_as == ImplementedAs::Command { // if so, see if it implements validate via the resource manifest if let Some(manifest) = resource.manifest.clone() { // convert to resource_manifest - let manifest: ResourceManifest = match serde_json::from_value(manifest) { - Ok(manifest) => manifest, - Err(e) => { - error!("Error: Failed to parse resource manifest: {e}"); - exit(EXIT_INVALID_INPUT); - }, - }; + let manifest: ResourceManifest = serde_json::from_value(manifest)?; if manifest.validate.is_some() { - let result = match resource.validate(config) { - Ok(result) => result, - Err(e) => { - error!("Error: Failed to validate resource: {e}"); - exit(EXIT_VALIDATION_FAILED); - }, - }; + debug!("Resource {type_name} implements validation"); + // get the resource's part of the config + let resource_config = resource_block["properties"].to_string(); + let result = resource.validate(&resource_config)?; if !result.valid { let reason = result.reason.unwrap_or("No reason provided".to_string()); let type_name = resource.type_name.clone(); - error!("Resource {type_name} failed validation: {reason}"); - exit(EXIT_VALIDATION_FAILED); + return Err(DscError::Validation(format!("Resource {type_name} failed validation: {reason}"))); } } else { // use schema validation + trace!("Resource {type_name} does not implement validation, using schema"); let Ok(schema) = resource.schema() else { - error!("Error: Resource {type_name} does not have a schema nor supports validation"); - exit(EXIT_VALIDATION_FAILED); - }; - let schema = match serde_json::to_value(&schema) { - Ok(schema) => schema, - Err(e) => { - error!("Error: Failed to convert schema to JSON: {e}"); - exit(EXIT_DSC_ERROR); - }, - }; - let compiled_schema = match JSONSchema::compile(&schema) { - Ok(schema) => schema, - Err(e) => { - error!("Error: Failed to compile schema: {e}"); - exit(EXIT_DSC_ERROR); - }, - }; - let properties = resource_block["properties"].clone(); - let validation = compiled_schema.validate(&properties); - if let Err(err) = validation { - let mut error = String::new(); - for e in err { - error.push_str(&format!("{e} ")); - } - error!("Error: Resource {type_name} failed validation: {error}"); - exit(EXIT_VALIDATION_FAILED); + return Err(DscError::Validation(format!("Error: Resource {type_name} does not have a schema nor supports validation"))); }; + let schema = serde_json::from_str(&schema)?; + + validate_json(&resource.type_name, &schema, &resource_block["properties"])?; } + } else { + return Err(DscError::Validation(format!("Error: Resource {type_name} does not have a manifest"))); } } - } - exit(EXIT_SUCCESS); + + Ok(()) } pub fn resource(subcommand: &ResourceSubCommand, stdin: &Option) { diff --git a/dsc/src/util.rs b/dsc/src/util.rs index 2ca73de9..287ba02a 100644 --- a/dsc/src/util.rs +++ b/dsc/src/util.rs @@ -7,17 +7,26 @@ use atty::Stream; use dsc_lib::{ configure::{ config_doc::Configuration, - config_result::{ConfigurationGetResult, ConfigurationSetResult, ConfigurationTestResult} + config_result::{ + ConfigurationGetResult, + ConfigurationSetResult, + ConfigurationTestResult + } }, dscerror::DscError, dscresources::{ dscresource::DscResource, - invoke_result::{GetResult, SetResult, TestResult}, + invoke_result::{ + GetResult, + SetResult, + TestResult, + }, resource_manifest::ResourceManifest } }; +use jsonschema::JSONSchema; use schemars::{schema_for, schema::RootSchema}; -use serde_yaml::Value; +use serde_json::Value; use std::collections::HashMap; use std::env; use std::path::Path; @@ -28,7 +37,7 @@ use syntect::{ parsing::SyntaxSet, util::{as_24_bit_terminal_escaped, LinesWithEndings} }; -use tracing::{Level, debug, error}; +use tracing::{Level, debug, error, trace}; use tracing_subscriber::{filter::EnvFilter, layer::SubscriberExt, Layer}; pub const EXIT_SUCCESS: i32 = 0; @@ -292,6 +301,43 @@ pub fn enable_tracing(trace_level: &TraceLevel, trace_format: &TraceFormat) { } } +/// Validate the JSON against the schema. +/// +/// # Arguments +/// +/// * `source` - The source of the JSON +/// * `schema` - The schema to validate against +/// * `json` - The JSON to validate +/// +/// # Returns +/// +/// Nothing on success. +/// +/// # Errors +/// +/// * `DscError` - The JSON is invalid +pub fn validate_json(source: &str, schema: &Value, json: &Value) -> Result<(), DscError> { + debug!("Validating {source} against schema"); + trace!("JSON: {json}"); + trace!("Schema: {schema}"); + let compiled_schema = match JSONSchema::compile(schema) { + Ok(compiled_schema) => compiled_schema, + Err(err) => { + return Err(DscError::Validation(format!("JSON Schema Compilation Error: {err}"))); + } + }; + + if let Err(err) = compiled_schema.validate(json) { + let mut error = format!("'{source}' failed validation: "); + for e in err { + error.push_str(&format!("\n{e} ")); + } + return Err(DscError::Validation(error)); + }; + + Ok(()) +} + pub fn parse_input_to_json(value: &str) -> String { match serde_json::from_str(value) { Ok(json) => json, diff --git a/dsc/tests/dsc_brew.tests.ps1 b/dsc/tests/dsc_brew.tests.ps1 new file mode 100644 index 00000000..77a82cda --- /dev/null +++ b/dsc/tests/dsc_brew.tests.ps1 @@ -0,0 +1,22 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Brew resource tests' { + BeforeAll { + $brewExists = ($null -ne (Get-Command brew -CommandType Application -ErrorAction Ignore)) + } + + It 'Config get works' -Skip:(-not $brewExists) { + $out = dsc config get -p $PSScriptRoot/../examples/brew.dsc.yaml | ConvertFrom-Json -Depth 10 + $LASTEXITCODE | Should -Be 0 + $exists = $null -ne (Get-Command gitui -CommandType Application -ErrorAction Ignore) + $out.results[1].result.actualState._exist | Should -Be $exists + } + + It 'Config test works' -Skip:(-not $brewExists) { + $out = dsc config test -p $PSScriptRoot/../examples/brew.dsc.yaml | ConvertFrom-Json -Depth 10 + $LASTEXITCODE | Should -Be 0 + $exists = $null -ne (Get-Command gitui -CommandType Application -ErrorAction Ignore) + $out.results[1].result.inDesiredState | Should -Be $exists + } +} diff --git a/dsc/tests/dsc_group.tests.ps1 b/dsc/tests/dsc_group.tests.ps1 new file mode 100644 index 00000000..66b2259d --- /dev/null +++ b/dsc/tests/dsc_group.tests.ps1 @@ -0,0 +1,44 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Group resource tests' { + It 'Nested groups should work for get' { + $out = (dsc config get -p $PSScriptRoot/../examples/groups.dsc.yaml -f yaml | Out-String).Trim() + $LASTEXITCODE | Should -Be 0 + $out | Should -BeExactly @' +results: +- name: First Group + type: DSC/Group + result: + - name: First + type: Test/Echo + result: + actualState: + output: First + - name: Nested Group + type: DSC/Group + result: + - name: Nested First + type: Test/Echo + result: + actualState: + output: Nested First + - name: Nested Second + type: Test/Echo + result: + actualState: + output: Nested Second +- name: Last Group + type: DSC/Group + result: + - name: Last + type: Test/Echo + result: + actualState: + output: Last +messages: [] +hadErrors: false +'@ + } + +} diff --git a/dsc_lib/src/configure/config_result.rs b/dsc_lib/src/configure/config_result.rs index 96ab11ea..493a102c 100644 --- a/dsc_lib/src/configure/config_result.rs +++ b/dsc_lib/src/configure/config_result.rs @@ -32,6 +32,16 @@ pub struct ResourceGetResult { pub result: GetResult, } +impl From for ResourceGetResult { + fn from(test_result: ResourceTestResult) -> Self { + Self { + name: test_result.name, + resource_type: test_result.resource_type, + result: test_result.result.into(), + } + } +} + #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct ConfigurationGetResult { @@ -58,6 +68,20 @@ impl Default for ConfigurationGetResult { } } +impl From for ConfigurationGetResult { + fn from(test_result: ConfigurationTestResult) -> Self { + let mut results = Vec::::new(); + for result in test_result.results { + results.push(result.into()); + } + Self { + results, + messages: test_result.messages, + had_errors: test_result.had_errors, + } + } +} + #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct ResourceSetResult { @@ -67,6 +91,27 @@ pub struct ResourceSetResult { pub result: SetResult, } +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct GroupResourceSetResult { + pub results: Vec, +} + +impl GroupResourceSetResult { + #[must_use] + pub fn new() -> Self { + Self { + results: Vec::new(), + } + } +} + +impl Default for GroupResourceSetResult { + fn default() -> Self { + Self::new() + } +} + #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct ConfigurationSetResult { @@ -102,6 +147,27 @@ pub struct ResourceTestResult { pub result: TestResult, } +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct GroupResourceTestResult { + pub results: Vec, +} + +impl GroupResourceTestResult { + #[must_use] + pub fn new() -> Self { + Self { + results: Vec::new(), + } + } +} + +impl Default for GroupResourceTestResult { + fn default() -> Self { + Self::new() + } +} + #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct ConfigurationTestResult { diff --git a/dsc_lib/src/configure/depends_on.rs b/dsc_lib/src/configure/depends_on.rs index 0d7e123e..ff34375d 100644 --- a/dsc_lib/src/configure/depends_on.rs +++ b/dsc_lib/src/configure/depends_on.rs @@ -8,6 +8,8 @@ use crate::parser::Statement; use super::context::Context; +use tracing::{debug, trace}; + /// Gets the invocation order of resources based on their dependencies /// /// # Arguments @@ -22,6 +24,7 @@ use super::context::Context; /// /// * `DscError::Validation` - The configuration is invalid pub fn get_resource_invocation_order(config: &Configuration, parser: &mut Statement, context: &Context) -> Result, DscError> { + debug!("Getting resource invocation order"); let mut order: Vec = Vec::new(); for resource in &config.resources { // validate that the resource isn't specified more than once in the config @@ -84,6 +87,7 @@ pub fn get_resource_invocation_order(config: &Configuration, parser: &mut Statem order.push(resource.clone()); } + trace!("Resource invocation order: {0:?}", order); Ok(order) } diff --git a/dsc_lib/src/configure/mod.rs b/dsc_lib/src/configure/mod.rs index cb763684..e4290eda 100644 --- a/dsc_lib/src/configure/mod.rs +++ b/dsc_lib/src/configure/mod.rs @@ -165,6 +165,7 @@ impl Configurator { let config = self.validate_config()?; let mut result = ConfigurationGetResult::new(); for resource in get_resource_invocation_order(&config, &mut self.statement_parser, &self.context)? { + trace!("Get resource '{}' named: {}", resource.resource_type, resource.name); let properties = self.invoke_property_expressions(&resource.properties)?; let Some(dsc_resource) = self.discovery.find_resource(&resource.resource_type.to_lowercase()) else { return Err(DscError::ResourceNotFound(resource.resource_type)); @@ -274,7 +275,7 @@ impl Configurator { let mut result = ConfigurationExportResult::new(); let mut conf = config_doc::Configuration::new(); - for resource in &config.resources { + for resource in get_resource_invocation_order(&config, &mut self.statement_parser, &self.context)? { let Some(dsc_resource) = self.discovery.find_resource(&resource.resource_type.to_lowercase()) else { return Err(DscError::ResourceNotFound(resource.resource_type.clone())); }; diff --git a/dsc_lib/src/dscresources/command_resource.rs b/dsc_lib/src/dscresources/command_resource.rs index 766d82d9..af864f22 100644 --- a/dsc_lib/src/dscresources/command_resource.rs +++ b/dsc_lib/src/dscresources/command_resource.rs @@ -3,10 +3,11 @@ use jsonschema::JSONSchema; use serde_json::Value; -use std::{collections::HashMap, process::Command, io::{Write, Read}, process::Stdio}; -use crate::dscerror::DscError; +use std::{collections::HashMap, env, process::Command, io::{Write, Read}, process::Stdio}; +use crate::{dscerror::DscError, dscresources::invoke_result::{ResourceGetResponse, ResourceSetResponse, ResourceTestResponse}}; +use crate::configure::config_result::ResourceGetResult; use super::{dscresource::get_diff,resource_manifest::{ResourceManifest, InputKind, ReturnKind, SchemaKind}, invoke_result::{GetResult, SetResult, TestResult, ValidateResult, ExportResult}}; -use tracing::{debug, info}; +use tracing::{debug, info, trace}; pub const EXIT_PROCESS_TERMINATED: i32 = 0x102; @@ -53,16 +54,22 @@ pub fn invoke_get(resource: &ResourceManifest, cwd: &str, filter: &str) -> Resul return Err(DscError::Command(resource.resource_type.clone(), exit_code, stderr)); } - let result: Value = match serde_json::from_str(&stdout){ - Result::Ok(r) => {r}, - Result::Err(err) => { - return Err(DscError::Operation(format!("Failed to parse json from get {}|{}|{} -> {err}", &resource.get.executable, stdout, stderr))) - } + let result: GetResult = if let Ok(group_response) = serde_json::from_str::>(&stdout) { + trace!("Group get response: {:?}", &group_response); + GetResult::Group(group_response) + } else { + let result: Value = match serde_json::from_str(&stdout) { + Ok(r) => {r}, + Err(err) => { + return Err(DscError::Operation(format!("Failed to parse JSON from get {}|{}|{} -> {err}", &resource.get.executable, stdout, stderr))) + } + }; + GetResult::Resource(ResourceGetResponse{ + actual_state: result, + }) }; - Ok(GetResult { - actual_state: result, - }) + Ok(result) } /// Invoke the set operation on a resource @@ -101,13 +108,25 @@ pub fn invoke_set(resource: &ResourceManifest, cwd: &str, desired: &str, skip_te // 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() { info!("No pretest, invoking test {}", &resource.resource_type); - let test_result = invoke_test(resource, cwd, desired)?; - if test_result.in_desired_state { - return Ok(SetResult { - before_state: test_result.desired_state, - after_state: test_result.actual_state, + let (in_desired_state, actual_state) = match invoke_test(resource, cwd, desired)? { + TestResult::Group(group_response) => { + let mut result_array: Vec = Vec::new(); + for result in group_response.results { + result_array.push(serde_json::to_value(result)?); + } + (group_response.in_desired_state, Value::from(result_array)) + }, + TestResult::Resource(response) => { + (response.in_desired_state, response.actual_state) + } + }; + + if in_desired_state { + return Ok(SetResult::Resource(ResourceSetResponse{ + before_state: serde_json::from_str(desired)?, + after_state: actual_state, changed_properties: None, - }); + })); } } @@ -159,11 +178,11 @@ pub fn invoke_set(resource: &ResourceManifest, cwd: &str, desired: &str, skip_te // for changed_properties, we compare post state to pre state let diff_properties = get_diff( &actual_value, &pre_state); - Ok(SetResult { + Ok(SetResult::Resource(ResourceSetResponse{ before_state: pre_state, after_state: actual_value, changed_properties: Some(diff_properties), - }) + })) }, Some(ReturnKind::StateAndDiff) => { // command should be returning actual state as a JSON line and a list of properties that differ as separate JSON line @@ -177,22 +196,34 @@ pub fn invoke_set(resource: &ResourceManifest, cwd: &str, desired: &str, skip_te return Err(DscError::Command(resource.resource_type.clone(), exit_code, "Command did not return expected diff output".to_string())); }; let diff_properties: Vec = serde_json::from_str(diff_line)?; - Ok(SetResult { + Ok(SetResult::Resource(ResourceSetResponse { before_state: pre_state, after_state: actual_value, changed_properties: Some(diff_properties), - }) + })) }, None => { // perform a get and compare the result to the expected state let get_result = invoke_get(resource, cwd, desired)?; // for changed_properties, we compare post state to pre state - let diff_properties = get_diff( &get_result.actual_state, &pre_state); - Ok(SetResult { + let actual_state = match get_result { + GetResult::Group(results) => { + let mut result_array: Vec = Vec::new(); + for result in results { + result_array.push(serde_json::to_value(result)?); + } + Value::from(result_array) + }, + GetResult::Resource(response) => { + response.actual_state + } + }; + let diff_properties = get_diff( &actual_state, &pre_state); + Ok(SetResult::Resource(ResourceSetResponse { before_state: pre_state, - after_state: get_result.actual_state, + after_state: actual_state, changed_properties: Some(diff_properties), - }) + })) }, } } @@ -245,12 +276,12 @@ pub fn invoke_test(resource: &ResourceManifest, cwd: &str, expected: &str) -> Re } }; let diff_properties = get_diff(&expected_value, &actual_value); - Ok(TestResult { + Ok(TestResult::Resource(ResourceTestResponse { desired_state: expected_value, actual_state: actual_value, in_desired_state: diff_properties.is_empty(), diff_properties, - }) + })) }, Some(ReturnKind::StateAndDiff) => { // command should be returning actual state as a JSON line and a list of properties that differ as separate JSON line @@ -263,23 +294,35 @@ pub fn invoke_test(resource: &ResourceManifest, cwd: &str, expected: &str) -> Re return Err(DscError::Command(resource.resource_type.clone(), exit_code, "No diff properties returned".to_string())); }; let diff_properties: Vec = serde_json::from_str(diff_properties)?; - Ok(TestResult { + Ok(TestResult::Resource(ResourceTestResponse { desired_state: expected_value, actual_state: actual_value, in_desired_state: diff_properties.is_empty(), diff_properties, - }) + })) }, None => { // perform a get and compare the result to the expected state let get_result = invoke_get(resource, cwd, expected)?; - let diff_properties = get_diff(&expected_value, &get_result.actual_state); - Ok(TestResult { + let actual_state = match get_result { + GetResult::Group(results) => { + let mut result_array: Vec = Vec::new(); + for result in results { + result_array.push(serde_json::to_value(&result)?); + } + Value::from(result_array) + }, + GetResult::Resource(response) => { + response.actual_state + } + }; + let diff_properties = get_diff( &expected_value, &actual_state); + Ok(TestResult::Resource(ResourceTestResponse { desired_state: expected_value, - actual_state: get_result.actual_state, + actual_state, in_desired_state: diff_properties.is_empty(), diff_properties, - }) + })) }, } } @@ -300,6 +343,7 @@ pub fn invoke_test(resource: &ResourceManifest, cwd: &str, expected: &str) -> Re /// /// Error is returned if the underlying command returns a non-zero exit code. pub fn invoke_validate(resource: &ResourceManifest, cwd: &str, config: &str) -> Result { + trace!("Invoking validate '{}' using: {}", &resource.resource_type, &config); // TODO: use schema to validate config if validate is not implemented let Some(validate) = resource.validate.as_ref() else { return Err(DscError::NotImplemented("validate".to_string())); @@ -410,7 +454,7 @@ pub fn invoke_export(resource: &ResourceManifest, cwd: &str, input: Option<&str> /// Error is returned if the command fails to execute or stdin/stdout/stderr cannot be opened. #[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> { - debug!("Invoking command {} with args {:?}", executable, args); + debug!("Invoking command '{}' with args {:?}", executable, args); let mut command = Command::new(executable); if input.is_some() { command.stdin(Stdio::piped()); @@ -427,6 +471,11 @@ pub fn invoke_command(executable: &str, args: Option>, input: Option command.envs(env); } + if executable == "dsc" && env::var("DEBUG_DSC").is_ok() { + // remove this env var from child process as it will fail reading from keyboard to allow attaching + command.env_remove("DEBUG_DSC"); + } + let mut child = command.spawn()?; if input.is_some() { // pipe to child stdin in a scope so that it is dropped before we wait @@ -454,6 +503,12 @@ pub fn invoke_command(executable: &str, args: Option>, input: Option let exit_code = exit_status.code().unwrap_or(EXIT_PROCESS_TERMINATED); let stdout = String::from_utf8_lossy(&stdout_buf).to_string(); let stderr = String::from_utf8_lossy(&stderr_buf).to_string(); + if !stdout.is_empty() { + trace!("STDOUT returned: {}", &stdout); + } + if !stderr.is_empty() { + trace!("STDERR returned: {}", &stderr); + } Ok((exit_code, stdout, stderr)) } @@ -479,12 +534,20 @@ fn replace_token(args: &mut Option>, token: &str, value: &str) -> Re fn verify_json(resource: &ResourceManifest, cwd: &str, json: &str) -> Result<(), DscError> { - debug!("resource_type - {}", resource.resource_type); + debug!("Verify JSON for '{}'", resource.resource_type); - //TODO: remove this after schema validation for classic PS resources is implemented - if (resource.resource_type == "DSC/PowerShellGroup") - || (resource.resource_type == "DSC/WMIGroup") {return Ok(());} + // see if resource implements validate + if resource.validate.is_some() { + trace!("Validating against JSON: {json}"); + let result = invoke_validate(resource, cwd, json)?; + if result.valid { + return Ok(()); + } + + return Err(DscError::Validation("Resource reported input JSON is not valid".to_string())); + } + // otherwise, use schema validation let schema = get_schema(resource, cwd)?; let schema: Value = serde_json::from_str(&schema)?; let compiled_schema = match JSONSchema::compile(&schema) { diff --git a/dsc_lib/src/dscresources/dscresource.rs b/dsc_lib/src/dscresources/dscresource.rs index 7bdab211..aeaf0320 100644 --- a/dsc_lib/src/dscresources/dscresource.rs +++ b/dsc_lib/src/dscresources/dscresource.rs @@ -6,7 +6,9 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; -use super::{command_resource, dscerror, resource_manifest::import_manifest, invoke_result::{GetResult, SetResult, TestResult, ValidateResult, ExportResult}}; +use tracing::{debug, trace}; + +use super::{command_resource, dscerror, invoke_result::{ExportResult, GetResult, ResourceTestResponse, SetResult, TestResult, ValidateResult}, resource_manifest::import_manifest}; #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] @@ -62,6 +64,7 @@ impl DscResource { } fn validate_input(&self, input: &str) -> Result<(), DscError> { + debug!("Validating input for resource: {}", &self.type_name); if input.is_empty() { return Ok(()); } @@ -71,14 +74,20 @@ impl DscResource { let resource_manifest = import_manifest(manifest.clone())?; if resource_manifest.validate.is_some() { - let Ok(validation_result) = self.validate(input) else { - return Err(DscError::Validation("Validation invocation failed".to_string())); + trace!("Using custom validation"); + let validation_result = match self.validate(input) { + Ok(validation_result) => validation_result, + Err(err) => { + return Err(DscError::Validation(format!("Validation failed: {err}"))); + }, }; + trace!("Validation result is valid: {}", validation_result.valid); if !validation_result.valid { return Err(DscError::Validation("Validation failed".to_string())); } } else { + trace!("Using JSON schema validation"); let Ok(schema) = self.schema() else { return Err(DscError::Validation("Schema not available".to_string())); }; @@ -164,7 +173,7 @@ pub trait Invoke { fn schema(&self) -> Result; /// Invoke the export operation on the resource. - /// + /// /// # Arguments /// /// * `input` - Input for export operation. @@ -224,13 +233,25 @@ impl Invoke for DscResource { if resource_manifest.test.is_none() { let get_result = self.get(expected)?; let desired_state = serde_json::from_str(expected)?; - let diff_properties = get_diff(&desired_state, &get_result.actual_state); - let test_result = TestResult { + let actual_state = match get_result { + GetResult::Group(results) => { + let mut result_array: Vec = Vec::new(); + for result in results { + result_array.push(serde_json::to_value(result)?); + } + Value::from(result_array) + }, + GetResult::Resource(response) => { + response.actual_state + } + }; + let diff_properties = get_diff( &desired_state, &actual_state); + let test_result = TestResult::Resource(ResourceTestResponse { desired_state: serde_json::from_str(expected)?, - actual_state: get_result.actual_state, + actual_state, in_desired_state: diff_properties.is_empty(), diff_properties, - }; + }); Ok(test_result) } else { diff --git a/dsc_lib/src/dscresources/invoke_result.rs b/dsc_lib/src/dscresources/invoke_result.rs index 63dd3b7d..5df73e37 100644 --- a/dsc_lib/src/dscresources/invoke_result.rs +++ b/dsc_lib/src/dscresources/invoke_result.rs @@ -5,9 +5,37 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::Value; +use crate::configure::config_result::{ResourceGetResult, ResourceSetResult, ResourceTestResult}; + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] +#[serde(untagged)] +pub enum GetResult { + Resource(ResourceGetResponse), + Group(Vec), +} + +impl From for GetResult { + fn from(value: TestResult) -> Self { + match value { + TestResult::Group(group) => { + let mut results = Vec::::new(); + for result in group.results { + results.push(result.into()); + } + GetResult::Group(results) + }, + TestResult::Resource(resource) => { + GetResult::Resource(ResourceGetResponse { + actual_state: resource.actual_state + }) + } + } + } +} + #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] -pub struct GetResult { +pub struct ResourceGetResponse { /// The state of the resource as it was returned by the Get method. #[serde(rename = "actualState")] pub actual_state: Value, @@ -15,7 +43,35 @@ pub struct GetResult { #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] -pub struct SetResult { +pub struct GroupResourceSetResponse { + pub results: Vec, +} + +impl GroupResourceSetResponse { + #[must_use] + pub fn new() -> Self { + Self { + results: Vec::new(), + } + } +} + +impl Default for GroupResourceSetResponse { + fn default() -> Self { + Self::new() + } +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] +#[serde(untagged)] +pub enum SetResult { + Resource(ResourceSetResponse), + Group(GroupResourceSetResponse), +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ResourceSetResponse { /// The state of the resource as it was before the Set method was called. #[serde(rename = "beforeState")] pub before_state: Value, @@ -29,7 +85,38 @@ pub struct SetResult { #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] -pub struct TestResult { +pub struct GroupResourceTestResponse { + pub results: Vec, + #[serde(rename = "inDesiredState")] + pub in_desired_state: bool, +} + +impl GroupResourceTestResponse { + #[must_use] + pub fn new() -> Self { + Self { + results: Vec::new(), + in_desired_state: false, + } + } +} + +impl Default for GroupResourceTestResponse { + fn default() -> Self { + Self::new() + } +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] +#[serde(untagged)] +pub enum TestResult { + Resource(ResourceTestResponse), + Group(GroupResourceTestResponse), +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ResourceTestResponse { /// The state of the resource as it was expected to be. #[serde(rename = "desiredState")] pub desired_state: Value, diff --git a/osinfo/README.md b/osinfo/README.md index 96123678..7219ab35 100644 --- a/osinfo/README.md +++ b/osinfo/README.md @@ -19,10 +19,10 @@ Example output: ```json { "$id": "https://developer.microsoft.com/json-schemas/dsc/os_info/20230303/Microsoft.Dsc.OS_Info.schema.json", - "type": "Windows", + "family": "Windows", "version": "10.0.25309", "edition": "Windows 11 Professional", - "bitness": "X64" + "bitness": "64" } ``` @@ -43,10 +43,10 @@ Example output as YAML: ```yaml actual_state: $id: https://developer.microsoft.com/json-schemas/dsc/os_info/20230303/Microsoft.Dsc.OS_Info.schema.json - type: Windows + family: Windows version: 10.0.25309 edition: Windows 11 Professional - bitness: X64 + bitness: 64 ``` ## Performing a `test` @@ -65,10 +65,10 @@ expected_state: type: unknown actual_state: $id: https://developer.microsoft.com/json-schemas/dsc/os_info/20230303/Microsoft.Dsc.OS_Info.schema.json - type: Windows + family: Windows version: 10.0.25309 edition: Windows 11 Professional - bitness: X64 + bitness: 64 diff_properties: - type ```