diff --git a/.vscode/settings.json b/.vscode/settings.json index 09a06fd6..95742eed 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,6 +4,7 @@ "./dsc_lib/Cargo.toml", "./osinfo/Cargo.toml", "./registry/Cargo.toml", + "./runcommandonset/Cargo.toml", "./tools/test_group_resource/Cargo.toml", "./tools/dsctest/Cargo.toml", "./tree-sitter-dscexpression/Cargo.toml", diff --git a/dsc/examples/include.dsc.yaml b/dsc/examples/include.dsc.yaml new file mode 100644 index 00000000..8a3898ac --- /dev/null +++ b/dsc/examples/include.dsc.yaml @@ -0,0 +1,9 @@ +# This is a simple example of how to Include another configuration into this one + +$schema: https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2024/04/config/document.json +resources: +- name: get os info + type: Microsoft.DSC/Include + properties: + configurationFile: osinfo_parameters.dsc.yaml + parametersFile: osinfo.parameters.yaml diff --git a/dsc/include.dsc.resource.json b/dsc/include.dsc.resource.json new file mode 100644 index 00000000..2ee13406 --- /dev/null +++ b/dsc/include.dsc.resource.json @@ -0,0 +1,34 @@ +{ + "$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" + ] + } + } + } diff --git a/dsc/src/args.rs b/dsc/src/args.rs index 83cc6771..e973d7c2 100644 --- a/dsc/src/args.rs +++ b/dsc/src/args.rs @@ -21,7 +21,7 @@ pub enum TraceFormat { #[derive(Debug, Clone, PartialEq, Eq, ValueEnum)] pub enum TraceLevel { Error, - Warning, + Warn, Info, Debug, Trace @@ -33,8 +33,8 @@ pub struct Args { /// The subcommand to run #[clap(subcommand)] pub subcommand: SubCommand, - #[clap(short = 'l', long, help = "Trace level to use", value_enum, default_value = "warning")] - pub trace_level: TraceLevel, + #[clap(short = 'l', long, help = "Trace level to use", value_enum)] + pub trace_level: Option, #[clap(short = 'f', long, help = "Trace format to use", value_enum, default_value = "default")] pub trace_format: TraceFormat, } @@ -54,6 +54,7 @@ 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, + // Used to inform when DSC is used as a group resource to modify it's output #[clap(long, hide = true)] as_group: bool, }, @@ -119,6 +120,15 @@ pub enum ConfigSubCommand { path: Option, #[clap(short = 'f', long, help = "The output format to use")] format: Option, + }, + #[clap(name = "resolve", about = "Resolve the current configuration", hide = true)] + Resolve { + #[clap(short = 'd', long, help = "The document to pass to the configuration or resource", conflicts_with = "path")] + 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, } } @@ -203,8 +213,10 @@ pub enum DscType { GetResult, SetResult, TestResult, + ResolveResult, DscResource, ResourceManifest, + Include, Configuration, ConfigurationGetResult, ConfigurationSetResult, diff --git a/dsc/src/main.rs b/dsc/src/main.rs index cce6c0da..0567c77a 100644 --- a/dsc/src/main.rs +++ b/dsc/src/main.rs @@ -16,6 +16,7 @@ use crossterm::event; use std::env; pub mod args; +pub mod resolve; pub mod resource_command; pub mod subcommand; pub mod tablewriter; @@ -67,11 +68,11 @@ fn main() { }, 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) { + 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), Err(err) => { - error!("Error: Failed to read parameters file: {err}"); + error!("Error: Failed to read parameters file '{file_name}': {err}"); exit(util::EXIT_INVALID_INPUT); } } diff --git a/dsc/src/resolve.rs b/dsc/src/resolve.rs new file mode 100644 index 00000000..5e261466 --- /dev/null +++ b/dsc/src/resolve.rs @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use dsc_lib::configure::config_doc::Configuration; +use dsc_lib::util::parse_input_to_json; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::io::Read; +use std::fs::File; +use std::path::{Path, PathBuf}; +use tracing::{debug, info}; + +use crate::util::DSC_CONFIG_ROOT; + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] +pub struct Include { + /// The path to the file to include. Path is relative to the file containing the include + /// and not allowed to reference parent directories. If a configuration document is used + /// instead of a file, then the path is relative to the current working directory. + #[serde(rename = "configurationFile")] + pub configuration_file: String, + #[serde(rename = "parametersFile")] + pub parameters_file: Option, +} + +/// Read the file specified in the Include input and return the content as a JSON string. +/// +/// # Arguments +/// +/// * `input` - The Include input as a JSON string. +/// +/// # Returns +/// +/// A tuple containing the contents of the parameters file as JSON and the configuration content +/// as a JSON string. +/// +/// # Errors +/// +/// This function will return an error if the Include input is not valid JSON, if the file +/// specified in the Include input cannot be read, or if the content of the file cannot be +/// deserialized as YAML or JSON. +pub fn get_contents(input: &str) -> Result<(Option, String), String> { + debug!("Processing Include input"); + + // deserialize the Include input + let include = match serde_json::from_str::(input) { + Ok(include) => include, + Err(err) => { + return Err(format!("Error: Failed to deserialize Include input: {err}")); + } + }; + + let include_path = normalize_path(Path::new(&include.configuration_file))?; + + // read the file specified in the Include input + let mut buffer: Vec = Vec::new(); + match File::open(&include_path) { + Ok(mut file) => { + match file.read_to_end(&mut buffer) { + Ok(_) => (), + Err(err) => { + return Err(format!("Error: Failed to read file '{include_path:?}': {err}")); + } + } + }, + Err(err) => { + return Err(format!("Error: Failed to open included file '{include_path:?}': {err}")); + } + } + // convert the buffer to a string + let include_content = match String::from_utf8(buffer) { + Ok(input) => input, + Err(err) => { + return Err(format!("Error: Invalid UTF-8 sequence in included file '{include_path:?}': {err}")); + } + }; + + // try to deserialize the Include content as YAML first + let configuration: Configuration = match serde_yaml::from_str(&include_content) { + Ok(configuration) => configuration, + Err(_err) => { + // if that fails, try to deserialize it as JSON + match serde_json::from_str(&include_content) { + Ok(configuration) => configuration, + Err(err) => { + return Err(format!("Error: Failed to read the configuration file '{include_path:?}' as YAML or JSON: {err}")); + } + } + } + }; + + // serialize the Configuration as JSON + let config_json = match serde_json::to_string(&configuration) { + Ok(json) => json, + Err(err) => { + return Err(format!("Error: JSON Error: {err}")); + } + }; + + let parameters = if let Some(parameters_file) = include.parameters_file { + // combine the path with DSC_CONFIG_ROOT + let parameters_file = normalize_path(Path::new(¶meters_file))?; + info!("Resolving parameters from file '{parameters_file:?}'"); + match std::fs::read_to_string(¶meters_file) { + Ok(parameters) => { + let parameters_json = match parse_input_to_json(¶meters) { + Ok(json) => json, + Err(err) => { + return Err(format!("Failed to parse parameters file '{parameters_file:?}' to JSON: {err}")); + } + }; + Some(parameters_json) + }, + Err(err) => { + return Err(format!("Failed to resolve parameters file '{parameters_file:?}': {err}")); + } + } + } else { + debug!("No parameters file found"); + None + }; + + Ok((parameters, config_json)) +} + +fn normalize_path(path: &Path) -> Result { + if path.is_absolute() { + Ok(path.to_path_buf()) + } else { + // check that no components of the path are '..' + if path.components().any(|c| c == std::path::Component::ParentDir) { + return Err(format!("Error: Include path must not contain '..': {path:?}")); + } + + // use DSC_CONFIG_ROOT env var as current directory + let current_directory = match std::env::var(DSC_CONFIG_ROOT) { + Ok(current_directory) => current_directory, + Err(_err) => { + // use current working directory + match std::env::current_dir() { + Ok(current_directory) => current_directory.to_string_lossy().into_owned(), + Err(err) => { + return Err(format!("Error: Failed to get current directory: {err}")); + } + } + } + }; + + // combine the current directory with the Include path + Ok(Path::new(¤t_directory).join(path)) + } +} diff --git a/dsc/src/resource_command.rs b/dsc/src/resource_command.rs index 94d59bb3..a8787aee 100644 --- a/dsc/src/resource_command.rs +++ b/dsc/src/resource_command.rs @@ -261,6 +261,6 @@ pub fn export(dsc: &mut DscManager, resource_type: &str, format: &Option(dsc: &'a DscManager, resource: &str) -> Option<&'a DscResource> { - //TODO: add dinamically generated resource to dsc - dsc.find_resource(String::from(resource).to_lowercase().as_str()) + //TODO: add dynamically generated resource to dsc + dsc.find_resource(resource) } diff --git a/dsc/src/subcommand.rs b/dsc/src/subcommand.rs index df75c403..0a873e10 100644 --- a/dsc/src/subcommand.rs +++ b/dsc/src/subcommand.rs @@ -2,14 +2,15 @@ // Licensed under the MIT License. use crate::args::{ConfigSubCommand, DscType, OutputFormat, ResourceSubCommand}; +use crate::resolve::get_contents; 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_VALIDATION_FAILED, get_schema, write_output, get_input, set_dscconfigroot, validate_json}; -use dsc_lib::configure::{Configurator, ErrorAction, config_result::ResourceGetResult}; +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_result::ResourceGetResult}; use dsc_lib::dscerror::DscError; use dsc_lib::dscresources::invoke_result::{ - GroupResourceSetResponse, GroupResourceTestResponse, TestResult + GroupResourceSetResponse, GroupResourceTestResponse, ResolveResult, TestResult }; use dsc_lib::{ DscManager, @@ -17,13 +18,13 @@ use dsc_lib::{ dscresources::dscresource::{Capability, ImplementedAs, Invoke}, dscresources::resource_manifest::{import_manifest, ResourceManifest}, }; -use serde_yaml::Value; +use std::collections::HashMap; use std::process::exit; use tracing::{debug, error, trace}; pub fn config_get(configurator: &mut Configurator, format: &Option, as_group: &bool) { - match configurator.invoke_get(ErrorAction::Continue, || { /* code */ }) { + match configurator.invoke_get() { Ok(result) => { if *as_group { let mut group_result = Vec::::new(); @@ -62,7 +63,7 @@ pub fn config_get(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 */ }) { + match configurator.invoke_set(false) { Ok(result) => { if *as_group { let group_result = GroupResourceSetResponse { @@ -100,7 +101,7 @@ pub fn config_set(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 */ }) { + match configurator.invoke_test() { Ok(result) => { if *as_group { let mut in_desired_state = true; @@ -171,7 +172,7 @@ pub fn config_test(configurator: &mut Configurator, format: &Option) { - match configurator.invoke_export(ErrorAction::Continue, || { /* code */ }) { + match configurator.invoke_export() { Ok(result) => { let json = match serde_json::to_string(&result.result) { Ok(json) => json, @@ -198,22 +199,44 @@ pub fn config_export(configurator: &mut Configurator, format: &Option) -> Option { + if path.is_some() { + let config_path = path.clone().unwrap_or_default(); + Some(set_dscconfigroot(&config_path)) + } else if std::env::var(DSC_CONFIG_ROOT).is_ok() { + let config_root = std::env::var(DSC_CONFIG_ROOT).unwrap_or_default(); + debug!("Using {config_root} for {DSC_CONFIG_ROOT}"); + None + } else { + let current_directory = std::env::current_dir().unwrap_or_default(); + debug!("Using current directory '{current_directory:?}' for {DSC_CONFIG_ROOT}"); + set_dscconfigroot(¤t_directory.to_string_lossy()); + None + } +} + +#[allow(clippy::too_many_lines)] pub fn config(subcommand: &ConfigSubCommand, parameters: &Option, stdin: &Option, as_group: &bool) { - let json_string = match subcommand { + let (new_parameters, json_string) = match subcommand { ConfigSubCommand::Get { document, path, .. } | ConfigSubCommand::Set { document, path, .. } | ConfigSubCommand::Test { document, path, .. } | ConfigSubCommand::Validate { document, path, .. } | ConfigSubCommand::Export { document, path, .. } => { - let mut new_path = path; - let opt_new_path; - if path.is_some() - { - let config_path = path.clone().unwrap_or_default(); - opt_new_path = Some(set_dscconfigroot(&config_path)); - new_path = &opt_new_path; - } - get_input(document, stdin, new_path) + let new_path = initialize_config_root(path); + (None, get_input(document, stdin, &new_path)) + }, + ConfigSubCommand::Resolve { document, path, .. } => { + let new_path = initialize_config_root(path); + let input = get_input(document, stdin, &new_path); + 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) } }; @@ -225,13 +248,21 @@ pub fn config(subcommand: &ConfigSubCommand, parameters: &Option, stdin: } }; - let parameters: Option = match parameters { - None => None, + let parameters: Option = match if new_parameters.is_some() { + &new_parameters + } else { + parameters + } { + None => { + debug!("No parameters specified"); + None + }, Some(parameters) => { + debug!("Parameters specified"); match serde_json::from_str(parameters) { Ok(json) => Some(json), Err(_) => { - match serde_yaml::from_str::(parameters) { + match serde_yaml::from_str::(parameters) { Ok(yaml) => { match serde_json::to_value(yaml) { Ok(json) => Some(json), @@ -294,7 +325,38 @@ pub fn config(subcommand: &ConfigSubCommand, parameters: &Option, stdin: }, ConfigSubCommand::Export { format, .. } => { config_export(&mut configurator, format); - } + }, + ConfigSubCommand::Resolve { format, .. } => { + let configuration = match serde_json::from_str(&json_string) { + Ok(json) => json, + Err(err) => { + error!("Error: Failed to deserialize configuration: {err}"); + exit(EXIT_DSC_ERROR); + } + }; + // get the parameters out of the configurator + let parameters_hashmap = if configurator.context.parameters.is_empty() { + None + } else { + let mut parameters: HashMap = HashMap::new(); + for (key, value) in &configurator.context.parameters { + parameters.insert(key.clone(), value.0.clone()); + } + Some(parameters) + }; + let resolve_result = ResolveResult { + configuration, + parameters: parameters_hashmap, + }; + let json_string = match serde_json::to_string(&resolve_result) { + Ok(json) => json, + Err(err) => { + error!("Error: Failed to serialize resolve result: {err}"); + exit(EXIT_JSON_ERROR); + } + }; + write_output(&json_string, format); + }, } } @@ -402,15 +464,15 @@ pub fn resource(subcommand: &ResourceSubCommand, stdin: &Option) { list_resources(&mut dsc, resource_name, adapter_name, description, tags, format); }, ResourceSubCommand::Schema { resource , format } => { - dsc.find_resources(&[resource.to_lowercase().to_string()]); + dsc.find_resources(&[resource.to_string()]); resource_command::schema(&dsc, resource, format); }, ResourceSubCommand::Export { resource, format } => { - dsc.find_resources(&[resource.to_lowercase().to_string()]); + dsc.find_resources(&[resource.to_string()]); resource_command::export(&mut dsc, resource, format); }, ResourceSubCommand::Get { resource, input, path, all, format } => { - dsc.find_resources(&[resource.to_lowercase().to_string()]); + dsc.find_resources(&[resource.to_string()]); if *all { resource_command::get_all(&dsc, resource, format); } else { let parsed_input = get_input(input, stdin, path); @@ -418,17 +480,17 @@ pub fn resource(subcommand: &ResourceSubCommand, stdin: &Option) { } }, ResourceSubCommand::Set { resource, input, path, format } => { - dsc.find_resources(&[resource.to_lowercase().to_string()]); + dsc.find_resources(&[resource.to_string()]); let parsed_input = get_input(input, stdin, path); resource_command::set(&dsc, resource, parsed_input, format); }, ResourceSubCommand::Test { resource, input, path, format } => { - dsc.find_resources(&[resource.to_lowercase().to_string()]); + dsc.find_resources(&[resource.to_string()]); let parsed_input = get_input(input, stdin, path); resource_command::test(&dsc, resource, parsed_input, format); }, ResourceSubCommand::Delete { resource, input, path } => { - dsc.find_resources(&[resource.to_lowercase().to_string()]); + dsc.find_resources(&[resource.to_string()]); let parsed_input = get_input(input, stdin, path); resource_command::delete(&dsc, resource, parsed_input); }, @@ -443,7 +505,7 @@ fn list_resources(dsc: &mut DscManager, resource_name: &Option, adapter_ write_table = true; } for resource in dsc.list_available_resources(&resource_name.clone().unwrap_or("*".to_string()), &adapter_name.clone().unwrap_or_default()) { - let mut capabilities = "------".to_string(); + let mut capabilities = "-------".to_string(); let capability_types = [ (Capability::Get, "g"), (Capability::Set, "s"), @@ -451,6 +513,7 @@ fn list_resources(dsc: &mut DscManager, resource_name: &Option, adapter_ (Capability::Test, "t"), (Capability::Delete, "d"), (Capability::Export, "e"), + (Capability::Resolve, "r"), ]; for (i, (capability, letter)) in capability_types.iter().enumerate() { diff --git a/dsc/src/util.rs b/dsc/src/util.rs index 53cee18f..2689afb2 100644 --- a/dsc/src/util.rs +++ b/dsc/src/util.rs @@ -4,6 +4,7 @@ use crate::args::{DscType, OutputFormat, TraceFormat, TraceLevel}; use atty::Stream; +use crate::resolve::Include; use dsc_lib::{ configure::{ config_doc::Configuration, @@ -15,14 +16,14 @@ use dsc_lib::{ }, dscerror::DscError, dscresources::{ - dscresource::DscResource, - invoke_result::{ + dscresource::DscResource, invoke_result::{ GetResult, SetResult, TestResult, - }, - resource_manifest::ResourceManifest - } + ResolveResult, + }, resource_manifest::ResourceManifest + }, + util::parse_input_to_json, }; use jsonschema::JSONSchema; use path_absolutize::Absolutize; @@ -50,6 +51,9 @@ pub const EXIT_INVALID_INPUT: i32 = 4; pub const EXIT_VALIDATION_FAILED: i32 = 5; pub const EXIT_CTRL_C: i32 = 6; +pub const DSC_CONFIG_ROOT: &str = "DSC_CONFIG_ROOT"; +pub const DSC_TRACE_LEVEL: &str = "DSC_TRACE_LEVEL"; + /// Get string representation of JSON value. /// /// # Arguments @@ -152,12 +156,18 @@ pub fn get_schema(dsc_type: DscType) -> RootSchema { DscType::TestResult => { schema_for!(TestResult) }, + DscType::ResolveResult => { + schema_for!(ResolveResult) + } DscType::DscResource => { schema_for!(DscResource) }, DscType::ResourceManifest => { schema_for!(ResourceManifest) }, + DscType::Include => { + schema_for!(Include) + }, DscType::Configuration => { schema_for!(Configuration) }, @@ -257,10 +267,33 @@ pub fn write_output(json: &str, format: &Option) { } } -pub fn enable_tracing(trace_level: &TraceLevel, trace_format: &TraceFormat) { +pub fn enable_tracing(trace_level: &Option, trace_format: &TraceFormat) { let tracing_level = match trace_level { + Some(level) => level, + None => { + // use DSC_TRACE_LEVEL env var if set + match env::var(DSC_TRACE_LEVEL) { + Ok(level) => { + match level.to_ascii_uppercase().as_str() { + "ERROR" => &TraceLevel::Error, + "WARN" => &TraceLevel::Warn, + "INFO" => &TraceLevel::Info, + "DEBUG" => &TraceLevel::Debug, + "TRACE" => &TraceLevel::Trace, + _ => { + warn!("Invalid DSC_TRACE_LEVEL value '{level}', defaulting to 'warn'"); + &TraceLevel::Warn + }, + } + }, + Err(_) => &TraceLevel::Warn, + } + } + }; + + let tracing_level = match tracing_level { TraceLevel::Error => Level::ERROR, - TraceLevel::Warning => Level::WARN, + TraceLevel::Warn => Level::WARN, TraceLevel::Info => Level::INFO, TraceLevel::Debug => Level::DEBUG, TraceLevel::Trace => Level::TRACE, @@ -306,6 +339,9 @@ pub fn enable_tracing(trace_level: &TraceLevel, trace_format: &TraceFormat) { if tracing::subscriber::set_global_default(subscriber).is_err() { eprintln!("Unable to set global default tracing subscriber. Tracing is diabled."); } + + // set DSC_TRACE_LEVEL for child processes + env::set_var(DSC_TRACE_LEVEL, tracing_level.to_string().to_ascii_lowercase()); } /// Validate the JSON against the schema. @@ -345,29 +381,6 @@ pub fn validate_json(source: &str, schema: &Value, json: &Value) -> Result<(), D Ok(()) } -pub fn parse_input_to_json(value: &str) -> String { - match serde_json::from_str(value) { - Ok(json) => json, - Err(_) => { - match serde_yaml::from_str::(value) { - Ok(yaml) => { - match serde_json::to_value(yaml) { - Ok(json) => json.to_string(), - Err(err) => { - error!("Error: Failed to convert YAML to JSON: {err}"); - exit(EXIT_DSC_ERROR); - } - } - }, - Err(err) => { - error!("Error: Input is not valid JSON or YAML: {err}"); - exit(EXIT_INVALID_INPUT); - } - } - } - } -} - pub fn get_input(input: &Option, stdin: &Option, path: &Option) -> String { let value = match (input, stdin, path) { (Some(_), Some(_), None) | (None, Some(_), Some(_)) => { @@ -416,18 +429,25 @@ pub fn get_input(input: &Option, stdin: &Option, path: &Option json, + Err(err) => { + error!("Error: Invalid JSON or YAML: {err}"); + exit(EXIT_INVALID_INPUT); + } + } } /// Sets `DSC_CONFIG_ROOT` env var and makes path absolute. /// /// # Arguments /// -/// * `config_path` - Full path to the config file +/// * `config_path` - Full path to the config file or directory. /// /// # Returns /// /// Absolute full path to the config file. +/// If a directory is provided, the path returned is the directory path. pub fn set_dscconfigroot(config_path: &str) -> String { let path = Path::new(config_path); @@ -438,24 +458,25 @@ pub fn set_dscconfigroot(config_path: &str) -> String exit(EXIT_DSC_ERROR); }; - let Some(config_root_path) = full_path.parent() else { - // this should never happen because path was absolutized - error!("Error reading config path parent"); - exit(EXIT_DSC_ERROR); + let config_root_path = if full_path.is_file() { + let Some(config_root_path) = full_path.parent() else { + // this should never happen because path was made absolute + error!("Error reading config path parent"); + exit(EXIT_DSC_ERROR); + }; + config_root_path.to_string_lossy().into_owned() + } else { + config_path.to_string() }; - let env_var = "DSC_CONFIG_ROOT"; - // warn if env var is already set/used - if env::var(env_var).is_ok() { - warn!("The current value of '{env_var}' env var will be overridden"); + if env::var(DSC_CONFIG_ROOT).is_ok() { + warn!("The current value of '{DSC_CONFIG_ROOT}' env var will be overridden"); } // Set env var so child processes (of resources) can use it - let config_root = config_root_path.to_str().unwrap_or_default(); - debug!("Setting '{env_var}' env var as '{}'", config_root); - env::set_var(env_var, config_root); + debug!("Setting '{DSC_CONFIG_ROOT}' env var as '{config_root_path}'"); + env::set_var(DSC_CONFIG_ROOT, config_root_path); - // return absolutized path - full_path.to_str().unwrap_or_default().to_string() + full_path.to_string_lossy().into_owned() } diff --git a/dsc/tests/dsc_config_get.tests.ps1 b/dsc/tests/dsc_config_get.tests.ps1 index ea9f25ba..6016835c 100644 --- a/dsc/tests/dsc_config_get.tests.ps1 +++ b/dsc/tests/dsc_config_get.tests.ps1 @@ -37,7 +37,7 @@ Describe 'dsc config get tests' { `$schema: https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2024/04/config/document.json resources: - name: Echo - type: Test/Echo + type: test/echo properties: output: hello "@ @@ -45,7 +45,7 @@ Describe 'dsc config get tests' { $result.hadErrors | Should -BeFalse $result.results.Count | Should -Be 1 $result.results[0].Name | Should -Be 'Echo' - $result.results[0].type | Should -BeExactly 'Test/Echo' + $result.results[0].type | Should -BeExactly 'test/echo' $result.results[0].result.actualState.output | Should -Be 'hello' $result.metadata.'Microsoft.DSC'.version | Should -BeLike '3.*' $result.metadata.'Microsoft.DSC'.operation | Should -BeExactly 'Get' diff --git a/dsc/tests/dsc_get.tests.ps1 b/dsc/tests/dsc_get.tests.ps1 index a5586e91..f1cf7b65 100644 --- a/dsc/tests/dsc_get.tests.ps1 +++ b/dsc/tests/dsc_get.tests.ps1 @@ -9,7 +9,7 @@ Describe 'resource get tests' { switch ($type) { 'string' { - $resource = 'Microsoft.Windows/registry' + $resource = 'Microsoft.Windows/Registry' } 'json' { $resource = dsc resource list *registry @@ -44,7 +44,7 @@ Describe 'resource get tests' { "Name": "ProductName" } '@ - $testError = & {$json | dsc resource get -r Microsoft.Windows/registry get 2>&1} + $testError = & {$json | dsc resource get -r Microsoft.Windows/Registry get 2>&1} $testError[0] | SHould -match 'error:' $LASTEXITCODE | Should -Be 2 } diff --git a/dsc/tests/dsc_include.tests.ps1 b/dsc/tests/dsc_include.tests.ps1 new file mode 100644 index 00000000..fab0690a --- /dev/null +++ b/dsc/tests/dsc_include.tests.ps1 @@ -0,0 +1,186 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'Include tests' { + BeforeAll { + $includePath = New-Item -ItemType Directory -Path (Join-Path $TestDrive 'include') + Copy-Item (Join-Path $PSScriptRoot '../examples/osinfo_parameters.dsc.yaml') -Destination $includePath + $osinfoConfigPath = Get-Item (Join-Path $includePath 'osinfo_parameters.dsc.yaml') + Copy-Item (Join-Path $PSScriptRoot '../examples/osinfo.parameters.yaml') -Destination $includePath + $osinfoParametersConfigPath = Get-Item (Join-Path $includePath 'osinfo.parameters.yaml') + + $logPath = Join-Path $TestDrive 'stderr.log' + + $includeConfig = @' + `$schema: https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2024/04/config/document.json + resources: + - name: Echo + type: Test/Echo + properties: + output: Hello World +'@ + } + + It 'Include config with default parameters' { + $config = @" + `$schema: https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2024/04/config/document.json + resources: + - name: osinfo + type: Microsoft.DSC/Include + properties: + configurationFile: include/osinfo_parameters.dsc.yaml +"@ + $configPath = Join-Path $TestDrive 'config.dsc.yaml' + $config | Set-Content -Path $configPath + $out = dsc config get -p $configPath | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + if ($IsWindows) { + $expectedOS = 'Windows' + } elseif ($IsLinux) { + $expectedOS = 'Linux' + } else { + $expectedOS = 'macOS' + } + $out.results[0].result[0].result.actualState.family | Should -Be $expectedOS + } + + It 'Include config with parameters file' { + $config = @" + `$schema: https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2024/04/config/document.json + resources: + - name: osinfo + type: Microsoft.DSC/Include + properties: + configurationFile: include/osinfo_parameters.dsc.yaml + parametersFile: include/osinfo.parameters.yaml +"@ + $configPath = Join-Path $TestDrive 'config.dsc.yaml' + $config | Set-Content -Path $configPath + $out = dsc config get -p $configPath | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + if ($IsWindows) { + $expectedOS = 'Windows' + } elseif ($IsLinux) { + $expectedOS = 'Linux' + } else { + $expectedOS = 'macOS' + } + $out.results[0].result[0].result.actualState.family | Should -Be $expectedOS + } + + It 'Invalid file path: ' -TestCases @( + @{ test = 'non-existing configuration'; config = 'include/non-existing.dsc.yaml'; parameters = $null } + @{ test = 'non-existing parameters'; config = 'include/osinfo_parameters.dsc.yaml'; parameters = 'include/non-existing.parameters.yaml' } + @{ test = 'configuration referencing parent directory'; config = '../include/osinfo_parameters.dsc.yaml'; parameters = $null } + @{ test = 'parameters referencing parent directory'; config = 'include/osinfo_parameters.dsc.yaml'; parameters = '../include/non-existing.parameters.yaml' } + ) { + param($config, $parameters) + + $configYaml = @" + `$schema: https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2024/04/config/document.json + resources: + - name: osinfo + type: Microsoft.DSC/Include + properties: + configurationFile: $config + parametersFile: $parameters +"@ + + $configPath = Join-Path $TestDrive 'config.dsc.yaml' + $configYaml | Set-Content -Path $configPath + $out = dsc config get -p $configPath 2> $logPath + $LASTEXITCODE | Should -Be 2 + $log = Get-Content -Path $logPath -Raw + $log | Should -BeLike "*ERROR*" + } + + It 'Valid absolute file path: ' -TestCases @( + @{ test = 'configuration'; config = 'include/osinfo_parameters.dsc.yaml'; parameters = $null } + @{ test = 'parameters'; config = 'include/osinfo_parameters.dsc.yaml'; parameters = 'include/osinfo.parameters.yaml' } + ) { + param($test, $config, $parameters) + + if ($test -eq 'configuration') { + $config = Join-Path $TestDrive $config + } elseif ($test -eq 'parameters') { + $parameters = Join-Path $TestDrive $parameters + } else { + throw "Invalid test case: $test" + } + + $configYaml = @" + `$schema: https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2024/04/config/document.json + resources: + - name: osinfo + type: Microsoft.DSC/Include + properties: + configurationFile: $config + parametersFile: $parameters +"@ + + $configPath = Join-Path $TestDrive 'config.dsc.yaml' + $configYaml | Set-Content -Path $configPath + $out = dsc config get -p $configPath | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + if ($IsWindows) { + $expectedOS = 'Windows' + } elseif ($IsLinux) { + $expectedOS = 'Linux' + } else { + $expectedOS = 'macOS' + } + $out.results[0].result[0].result.actualState.family | Should -Be $expectedOS + } + + It 'Multiple includes' { + $echoConfig = @' +$schema: https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2024/04/config/document.json +resources: +- name: one + type: Test/Echo + properties: + output: one +'@ + + $echoConfigPath = Join-Path $TestDrive 'echo.dsc.yaml' + $echoConfig | Set-Content -Path $echoConfigPath -Encoding utf8 + # need to escape backslashes for YAML + $echoConfigPathParent = (Split-Path $echoConfigPath -Parent).Replace('\', '\\') + $echoConfigPathLeaf = (Split-Path $echoConfigPath -Leaf).Replace('\', '\\') + $directorySeparator = [System.IO.Path]::DirectorySeparatorChar.ToString().Replace('\', '\\') + + $nestedIncludeConfig = @" +`$schema: https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2024/04/config/document.json +resources: +- name: nested + type: Microsoft.DSC/Include + properties: + configurationFile: "[concat('$echoConfigPathParent', '$directorySeparator', '$echoConfigPathLeaf')]" +"@ + + $nestedIncludeConfigPath = Join-Path $TestDrive 'nested_include.dsc.yaml' + $nestedIncludeConfig | Set-Content -Path $nestedIncludeConfigPath -Encoding utf8 + + $includeConfig = @" +`$schema: https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2024/04/config/document.json +resources: +- name: include + type: Microsoft.DSC/Include + properties: + configurationFile: $echoConfigPath +- name: include nested + type: Microsoft.DSC/Include + properties: + configurationFile: $nestedIncludeConfigPath +"@ + + $out = $includeConfig | dsc config get | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $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' + } +} diff --git a/dsc/tests/dsc_schema.tests.ps1 b/dsc/tests/dsc_schema.tests.ps1 index 48c308bc..1bcef876 100644 --- a/dsc/tests/dsc_schema.tests.ps1 +++ b/dsc/tests/dsc_schema.tests.ps1 @@ -3,7 +3,7 @@ Describe 'config schema tests' { It 'return resource schema' -Skip:(!$IsWindows) { - $schema = dsc resource schema -r Microsoft.Windows/registry + $schema = dsc resource schema -r Microsoft.Windows/Registry $LASTEXITCODE | Should -Be 0 $schema | Should -Not -BeNullOrEmpty $schema = $schema | ConvertFrom-Json diff --git a/dsc/tests/dsc_set.tests.ps1 b/dsc/tests/dsc_set.tests.ps1 index 0323cf4b..3175c57d 100644 --- a/dsc/tests/dsc_set.tests.ps1 +++ b/dsc/tests/dsc_set.tests.ps1 @@ -88,7 +88,7 @@ Describe 'resource set tests' { } } '@ - $out = $json | dsc resource set -r Microsoft.Windows/registry + $out = $json | dsc resource set -r Microsoft.Windows/Registry $LASTEXITCODE | Should -Be 0 $result = $out | ConvertFrom-Json $result.afterState.keyPath | Should -Be 'HKCU\1\2\3' @@ -97,7 +97,7 @@ Describe 'resource set tests' { $result.changedProperties | Should -Be @('valueName', 'valueData', '_exist') ($result.psobject.properties | Measure-Object).Count | Should -Be 3 - $out = $json | dsc resource get -r Microsoft.Windows/registry + $out = $json | dsc resource get -r Microsoft.Windows/Registry $LASTEXITCODE | Should -Be 0 $result = $out | ConvertFrom-Json $result.actualState.keyPath | Should -Be 'HKCU\1\2\3' @@ -111,7 +111,7 @@ Describe 'resource set tests' { "_exist": false } '@ - $out = $json | dsc resource set -r Microsoft.Windows/registry + $out = $json | dsc resource set -r Microsoft.Windows/Registry $LASTEXITCODE | Should -Be 0 $result = $out | ConvertFrom-Json $result.afterState.keyPath | Should -BeExactly 'HKCU\1' diff --git a/dsc/tests/dsc_test.tests.ps1 b/dsc/tests/dsc_test.tests.ps1 index a108e60f..35999f48 100644 --- a/dsc/tests/dsc_test.tests.ps1 +++ b/dsc/tests/dsc_test.tests.ps1 @@ -10,7 +10,7 @@ Describe 'resource test tests' { } '@ $current = registry config get --input $json - $out = $current | dsc resource test -r Microsoft.Windows/registry + $out = $current | dsc resource test -r Microsoft.Windows/Registry $LASTEXITCODE | Should -Be 0 $out = $out | ConvertFrom-Json $out.inDesiredState | Should -BeTrue @@ -27,7 +27,7 @@ Describe 'resource test tests' { } } '@ - $out = $json | dsc resource test -r Microsoft.Windows/registry + $out = $json | dsc resource test -r Microsoft.Windows/Registry $LASTEXITCODE | Should -Be 0 $out = $out | ConvertFrom-Json $out.inDesiredState | Should -BeFalse @@ -45,7 +45,7 @@ Describe 'resource test tests' { } } '@ - $out = $json | dsc resource test -r Microsoft.Windows/registry + $out = $json | dsc resource test -r Microsoft.Windows/Registry $LASTEXITCODE | Should -Be 0 $out = $out | ConvertFrom-Json $out.inDesiredState | Should -BeFalse diff --git a/dsc/tests/dsc_tracing.tests.ps1 b/dsc/tests/dsc_tracing.tests.ps1 index 24ae2edb..56698ac0 100644 --- a/dsc/tests/dsc_tracing.tests.ps1 +++ b/dsc/tests/dsc_tracing.tests.ps1 @@ -47,7 +47,7 @@ Describe 'tracing tests' { It 'trace level emits source info: ' -TestCases @( @{ level = 'error'; sourceExpected = $false } - @{ level = 'warning'; sourceExpected = $false } + @{ level = 'warn'; sourceExpected = $false } @{ level = 'info'; sourceExpected = $false } @{ level = 'debug'; sourceExpected = $true } @{ level = 'trace'; sourceExpected = $true } @@ -63,4 +63,26 @@ Describe 'tracing tests' { $log | Should -Not -BeLike "*dsc_lib*: *" } } + + It 'trace level is passed to resource' -TestCases @( + @{ level = 'error' } + @{ level = 'warn' } + @{ level = 'info' } + @{ level = 'debug' } + @{ level = 'trace' } + ) { + param($level) + + $configYaml = @" + `$schema: https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2024/04/config/document.json + resources: + - name: trace + type: Test/Trace + properties: + level: trace +"@ + + $out = (dsc -l $level config get -d $configYaml 2> $null) | ConvertFrom-Json + $out.results[0].result.actualState.level | Should -BeExactly $level + } } diff --git a/dsc_lib/src/configure/mod.rs b/dsc_lib/src/configure/mod.rs index d4ee0d9c..a5c84636 100644 --- a/dsc_lib/src/configure/mod.rs +++ b/dsc_lib/src/configure/mod.rs @@ -31,17 +31,11 @@ pub mod parameters; pub struct Configurator { config: String, - context: Context, + pub context: Context, discovery: Discovery, statement_parser: Statement, } -#[derive(Debug, Clone, Copy)] -pub enum ErrorAction { - Continue, - Stop, -} - /// Add the results of an export operation to a configuration. /// /// # Arguments @@ -217,15 +211,14 @@ impl Configurator { /// Invoke the get operation on a resource. /// - /// # Arguments + /// # Returns /// - /// * `error_action` - The error action to use. - /// * `progress_callback` - A callback to call when progress is made. + /// * `ConfigurationGetResult` - The result of the get operation. /// /// # Errors /// /// This function will return an error if the underlying resource fails. - pub fn invoke_get(&mut self, _error_action: ErrorAction, _progress_callback: impl Fn() + 'static) -> Result { + 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)?; @@ -235,7 +228,7 @@ impl Configurator { Span::current().pb_inc(1); pb_span.pb_set_message(format!("Get '{}'", resource.name).as_str()); let properties = self.invoke_property_expressions(&resource.properties)?; - let Some(dsc_resource) = self.discovery.find_resource(&resource.resource_type.to_lowercase()) else { + let Some(dsc_resource) = self.discovery.find_resource(&resource.resource_type) else { return Err(DscError::ResourceNotFound(resource.resource_type)); }; debug!("resource_type {}", &resource.resource_type); @@ -275,14 +268,16 @@ impl Configurator { /// /// # Arguments /// - /// * `error_action` - The error action to use. - /// * `progress_callback` - A callback to call when progress is made. + /// * `skip_test` - Whether to skip the test operation. + /// + /// # Returns + /// + /// * `ConfigurationSetResult` - The result of the set operation. /// /// # Errors /// /// This function will return an error if the underlying resource fails. - #[allow(clippy::too_many_lines)] - pub fn invoke_set(&mut self, skip_test: bool, _error_action: ErrorAction, _progress_callback: impl Fn() + 'static) -> Result { + 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)?; @@ -292,7 +287,7 @@ impl Configurator { Span::current().pb_inc(1); pb_span.pb_set_message(format!("Set '{}'", resource.name).as_str()); let properties = self.invoke_property_expressions(&resource.properties)?; - let Some(dsc_resource) = self.discovery.find_resource(&resource.resource_type.to_lowercase()) else { + let Some(dsc_resource) = self.discovery.find_resource(&resource.resource_type) else { return Err(DscError::ResourceNotFound(resource.resource_type)); }; debug!("resource_type {}", &resource.resource_type); @@ -393,15 +388,14 @@ impl Configurator { /// Invoke the test operation on a resource. /// - /// # Arguments + /// # Returns /// - /// * `error_action` - The error action to use. - /// * `progress_callback` - A callback to call when progress is made. + /// * `ConfigurationTestResult` - The result of the test operation. /// /// # Errors /// /// This function will return an error if the underlying resource fails. - pub fn invoke_test(&mut self, _error_action: ErrorAction, _progress_callback: impl Fn() + 'static) -> Result { + 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)?; @@ -411,7 +405,7 @@ impl Configurator { Span::current().pb_inc(1); pb_span.pb_set_message(format!("Test '{}'", resource.name).as_str()); let properties = self.invoke_property_expressions(&resource.properties)?; - let Some(dsc_resource) = self.discovery.find_resource(&resource.resource_type.to_lowercase()) else { + let Some(dsc_resource) = self.discovery.find_resource(&resource.resource_type) else { return Err(DscError::ResourceNotFound(resource.resource_type)); }; debug!("resource_type {}", &resource.resource_type); @@ -449,11 +443,6 @@ impl Configurator { /// Invoke the export operation on a configuration. /// - /// # Arguments - /// - /// * `error_action` - The error action to use. - /// * `progress_callback` - A callback to call when progress is made. - /// /// # Returns /// /// * `ConfigurationExportResult` - The result of the export operation. @@ -461,7 +450,7 @@ impl Configurator { /// # Errors /// /// This function will return an error if the underlying resource fails. - pub fn invoke_export(&mut self, _error_action: ErrorAction, _progress_callback: impl Fn() + 'static) -> Result { + pub fn invoke_export(&mut self) -> Result { let config = self.validate_config()?; let mut result = ConfigurationExportResult::new(); @@ -473,7 +462,7 @@ impl Configurator { Span::current().pb_inc(1); pb_span.pb_set_message(format!("Export '{}'", resource.name).as_str()); let properties = self.invoke_property_expressions(&resource.properties)?; - let Some(dsc_resource) = self.discovery.find_resource(&resource.resource_type.to_lowercase()) else { + let Some(dsc_resource) = self.discovery.find_resource(&resource.resource_type) else { return Err(DscError::ResourceNotFound(resource.resource_type.clone())); }; let input = add_metadata(&dsc_resource.kind, properties)?; @@ -509,7 +498,9 @@ impl Configurator { }; for (name, parameter) in parameters { + debug!("Processing parameter '{name}'"); if let Some(default_value) = ¶meter.default_value { + debug!("Set default parameter '{name}'"); // default values can be expressions let value = if default_value.is_string() { if let Some(value) = default_value.as_str() { @@ -526,15 +517,18 @@ impl Configurator { } let Some(parameters_input) = parameters_input else { + debug!("No parameters input"); return Ok(()); }; + trace!("parameters_input: {parameters_input}"); let parameters: HashMap = serde_json::from_value::(parameters_input.clone())?.parameters; let Some(parameters_constraints) = &config.parameters else { return Err(DscError::Validation("No parameters defined in configuration".to_string())); }; for (name, value) in parameters { if let Some(constraint) = parameters_constraints.get(&name) { + debug!("Validating parameter '{name}'"); check_length(&name, &value, constraint)?; check_allowed_values(&name, &value, constraint)?; check_number_limits(&name, &value, constraint)?; @@ -611,9 +605,7 @@ impl Configurator { check_security_context(&config.metadata)?; // Perform discovery of resources used in config - let mut required_resources = config.resources.iter().map(|p| p.resource_type.to_lowercase()).collect::>(); - required_resources.sort_unstable(); - required_resources.dedup(); + let required_resources = config.resources.iter().map(|p| p.resource_type.clone()).collect::>(); self.discovery.find_resources(&required_resources); Ok(config) } diff --git a/dsc_lib/src/discovery/command_discovery.rs b/dsc_lib/src/discovery/command_discovery.rs index fb536db2..ea1ae1c0 100644 --- a/dsc_lib/src/discovery/command_discovery.rs +++ b/dsc_lib/src/discovery/command_discovery.rs @@ -106,7 +106,6 @@ impl ResourceDiscovery for CommandDiscovery { if let Ok(paths) = CommandDiscovery::get_resource_paths() { for path in paths { - trace!("Searching in {:?}", path); if path.exists() && path.is_dir() { for entry in path.read_dir().unwrap() { let entry = entry.unwrap(); @@ -142,10 +141,10 @@ impl ResourceDiscovery for CommandDiscovery { if let Some(ref manifest) = resource.manifest { let manifest = import_manifest(manifest.clone())?; if manifest.kind == Some(Kind::Adapter) { - trace!("Resource adapter {} found", resource.type_name); + trace!("Resource adapter '{}' found", resource.type_name); insert_resource(&mut adapters, &resource, true); } else { - trace!("Resource {} found", resource.type_name); + trace!("Resource '{}' found", resource.type_name); insert_resource(&mut resources, &resource, true); } } @@ -292,8 +291,12 @@ impl ResourceDiscovery for CommandDiscovery { debug!("Searching for resources: {:?}", required_resource_types); self.discover_resources("*")?; + // convert required_resource_types to lowercase to handle case-insentiive search + let mut remaining_required_resource_types = required_resource_types.iter().map(|x| x.to_lowercase()).collect::>(); + remaining_required_resource_types.sort_unstable(); + remaining_required_resource_types.dedup(); + let mut found_resources = BTreeMap::::new(); - let mut remaining_required_resource_types = required_resource_types.to_owned(); for (resource_name, resources) in &self.resources { // TODO: handle version requirements @@ -436,7 +439,11 @@ fn load_manifest(path: &Path) -> Result { }; // all command based resources are required to support `get` - let mut capabilities = vec![Capability::Get]; + let mut capabilities = if manifest.get.is_some() { + vec![Capability::Get] + } else { + vec![] + }; if let Some(set) = &manifest.set { capabilities.push(Capability::Set); if set.handles_exist == Some(true) { @@ -452,6 +459,9 @@ fn load_manifest(path: &Path) -> Result { if manifest.export.is_some() { capabilities.push(Capability::Export); } + if manifest.resolve.is_some() { + capabilities.push(Capability::Resolve); + } let resource = DscResource { type_name: manifest.resource_type.clone(), diff --git a/dsc_lib/src/discovery/mod.rs b/dsc_lib/src/discovery/mod.rs index 4a45664a..20921f7a 100644 --- a/dsc_lib/src/discovery/mod.rs +++ b/dsc_lib/src/discovery/mod.rs @@ -27,14 +27,14 @@ impl Discovery { } /// List operation for getting available resources based on the filters. - /// + /// /// # Arguments - /// + /// /// * `type_name_filter` - The filter for the resource type name. /// * `adapter_name_filter` - The filter for the adapter name. - /// + /// /// # Returns - /// + /// /// A vector of `DscResource` instances. pub fn list_available_resources(&mut self, type_name_filter: &str, adapter_name_filter: &str) -> Vec { let discovery_types: Vec> = vec![ @@ -65,13 +65,13 @@ impl Discovery { #[must_use] pub fn find_resource(&self, type_name: &str) -> Option<&DscResource> { - self.resources.get(type_name) + self.resources.get(&type_name.to_lowercase()) } /// Find resources based on the required resource types. - /// + /// /// # Arguments - /// + /// /// * `required_resource_types` - The required resource types. pub fn find_resources(&mut self, required_resource_types: &[String]) { let discovery_types: Vec> = vec![ diff --git a/dsc_lib/src/dscerror.rs b/dsc_lib/src/dscerror.rs index fd401eb7..12ab5c1f 100644 --- a/dsc_lib/src/dscerror.rs +++ b/dsc_lib/src/dscerror.rs @@ -112,4 +112,7 @@ pub enum DscError { #[error("Validation: {0}")] Validation(String), + + #[error("YAML: {0}")] + Yaml(#[from] serde_yaml::Error), } diff --git a/dsc_lib/src/dscresources/command_resource.rs b/dsc_lib/src/dscresources/command_resource.rs index 5f9a5b4d..1524e227 100644 --- a/dsc_lib/src/dscresources/command_resource.rs +++ b/dsc_lib/src/dscresources/command_resource.rs @@ -4,34 +4,28 @@ use jsonschema::JSONSchema; use serde_json::Value; use std::{collections::HashMap, env, io::{Read, Write}, process::{Command, Stdio}}; +use crate::{configure::{config_result::ResourceGetResult, parameters, Configurator}, util::parse_input_to_json}; use crate::{dscerror::DscError, dscresources::invoke_result::{ResourceGetResponse, ResourceSetResponse, ResourceTestResponse}}; -use crate::configure::config_result::ResourceGetResult; -use super::{dscresource::get_diff, invoke_result::{ExportResult, GetResult, SetResult, TestResult, ValidateResult}, resource_manifest::{ArgKind, InputKind, Kind, ResourceManifest, ReturnKind, SchemaKind}}; +use super::{dscresource::get_diff, invoke_result::{ExportResult, GetResult, ResolveResult, SetResult, TestResult, ValidateResult}, resource_manifest::{ArgKind, InputKind, Kind, ResourceManifest, ReturnKind, SchemaKind}}; use tracing::{error, warn, info, debug, trace}; pub const EXIT_PROCESS_TERMINATED: i32 = 0x102; - -pub fn log_resource_traces(process_name: &str, stderr: &str) -{ - if !stderr.is_empty() - { - for trace_line in stderr.lines() { - if let Result::Ok(json_obj) = serde_json::from_str::(trace_line) { - if let Some(msg) = json_obj.get("Error") { - error!("Process {process_name}: {}", msg.as_str().unwrap_or_default()); - } else if let Some(msg) = json_obj.get("Warning") { - warn!("Process {process_name}: {}", msg.as_str().unwrap_or_default()); - } else if let Some(msg) = json_obj.get("Info") { - info!("Process {process_name}: {}", msg.as_str().unwrap_or_default()); - } else if let Some(msg) = json_obj.get("Debug") { - debug!("Process {process_name}: {}", msg.as_str().unwrap_or_default()); - } else if let Some(msg) = json_obj.get("Trace") { - trace!("Process {process_name}: {}", msg.as_str().unwrap_or_default()); - }; - }; - } - } +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 @@ -45,18 +39,27 @@ pub fn log_resource_traces(process_name: &str, stderr: &str) /// /// 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 args = process_args(&resource.get.args, filter); + let Some(get) = &resource.get else { + return Err(DscError::NotImplemented("get".to_string())); + }; + let args = process_args(&get.args, filter); if !filter.is_empty() { verify_json(resource, cwd, filter)?; - - command_input = get_command_input(&resource.get.input, filter)?; + command_input = get_command_input(&get.input, filter)?; } - info!("Invoking get '{}' using '{}'", &resource.resource_type, &resource.get.executable); - let (_exit_code, stdout, stderr) = invoke_command(&resource.get.executable, args, command_input.stdin.as_deref(), Some(cwd), command_input.env)?; + info!("Invoking get '{}' using '{}'", &resource.resource_type, &get.executable); + let (_exit_code, stdout, stderr) = invoke_command(&get.executable, args, command_input.stdin.as_deref(), Some(cwd), command_input.env)?; if resource.kind == Some(Kind::Resource) { - debug!("Verifying output of get '{}' using '{}'", &resource.resource_type, &resource.get.executable); + debug!("Verifying output of get '{}' using '{}'", &resource.resource_type, &get.executable); verify_json(resource, cwd, &stdout)?; } @@ -67,7 +70,7 @@ pub fn invoke_get(resource: &ResourceManifest, cwd: &str, filter: &str) -> Resul 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))) + return Err(DscError::Operation(format!("Failed to parse JSON from get {}|{}|{} -> {err}", &get.executable, stdout, stderr))) } }; GetResult::Resource(ResourceGetResponse{ @@ -91,6 +94,8 @@ pub fn invoke_get(resource: &ResourceManifest, cwd: &str, filter: &str) -> Resul /// Error returned if the resource does not successfully set the desired state #[allow(clippy::too_many_lines)] pub fn invoke_set(resource: &ResourceManifest, cwd: &str, desired: &str, skip_test: bool) -> Result { + // TODO: support import resources + let Some(set) = &resource.set else { return Err(DscError::NotImplemented("set".to_string())); }; @@ -121,14 +126,17 @@ pub fn invoke_set(resource: &ResourceManifest, cwd: &str, desired: &str, skip_te } } - let args = process_args(&resource.get.args, desired); - let command_input = get_command_input(&resource.get.input, desired)?; + let Some(get) = &resource.get else { + return Err(DscError::NotImplemented("get".to_string())); + }; + let args = process_args(&get.args, desired); + let command_input = get_command_input(&get.input, desired)?; - info!("Getting current state for set by invoking get {} using {}", &resource.resource_type, &resource.get.executable); - let (exit_code, stdout, stderr) = invoke_command(&resource.get.executable, args, command_input.stdin.as_deref(), Some(cwd), command_input.env)?; + info!("Getting current state for set by invoking get {} using {}", &resource.resource_type, &get.executable); + let (exit_code, stdout, stderr) = invoke_command(&get.executable, args, command_input.stdin.as_deref(), Some(cwd), command_input.env)?; if resource.kind == Some(Kind::Resource) { - debug!("Verifying output of get '{}' using '{}'", &resource.resource_type, &resource.get.executable); + debug!("Verifying output of get '{}' using '{}'", &resource.resource_type, &get.executable); verify_json(resource, cwd, &stdout)?; } @@ -235,6 +243,8 @@ 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 { + // TODO: support import resources + 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); @@ -449,18 +459,16 @@ pub fn get_schema(resource: &ResourceManifest, cwd: &str) -> Result) -> Result { - let Some(export) = resource.export.as_ref() else { return Err(DscError::Operation(format!("Export is not supported by resource {}", &resource.resource_type))) }; - let mut command_input: CommandInput = CommandInput { env: None, stdin: None }; let args: Option>; if let Some(input) = input { if !input.is_empty() { verify_json(resource, cwd, input)?; - + command_input = get_command_input(&export.input, input)?; } @@ -480,7 +488,7 @@ pub fn invoke_export(resource: &ResourceManifest, cwd: &str, input: Option<&str> } }; if resource.kind == Some(Kind::Resource) { - debug!("Verifying output of export '{}' using '{}'", &resource.resource_type, &resource.get.executable); + debug!("Verifying output of export '{}' using '{}'", &resource.resource_type, &export.executable); verify_json(resource, cwd, line)?; } instances.push(instance); @@ -491,6 +499,35 @@ pub fn invoke_export(resource: &ResourceManifest, cwd: &str, input: Option<&str> }) } +/// Invoke the resolve operation on a resource +/// +/// # Arguments +/// +/// * `resource` - The resource manifest +/// * `cwd` - The current working directory +/// * `input` - Input to the command +/// +/// # Returns +/// +/// * `ResolveResult` - The result of the resolve operation +/// +/// # Errors +/// +/// Error returned if the resource does not successfully resolve the input +pub fn invoke_resolve(resource: &ResourceManifest, cwd: &str, input: &str) -> Result { + let Some(resolve) = &resource.resolve else { + return Err(DscError::Operation(format!("Resolve is not supported by resource {}", &resource.resource_type))); + }; + + let args = process_args(&resolve.args, input); + let command_input = get_command_input(&resolve.input, input)?; + + info!("Invoking resolve '{}' using '{}'", &resource.resource_type, &resolve.executable); + let (_exit_code, stdout, _stderr) = invoke_command(&resolve.executable, args, command_input.stdin.as_deref(), Some(cwd), command_input.env)?; + let result: ResolveResult = serde_json::from_str(&stdout)?; + Ok(result) +} + /// Invoke a command and return the exit code, stdout, and stderr. /// /// # Arguments @@ -529,7 +566,7 @@ pub fn invoke_command(executable: &str, args: Option>, input: Option let mut child = command.spawn()?; if let Some(input) = input { - trace!("Writing stdin to command: {input}"); + trace!("Writing to command STDIN: {input}"); // pipe to child stdin in a scope so that it is dropped before we wait // otherwise the pipe isn't closed and the child process waits forever let Some(mut child_stdin) = child.stdin.take() else { @@ -558,16 +595,20 @@ pub fn invoke_command(executable: &str, args: Option>, input: Option if !stdout.is_empty() { trace!("STDOUT returned: {}", &stdout); } - if !stderr.is_empty() { - trace!("STDERR returned: {}", &stderr); - log_resource_traces(executable, &stderr); - } + let cleaned_stderr = if stderr.is_empty() { + stderr + } else { + trace!("STDERR returned data to be traced"); + log_resource_traces(executable, &child.id(), &stderr); + // TODO: remove logged traces from STDERR + String::new() + }; if exit_code != 0 { - return Err(DscError::Command(executable.to_string(), exit_code, stderr)); + return Err(DscError::Command(executable.to_string(), exit_code, cleaned_stderr)); } - Ok((exit_code, stdout, stderr)) + Ok((exit_code, stdout, cleaned_stderr)) } fn process_args(args: &Option>, value: &str) -> Option> { @@ -706,3 +747,37 @@ fn json_to_hashmap(json: &str) -> Result, DscError> { } Ok(map) } + +/// Log output from a process as traces. +/// +/// # Arguments +/// +/// * `process_name` - The name of the process +/// * `process_id` - The ID of the process +/// * `stderr` - The stderr output from the process +pub fn log_resource_traces(process_name: &str, process_id: &u32, stderr: &str) +{ + if !stderr.is_empty() + { + for trace_line in stderr.lines() { + if let Result::Ok(json_obj) = serde_json::from_str::(trace_line) { + if let Some(msg) = json_obj.get("Error") { + error!("Process '{process_name}' id {process_id} : {}", msg.as_str().unwrap_or_default()); + } else if let Some(msg) = json_obj.get("Warning") { + warn!("Process '{process_name}' id {process_id} : {}", msg.as_str().unwrap_or_default()); + } else if let Some(msg) = json_obj.get("Info") { + info!("Process '{process_name}' id {process_id} : {}", msg.as_str().unwrap_or_default()); + } else if let Some(msg) = json_obj.get("Debug") { + debug!("Process '{process_name}' id {process_id} : {}", msg.as_str().unwrap_or_default()); + } else if let Some(msg) = json_obj.get("Trace") { + trace!("Process '{process_name}' id {process_id} : {}", msg.as_str().unwrap_or_default()); + } else { + // TODO: deserialize tracing JSON to have better presentation + trace!("Process '{process_name}' id {process_id} : {trace_line}"); + }; + } else { + trace!("Process '{process_name}' id {process_id} : {trace_line}"); + } + } + } +} diff --git a/dsc_lib/src/dscresources/dscresource.rs b/dsc_lib/src/dscresources/dscresource.rs index 37fa8317..25f5ea69 100644 --- a/dsc_lib/src/dscresources/dscresource.rs +++ b/dsc_lib/src/dscresources/dscresource.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; -use super::{command_resource, dscerror, invoke_result::{ExportResult, GetResult, ResourceTestResponse, SetResult, TestResult, ValidateResult}, resource_manifest::import_manifest}; +use super::{command_resource, dscerror, invoke_result::{ExportResult, GetResult, ResolveResult, ResourceTestResponse, SetResult, TestResult, ValidateResult}, resource_manifest::import_manifest}; #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] @@ -56,6 +56,8 @@ pub enum Capability { Delete, /// The resource supports exporting configuration. Export, + /// The resource supports resolving imported configuration. + Resolve, } #[derive(Clone, Debug, PartialEq, Deserialize, Serialize, JsonSchema)] @@ -168,6 +170,17 @@ pub trait Invoke { /// /// This function will return an error if the underlying resource fails. fn export(&self, input: &str) -> Result; + + /// Invoke the resolve operation on the resource. + /// + /// # Arguments + /// + /// * `input` - The input to the operation to be resolved. + /// + /// # Errors + /// + /// This function will return an error if the underlying resource fails. + fn resolve(&self, input: &str) -> Result; } impl Invoke for DscResource { @@ -296,6 +309,14 @@ impl Invoke for DscResource { let resource_manifest = import_manifest(manifest.clone())?; command_resource::invoke_export(&resource_manifest, &self.directory, Some(input)) } + + fn resolve(&self, input: &str) -> Result { + let Some(manifest) = &self.manifest else { + return Err(DscError::MissingManifest(self.type_name.clone())); + }; + let resource_manifest = import_manifest(manifest.clone())?; + command_resource::invoke_resolve(&resource_manifest, &self.directory, input) + } } #[must_use] diff --git a/dsc_lib/src/dscresources/include.rs b/dsc_lib/src/dscresources/include.rs new file mode 100644 index 00000000..125d7235 --- /dev/null +++ b/dsc_lib/src/dscresources/include.rs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] +pub struct Include { + /// The path to the file to include. Relative paths are relative to the file containing the include + /// and not allowed to reference parent directories. + #[serde(rename = "configurationFile")] + pub configuration_file: String, +} diff --git a/dsc_lib/src/dscresources/invoke_result.rs b/dsc_lib/src/dscresources/invoke_result.rs index 5df73e37..78525fd6 100644 --- a/dsc_lib/src/dscresources/invoke_result.rs +++ b/dsc_lib/src/dscresources/invoke_result.rs @@ -4,7 +4,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::Value; - +use std::collections::HashMap; use crate::configure::config_result::{ResourceGetResult, ResourceSetResult, ResourceTestResult}; #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] @@ -147,3 +147,12 @@ pub struct ExportResult { #[serde(rename = "actualState")] pub actual_state: Vec, } + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ResolveResult { + /// The resolved configuration. + pub configuration: Value, + /// The optional resolved parameters. + pub parameters: Option>, +} diff --git a/dsc_lib/src/dscresources/resource_manifest.rs b/dsc_lib/src/dscresources/resource_manifest.rs index e54b3944..0060659c 100644 --- a/dsc_lib/src/dscresources/resource_manifest.rs +++ b/dsc_lib/src/dscresources/resource_manifest.rs @@ -13,10 +13,11 @@ use crate::dscerror::DscError; pub enum Kind { Adapter, Group, + Import, Resource, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] +#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct ResourceManifest { /// The version of the resource manifest schema. @@ -35,7 +36,7 @@ pub struct ResourceManifest { /// Tags for the resource. pub tags: Option>, /// Details how to call the Get method of the resource. - pub get: GetMethod, + pub get: Option, /// Details how to call the Set method of the resource. #[serde(skip_serializing_if = "Option::is_none")] pub set: Option, @@ -48,6 +49,9 @@ pub struct ResourceManifest { /// Details how to call the Export method of the resource. #[serde(skip_serializing_if = "Option::is_none")] pub export: Option, + /// Details how to call the Resolve method of the resource. + #[serde(skip_serializing_if = "Option::is_none")] + pub resolve: Option, /// Details how to call the Validate method of the resource. #[serde(skip_serializing_if = "Option::is_none")] pub validate: Option, @@ -92,13 +96,13 @@ pub enum ArgKind { /// The argument is a string. String(String), /// The argument accepts the JSON input object. - Json{ + Json { /// The argument that accepts the JSON input object. #[serde(rename = "jsonInputArg")] json_input_arg: String, /// Indicates if argument is mandatory which will pass an empty string if no JSON input is provided. Default is false. mandatory: Option, - } + }, } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] @@ -142,7 +146,7 @@ pub enum ReturnKind { StateAndDiff, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] +#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] pub struct GetMethod { /// The command to run to get the state of the resource. pub executable: String, @@ -215,6 +219,16 @@ pub struct ExportMethod { pub input: Option, } +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] +pub struct ResolveMethod { + /// The command to run to enumerate instances of the resource. + pub executable: String, + /// The arguments to pass to the command to perform a Export. + pub args: Option>, + /// How to pass input for a Export. + pub input: Option, +} + #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] pub struct Adapter { /// The way to list adapter supported resources. diff --git a/dsc_lib/src/lib.rs b/dsc_lib/src/lib.rs index 743e6b1c..482b9e9b 100644 --- a/dsc_lib/src/lib.rs +++ b/dsc_lib/src/lib.rs @@ -10,6 +10,7 @@ pub mod dscerror; pub mod dscresources; pub mod functions; pub mod parser; +pub mod util; pub struct DscManager { discovery: discovery::Discovery, diff --git a/dsc_lib/src/util.rs b/dsc_lib/src/util.rs new file mode 100644 index 00000000..a5bbcccf --- /dev/null +++ b/dsc_lib/src/util.rs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::dscerror::DscError; +use serde_json::Value; + +/// Return JSON string whether the input is JSON or YAML +/// +/// # Arguments +/// +/// * `value` - A string slice that holds the input value +/// +/// # Returns +/// +/// A string that holds the JSON value +/// +/// # Errors +/// +/// This function will return an error if the input value is not valid JSON or YAML +pub fn parse_input_to_json(value: &str) -> Result { + match serde_json::from_str(value) { + Ok(json) => Ok(json), + Err(_) => { + match serde_yaml::from_str::(value) { + Ok(yaml) => { + match serde_json::to_value(yaml) { + Ok(json) => Ok(json.to_string()), + Err(err) => { + Err(DscError::Json(err)) + } + } + }, + Err(err) => { + Err(DscError::Yaml(err)) + } + } + } + } +} diff --git a/osinfo/tests/osinfo.tests.ps1 b/osinfo/tests/osinfo.tests.ps1 index 8d75cc98..5120c3be 100644 --- a/osinfo/tests/osinfo.tests.ps1 +++ b/osinfo/tests/osinfo.tests.ps1 @@ -3,7 +3,7 @@ Describe 'osinfo resource tests' { It 'should get osinfo' { - $out = dsc resource get -r Microsoft/osinfo | ConvertFrom-Json + $out = dsc resource get -r Microsoft/osInfo | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 if ($IsWindows) { $out.actualState.family | Should -BeExactly 'Windows' @@ -31,7 +31,7 @@ Describe 'osinfo resource tests' { else { $invalid = 'Windows' } - $out = "{`"family`": `"$invalid`"}" | dsc resource test -r 'Microsoft/osinfo' | ConvertFrom-Json + $out = "{`"family`": `"$invalid`"}" | dsc resource test -r 'Microsoft/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 @@ -41,7 +41,7 @@ Describe 'osinfo resource tests' { } It 'should support export' { - $out = dsc resource export -r Microsoft/osinfo | ConvertFrom-Json + $out = dsc resource export -r Microsoft/OSInfo | ConvertFrom-Json $out.'$schema' | Should -BeExactly 'https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2024/04/config/document.json' if ($IsWindows) { $out.resources[0].properties.family | Should -BeExactly 'Windows' diff --git a/powershell-adapter/Tests/powershellgroup.config.tests.ps1 b/powershell-adapter/Tests/powershellgroup.config.tests.ps1 index a562cefb..b36ff5ed 100644 --- a/powershell-adapter/Tests/powershellgroup.config.tests.ps1 +++ b/powershell-adapter/Tests/powershellgroup.config.tests.ps1 @@ -135,7 +135,7 @@ Describe 'PowerShell adapter resource tests' { $res.results.result.actualState.result.properties.Prop1 | Should -Be $TestDrive } - It 'DSC_CONFIG_ROOT env var does not exist when config is piped from stdin' -Skip:(!$IsWindows){ + It 'DSC_CONFIG_ROOT env var is cwd when config is piped from stdin' -Skip:(!$IsWindows){ $yaml = @" `$schema: https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2024/04/config/document.json @@ -149,8 +149,8 @@ Describe 'PowerShell adapter resource tests' { properties: Name: "[envvar('DSC_CONFIG_ROOT')]" "@ - $testError = & {$yaml | dsc config get 2>&1} - $testError | Select-String 'Environment variable not found' -Quiet | Should -BeTrue - $LASTEXITCODE | Should -Be 2 + $out = $yaml | dsc config get | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out.results[0].result.actualState.result[0].properties.Name | Should -BeExactly (Get-Location).Path } } diff --git a/runcommandonset/src/args.rs b/runcommandonset/src/args.rs index e4816a55..ab546d2d 100644 --- a/runcommandonset/src/args.rs +++ b/runcommandonset/src/args.rs @@ -13,7 +13,7 @@ pub enum TraceFormat { #[derive(Debug, Clone, PartialEq, Eq, ValueEnum)] pub enum TraceLevel { Error, - Warning, + Warn, Info, Debug, Trace @@ -26,7 +26,7 @@ pub struct Arguments { #[clap(subcommand)] pub subcommand: SubCommand, #[clap(short = 'l', long, help = "Trace level to use", value_enum, default_value = "info")] - pub trace_level: TraceLevel, + pub trace_level: Option, #[clap(short = 'f', long, help = "Trace format to use", value_enum, default_value = "json")] pub trace_format: TraceFormat, } diff --git a/runcommandonset/src/main.rs b/runcommandonset/src/main.rs index 16cd3543..f5e2a6b5 100644 --- a/runcommandonset/src/main.rs +++ b/runcommandonset/src/main.rs @@ -2,12 +2,12 @@ // Licensed under the MIT License. use atty::Stream; -use clap::{Parser}; +use clap::Parser; use std::{io::{self, Read}, process::exit}; use tracing::{error, warn, debug}; -use args::{Arguments, SubCommand}; -use runcommand::{RunCommand}; +use args::{Arguments, SubCommand, TraceLevel}; +use runcommand::RunCommand; use utils::{enable_tracing, invoke_command, parse_input, EXIT_INVALID_ARGS}; pub mod args; @@ -16,7 +16,29 @@ pub mod utils; fn main() { let args = Arguments::parse(); - enable_tracing(&args.trace_level, &args.trace_format); + let trace_level = match args.trace_level { + Some(trace_level) => trace_level, + None => { + // get from DSC_TRACE_LEVEL env var + if let Ok(trace_level) = std::env::var("DSC_TRACE_LEVEL") { + match trace_level.to_lowercase().as_str() { + "error" => TraceLevel::Error, + "warn" => TraceLevel::Warn, + "info" => TraceLevel::Info, + "debug" => TraceLevel::Debug, + "trace" => TraceLevel::Trace, + _ => { + warn!("Invalid trace level: {trace_level}"); + TraceLevel::Info + } + } + } else { + // default to info + TraceLevel::Info + } + } + }; + enable_tracing(&trace_level, &args.trace_format); warn!("This resource is not idempotent"); let stdin = if atty::is(Stream::Stdin) { diff --git a/runcommandonset/src/utils.rs b/runcommandonset/src/utils.rs index 831b4fc3..62937499 100644 --- a/runcommandonset/src/utils.rs +++ b/runcommandonset/src/utils.rs @@ -65,7 +65,7 @@ pub fn enable_tracing(trace_level: &TraceLevel, trace_format: &TraceFormat) { // originally implemented in dsc/src/util.rs let tracing_level = match trace_level { TraceLevel::Error => Level::ERROR, - TraceLevel::Warning => Level::WARN, + TraceLevel::Warn => Level::WARN, TraceLevel::Info => Level::INFO, TraceLevel::Debug => Level::DEBUG, TraceLevel::Trace => Level::TRACE, diff --git a/runcommandonset/tests/runcommandonset.set.tests.ps1 b/runcommandonset/tests/runcommandonset.set.tests.ps1 index 94dd30e7..e3188d22 100644 --- a/runcommandonset/tests/runcommandonset.set.tests.ps1 +++ b/runcommandonset/tests/runcommandonset.set.tests.ps1 @@ -76,25 +76,13 @@ Describe 'tests for runcommandonset set' { } It 'Executable does not exist' { - '{ "executable": "foo" }' | dsc resource set -r Microsoft.DSC.Transitional/RunCommandOnSet 2> $TestDrive/output.txt - $actual = Get-Content -Path $TestDrive/output.txt + '{ "executable": "foo" }' | dsc -l trace resource set -r Microsoft.DSC.Transitional/RunCommandOnSet 2> $TestDrive/output.txt + $actual = Get-Content -Path $TestDrive/output.txt -Raw $expected_logging = 'Failed to execute foo: No such file or directory (os error 2)' if ($IsWindows) { $expected_logging = 'Failed to execute foo: program not found' } - $found_logging = $false - ForEach ($line in $actual) { - try { - $log = $line | ConvertFrom-Json - if ($log.fields.message -eq $expected_logging) { - $found_logging = $true - break - } - } catch { - # skip lines that aren't JSON - } - } - $found_logging | Should -Be $true + $actual | Should -BeLike "*$expected_logging*" $LASTEXITCODE | Should -Be 2 } } diff --git a/tools/dsctest/dsctrace.dsc.resource.json b/tools/dsctest/dsctrace.dsc.resource.json new file mode 100644 index 00000000..b5b93c04 --- /dev/null +++ b/tools/dsctest/dsctrace.dsc.resource.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2024/04/bundled/resource/manifest.json", + "type": "Test/Trace", + "version": "0.1.0", + "get": { + "executable": "dsctest", + "args": [ + "trace" + ], + "input": "stdin" + }, + "set": { + "executable": "dsctest", + "args": [ + "trace" + ], + "input": "stdin" + }, + "test": { + "executable": "dsctest", + "args": [ + "trace" + ], + "input": "stdin" + }, + "schema": { + "command": { + "executable": "dsctest", + "args": [ + "schema", + "-s", + "trace" + ] + } + } +} diff --git a/tools/dsctest/src/args.rs b/tools/dsctest/src/args.rs index 562ce8b9..f74c508b 100644 --- a/tools/dsctest/src/args.rs +++ b/tools/dsctest/src/args.rs @@ -9,6 +9,7 @@ pub enum Schemas { Echo, Exist, Sleep, + Trace, } #[derive(Debug, Parser)] @@ -50,4 +51,7 @@ pub enum SubCommand { #[clap(name = "input", short, long, help = "The input to the sleep command as JSON")] input: String, }, + + #[clap(name = "trace", about = "The trace level")] + Trace, } diff --git a/tools/dsctest/src/main.rs b/tools/dsctest/src/main.rs index 931ad334..79684cb3 100644 --- a/tools/dsctest/src/main.rs +++ b/tools/dsctest/src/main.rs @@ -6,6 +6,7 @@ mod delete; mod echo; mod exist; mod sleep; +mod trace; use args::{Args, Schemas, SubCommand}; use clap::Parser; @@ -14,6 +15,7 @@ use crate::delete::Delete; use crate::echo::Echo; use crate::exist::{Exist, State}; use crate::sleep::Sleep; +use crate::trace::Trace; use std::{thread, time::Duration}; fn main() { @@ -70,6 +72,9 @@ fn main() { Schemas::Sleep => { schema_for!(Sleep) }, + Schemas::Trace => { + schema_for!(Trace) + }, }; serde_json::to_string(&schema).unwrap() }, @@ -84,6 +89,17 @@ fn main() { thread::sleep(Duration::from_secs(sleep.seconds)); serde_json::to_string(&sleep).unwrap() }, + SubCommand::Trace => { + // get level from DSC_TRACE_LEVEL env var + let level = match std::env::var("DSC_TRACE_LEVEL") { + Ok(level) => level, + Err(_) => "warn".to_string(), + }; + let trace = trace::Trace { + level, + }; + serde_json::to_string(&trace).unwrap() + }, }; println!("{json}"); diff --git a/tools/dsctest/src/trace.rs b/tools/dsctest/src/trace.rs new file mode 100644 index 00000000..f9d9ff50 --- /dev/null +++ b/tools/dsctest/src/trace.rs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Trace { + pub level: String, +} diff --git a/tools/test_group_resource/src/main.rs b/tools/test_group_resource/src/main.rs index cf2e742a..9bff0beb 100644 --- a/tools/test_group_resource/src/main.rs +++ b/tools/test_group_resource/src/main.rs @@ -30,20 +30,11 @@ fn main() { resource_type: "Test/TestResource1".to_string(), kind: Some(Kind::Resource), version: "1.0.0".to_string(), - tags: None, - get: GetMethod { + get: Some(GetMethod { executable: String::new(), - args: None, - input: None, - }, - set: None, - test: None, - delete: None, - export: None, - validate: None, - adapter: None, - exit_codes: None, - schema: None, + ..Default::default() + }), + ..Default::default() }).unwrap()), }; let resource2 = DscResource { @@ -64,20 +55,11 @@ fn main() { resource_type: "Test/TestResource2".to_string(), kind: Some(Kind::Resource), version: "1.0.1".to_string(), - tags: None, - get: GetMethod { + get: Some(GetMethod { executable: String::new(), - args: None, - input: None, - }, - set: None, - test: None, - delete: None, - export: None, - validate: None, - adapter: None, - exit_codes: None, - schema: None, + ..Default::default() + }), + ..Default::default() }).unwrap()), }; println!("{}", serde_json::to_string(&resource1).unwrap());