diff --git a/dsc/src/args.rs b/dsc/src/args.rs index 188dfb0d..3a310b36 100644 --- a/dsc/src/args.rs +++ b/dsc/src/args.rs @@ -50,6 +50,8 @@ pub enum ConfigSubCommand { Test, #[clap(name = "validate", about = "Validate the current configuration", hide = true)] Validate, + #[clap(name = "export", about = "Export the current configuration")] + Export } #[derive(Debug, PartialEq, Eq, Subcommand)] @@ -65,6 +67,8 @@ pub enum ResourceSubCommand { }, #[clap(name = "get", about = "Invoke the get operation to a resource", arg_required_else_help = true)] Get { + #[clap(short, long, help = "Get all instances of the resource")] + all: bool, #[clap(short, long, help = "The name or DscResource JSON of the resource to invoke `get` on")] resource: String, #[clap(short, long, help = "The input to pass to the resource as JSON")] @@ -89,6 +93,11 @@ pub enum ResourceSubCommand { #[clap(short, long, help = "The name of the resource to get the JSON schema")] resource: String, }, + #[clap(name = "export", about = "Retrieve all resource instances", arg_required_else_help = true)] + Export { + #[clap(short, long, help = "The name or DscResource JSON of the resource to invoke `export` on")] + resource: String, + }, } #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] diff --git a/dsc/src/resource_command.rs b/dsc/src/resource_command.rs index 53b52a6d..0cae2db2 100644 --- a/dsc/src/resource_command.rs +++ b/dsc/src/resource_command.rs @@ -3,6 +3,9 @@ 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::dscresource::{Invoke, DscResource}, @@ -42,6 +45,34 @@ pub fn get(dsc: &mut DscManager, resource: &str, input: &Option, stdin: } } +pub fn get_all(dsc: &mut DscManager, resource: &str, _input: &Option, _stdin: &Option, format: &Option) { + let resource = get_resource(dsc, resource); + + let export_result = match resource.export() { + Ok(export) => { export } + Err(err) => { + eprintln!("Error: {err}"); + exit(EXIT_DSC_ERROR); + } + }; + + for instance in export_result.actual_state + { + let get_result = GetResult { + actual_state: instance.clone(), + }; + + let json = match serde_json::to_string(&get_result) { + Ok(json) => json, + Err(err) => { + eprintln!("JSON Error: {err}"); + exit(EXIT_JSON_ERROR); + } + }; + write_output(&json, format); + } +} + pub fn set(dsc: &mut DscManager, resource: &str, input: &Option, stdin: &Option, format: &Option) { let mut input = get_input(input, stdin); let mut resource = get_resource(dsc, resource); @@ -129,6 +160,23 @@ pub fn schema(dsc: &mut DscManager, resource: &str, format: &Option) { + let dsc_resource = get_resource(dsc, resource); + + let mut conf = Configuration::new(); + + add_resource_export_results_to_configuration(&dsc_resource, &mut conf); + + let json = match serde_json::to_string(&conf) { + Ok(json) => json, + Err(err) => { + eprintln!("JSON Error: {err}"); + exit(EXIT_JSON_ERROR); + } + }; + write_output(&json, format); +} + pub fn get_resource(dsc: &mut DscManager, resource: &str) -> DscResource { // check if resource is JSON or just a name match serde_json::from_str(resource) { diff --git a/dsc/src/subcommand.rs b/dsc/src/subcommand.rs index 37aa638d..679e6a25 100644 --- a/dsc/src/subcommand.rs +++ b/dsc/src/subcommand.rs @@ -86,6 +86,35 @@ pub fn config_test(configurator: Configurator, format: &Option) } } +pub fn config_export(configurator: Configurator, format: &Option) +{ + match configurator.invoke_export(ErrorAction::Continue, || { /* code */ }) { + Ok(result) => { + let json = match serde_json::to_string(&result.result) { + Ok(json) => json, + Err(err) => { + eprintln!("JSON Error: {err}"); + exit(EXIT_JSON_ERROR); + } + }; + write_output(&json, format); + if result.had_errors { + + for msg in result.messages + { + eprintln!("{:?} message {}", msg.level, msg.message); + }; + + exit(EXIT_DSC_ERROR); + } + }, + Err(err) => { + eprintln!("Error: {err}"); + exit(EXIT_DSC_ERROR); + } + } +} + pub fn config(subcommand: &ConfigSubCommand, format: &Option, stdin: &Option) { if stdin.is_none() { eprintln!("Configuration must be piped to STDIN"); @@ -134,6 +163,9 @@ pub fn config(subcommand: &ConfigSubCommand, format: &Option, stdi }, ConfigSubCommand::Validate => { validate_config(&json_string); + }, + ConfigSubCommand::Export => { + config_export(configurator, format); } } } @@ -353,8 +385,9 @@ pub fn resource(subcommand: &ResourceSubCommand, format: &Option, table.print(); } }, - ResourceSubCommand::Get { resource, input } => { - resource_command::get(&mut dsc, resource, input, stdin, format); + ResourceSubCommand::Get { resource, input, all } => { + if *all { resource_command::get_all(&mut dsc, resource, input, stdin, format); } + else { resource_command::get(&mut dsc, resource, input, stdin, format); }; }, ResourceSubCommand::Set { resource, input } => { resource_command::set(&mut dsc, resource, input, stdin, format); @@ -365,5 +398,8 @@ pub fn resource(subcommand: &ResourceSubCommand, format: &Option, ResourceSubCommand::Schema { resource } => { resource_command::schema(&mut dsc, resource, format); }, + ResourceSubCommand::Export { resource} => { + resource_command::export(&mut dsc, resource, format); + }, } } diff --git a/dsc/tests/dsc_export.tests.ps1 b/dsc/tests/dsc_export.tests.ps1 new file mode 100644 index 00000000..7c8ae7ee --- /dev/null +++ b/dsc/tests/dsc_export.tests.ps1 @@ -0,0 +1,77 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'resource export tests' { + + It 'Export can be called on individual resource' { + + $out = dsc resource export -r Microsoft/Process + $LASTEXITCODE | Should -Be 0 + $config_with_process_list = $out | ConvertFrom-Json + $config_with_process_list.'$schema' | Should -BeExactly 'https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2023/08/config/document.json' + $config_with_process_list.'resources' | Should -Not -BeNullOrEmpty + $config_with_process_list.resources.count | Should -BeGreaterThan 1 + } + + It 'get --all can be called on individual resource' { + + $out = dsc resource get --all -r Microsoft/Process + $LASTEXITCODE | Should -Be 0 + $process_list = $out | ConvertFrom-Json + $process_list.resources.count | Should -BeGreaterThan 1 + $process_list | % {$_.actualState | Should -Not -BeNullOrEmpty} + } + + It 'Export can be called on a configuration' { + + $yaml = @' + $schema: https://schemas.microsoft.com/dsc/2023/03/configuration.schema.json + resources: + - name: Processes + type: Microsoft/Process + properties: + pid: 0 +'@ + $out = $yaml | dsc config export + $LASTEXITCODE | Should -Be 0 + $config_with_process_list = $out | ConvertFrom-Json + $config_with_process_list.'$schema' | Should -BeExactly 'https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2023/08/config/document.json' + $config_with_process_list.'resources' | Should -Not -BeNullOrEmpty + $config_with_process_list.resources.count | Should -BeGreaterThan 1 + } + + It 'Configuration Export can be piped to configuration Set' -Skip:(!$IsWindows) { + + $yaml = @' + $schema: https://schemas.microsoft.com/dsc/2023/03/configuration.schema.json + resources: + - name: Processes + type: Microsoft/Process + properties: + pid: 0 +'@ + $out = $yaml | dsc config export | dsc config set + $LASTEXITCODE | Should -Be 0 + $set_results = $out | ConvertFrom-Json + $set_results.results.count | Should -BeGreaterThan 1 + } + + It 'Duplicate resource types in Configuration Export should result in error' { + + $yaml = @' + $schema: https://schemas.microsoft.com/dsc/2023/03/configuration.schema.json + resources: + - name: Processes + type: Microsoft/Process + properties: + pid: 0 + - name: Processes + type: Microsoft/Process + properties: + pid: 0 +'@ + $out = $yaml | dsc config export 2>&1 + $LASTEXITCODE | Should -Be 2 + $out | Should -BeLike '*specified multiple times*' + } +} diff --git a/dsc_lib/src/configure/config_doc.rs b/dsc_lib/src/configure/config_doc.rs index 9268aaee..2938bf39 100644 --- a/dsc_lib/src/configure/config_doc.rs +++ b/dsc_lib/src/configure/config_doc.rs @@ -91,3 +91,34 @@ impl Default for Configuration { } } } + +impl Configuration { + #[must_use] + pub fn new() -> Self { + Self { + schema: SCHEMA.to_string(), + parameters: None, + variables: None, + resources: Vec::new(), + metadata: None, + } + } +} + +impl Resource { + #[must_use] + pub fn new() -> Self { + Self { + resource_type: String::new(), + name: String::new(), + depends_on: None, + properties: None, + } + } +} + +impl Default for Resource { + fn default() -> Self { + Self::new() + } +} diff --git a/dsc_lib/src/configure/config_result.rs b/dsc_lib/src/configure/config_result.rs index dffbb5a7..96ab11ea 100644 --- a/dsc_lib/src/configure/config_result.rs +++ b/dsc_lib/src/configure/config_result.rs @@ -4,6 +4,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::dscresources::invoke_result::{GetResult, SetResult, TestResult}; +use crate::configure::config_doc; #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] pub enum MessageLevel { @@ -126,3 +127,29 @@ impl Default for ConfigurationTestResult { Self::new() } } + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ConfigurationExportResult { + pub result: Option, + pub messages: Vec, + #[serde(rename = "hadErrors")] + pub had_errors: bool, +} + +impl ConfigurationExportResult { + #[must_use] + pub fn new() -> Self { + Self { + result: None, + messages: Vec::new(), + had_errors: false, + } + } +} + +impl Default for ConfigurationExportResult { + fn default() -> Self { + Self::new() + } +} diff --git a/dsc_lib/src/configure/mod.rs b/dsc_lib/src/configure/mod.rs index d866d34c..30348914 100644 --- a/dsc_lib/src/configure/mod.rs +++ b/dsc_lib/src/configure/mod.rs @@ -5,10 +5,12 @@ use jsonschema::JSONSchema; use crate::dscerror::DscError; use crate::dscresources::dscresource::Invoke; +use crate::DscResource; use crate::discovery::Discovery; use self::config_doc::Configuration; -use self::config_result::{ConfigurationGetResult, ConfigurationSetResult, ConfigurationTestResult, ResourceMessage, MessageLevel}; use self::depends_on::get_resource_invocation_order; +use self::config_result::{ConfigurationGetResult, ConfigurationSetResult, ConfigurationTestResult, ConfigurationExportResult, ResourceMessage, MessageLevel}; +use std::collections::{HashMap, HashSet}; pub mod config_doc; pub mod config_result; @@ -25,6 +27,21 @@ pub enum ErrorAction { Stop, } +pub fn add_resource_export_results_to_configuration(resource: &DscResource, conf: &mut Configuration) { + let export_result = resource.export().unwrap(); + + for (i, instance) in export_result.actual_state.iter().enumerate() + { + let mut r = config_doc::Resource::new(); + r.resource_type = resource.type_name.clone(); + r.name = format!("{}-{i}", r.resource_type); + let props: HashMap = serde_json::from_value(instance.clone()).unwrap(); + r.properties = Some(props); + + conf.resources.push(r); + } +} + impl Configurator { /// Create a new `Configurator` instance. /// @@ -152,6 +169,60 @@ impl Configurator { Ok(result) } + fn find_duplicate_resource_types(config: &Configuration) -> Vec + { + let mut map: HashMap<&String, i32> = HashMap::new(); + let mut result: HashSet = HashSet::new(); + let resource_list = &config.resources; + if resource_list.is_empty() { + return Vec::new(); + } + + for r in resource_list + { + let v = map.entry(&r.resource_type).or_insert(0); + *v += 1; + if *v > 1 { + result.insert(r.resource_type.clone()); + } + } + + result.into_iter().collect() + } + + pub fn invoke_export(&self, _error_action: ErrorAction, _progress_callback: impl Fn() + 'static) -> Result { + let (config, messages, had_errors) = self.validate_config()?; + + let duplicates = Self::find_duplicate_resource_types(&config); + if !duplicates.is_empty() + { + let duplicates_string = &duplicates.join(","); + return Err(DscError::Validation(format!("Resource(s) {duplicates_string} specified multiple times"))); + } + + let mut result = ConfigurationExportResult { + result: None, + messages, + had_errors + }; + + if had_errors { + return Ok(result); + }; + let mut conf = config_doc::Configuration::new(); + + for resource in &config.resources { + let Some(dsc_resource) = self.discovery.find_resource(&resource.resource_type).next() else { + return Err(DscError::ResourceNotFound(resource.resource_type.clone())); + }; + add_resource_export_results_to_configuration(&dsc_resource, &mut conf); + } + + result.result = Some(conf); + + Ok(result) + } + fn validate_config(&self) -> Result<(Configuration, Vec, bool), DscError> { let config: Configuration = serde_json::from_str(self.config.as_str())?; let mut messages: Vec = Vec::new(); diff --git a/dsc_lib/src/dscresources/command_resource.rs b/dsc_lib/src/dscresources/command_resource.rs index 33be8841..e0ebc65d 100644 --- a/dsc_lib/src/dscresources/command_resource.rs +++ b/dsc_lib/src/dscresources/command_resource.rs @@ -6,7 +6,7 @@ use serde_json::Value; use std::{process::Command, io::{Write, Read}, process::Stdio}; use crate::dscerror::DscError; -use super::{dscresource::get_diff,resource_manifest::{ResourceManifest, ReturnKind, SchemaKind}, invoke_result::{GetResult, SetResult, TestResult, ValidateResult}}; +use super::{dscresource::get_diff,resource_manifest::{ResourceManifest, ReturnKind, SchemaKind}, invoke_result::{GetResult, SetResult, TestResult, ValidateResult, ExportResult}}; pub const EXIT_PROCESS_TERMINATED: i32 = 0x102; @@ -54,7 +54,7 @@ pub fn invoke_get(resource: &ResourceManifest, cwd: &str, filter: &str) -> Resul /// /// Error returned if the resource does not successfully set the desired state pub fn invoke_set(resource: &ResourceManifest, cwd: &str, desired: &str) -> Result { - let Some(set) = &resource.set else { + let Some(set) = resource.set.as_ref() else { return Err(DscError::NotImplemented("set".to_string())); }; verify_json(resource, cwd, desired)?; @@ -255,6 +255,34 @@ pub fn get_schema(resource: &ResourceManifest, cwd: &str) -> Result Result { + + if resource.export.is_none() + { + return Err(DscError::Operation(format!("Export is not supported by resource {}", &resource.resource_type))) + } + + let (exit_code, stdout, stderr) = invoke_command(&resource.export.clone().unwrap().executable, resource.export.clone().unwrap().args.clone(), None, Some(cwd))?; + if exit_code != 0 { + return Err(DscError::Command(resource.resource_type.clone(), exit_code, stderr)); + } + let mut instances: Vec = Vec::new(); + for line in stdout.lines() + { + let instance: Value = match serde_json::from_str(line){ + Result::Ok(r) => {r}, + Result::Err(err) => { + return Err(DscError::Operation(format!("Failed to parse json from export {}|{}|{} -> {err}", &resource.export.clone().unwrap().executable, stdout, stderr))) + } + }; + instances.push(instance); + } + + Ok(ExportResult { + actual_state: instances, + }) +} + /// Invoke a command and return the exit code, stdout, and stderr. /// /// # Arguments diff --git a/dsc_lib/src/dscresources/dscresource.rs b/dsc_lib/src/dscresources/dscresource.rs index 6b5dac7f..b06138f6 100644 --- a/dsc_lib/src/dscresources/dscresource.rs +++ b/dsc_lib/src/dscresources/dscresource.rs @@ -6,7 +6,7 @@ use resource_manifest::ResourceManifest; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::Value; -use super::{command_resource, dscerror, resource_manifest, invoke_result::{GetResult, SetResult, TestResult, ValidateResult}}; +use super::{command_resource, dscerror, resource_manifest, invoke_result::{GetResult, SetResult, TestResult, ValidateResult, ExportResult}}; #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] @@ -120,6 +120,13 @@ pub trait Invoke { /// /// This function will return an error if the underlying resource fails. fn schema(&self) -> Result; + + /// Invoke the export operation on the resource. + /// + /// # Errors + /// + /// This function will return an error if the underlying resource fails. + fn export(&self) -> Result; } impl Invoke for DscResource { @@ -213,6 +220,21 @@ impl Invoke for DscResource { }, } } + + fn export(&self) -> Result { + match &self.implemented_as { + ImplementedAs::Custom(_custom) => { + Err(DscError::NotImplemented("export custom resources".to_string())) + }, + ImplementedAs::Command => { + let Some(manifest) = &self.manifest else { + return Err(DscError::MissingManifest(self.type_name.clone())); + }; + let resource_manifest = serde_json::from_value::(manifest.clone())?; + command_resource::invoke_export(&resource_manifest, &self.directory) + }, + } + } } #[must_use] diff --git a/dsc_lib/src/dscresources/invoke_result.rs b/dsc_lib/src/dscresources/invoke_result.rs index 2d2f9229..63dd3b7d 100644 --- a/dsc_lib/src/dscresources/invoke_result.rs +++ b/dsc_lib/src/dscresources/invoke_result.rs @@ -52,3 +52,11 @@ pub struct ValidateResult { /// Reason for the validation result. pub reason: Option, } + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ExportResult { + /// The state of the resource as it was returned by the Get method. + #[serde(rename = "actualState")] + pub actual_state: Vec, +} diff --git a/dsc_lib/src/dscresources/resource_manifest.rs b/dsc_lib/src/dscresources/resource_manifest.rs index d668a677..5f97f025 100644 --- a/dsc_lib/src/dscresources/resource_manifest.rs +++ b/dsc_lib/src/dscresources/resource_manifest.rs @@ -29,6 +29,9 @@ pub struct ResourceManifest { /// Details how to call the Test method of the resource. #[serde(skip_serializing_if = "Option::is_none")] pub test: Option, + /// 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 Validate method of the resource. #[serde(skip_serializing_if = "Option::is_none")] pub validate: Option, @@ -132,6 +135,14 @@ pub struct ValidateMethod { // TODO: enable validation via schema or command pub args: Option>, } +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] +pub struct ExportMethod { + /// 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>, +} + #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] pub struct Provider { /// The way to list provider supported resources. diff --git a/process/ExportTest.dsc.yaml b/process/ExportTest.dsc.yaml new file mode 100644 index 00000000..1fc6cbc9 --- /dev/null +++ b/process/ExportTest.dsc.yaml @@ -0,0 +1,7 @@ +# Example configuration mixing native app resources with classic PS resources +$schema: https://schemas.microsoft.com/dsc/2023/03/configuration.schema.json +resources: +- name: Processes + type: Microsoft/Process + properties: + pid: 0 diff --git a/process/process.dsc.resource.json b/process/process.dsc.resource.json index 9ce5d375..e5391cc2 100644 --- a/process/process.dsc.resource.json +++ b/process/process.dsc.resource.json @@ -1,7 +1,7 @@ { "manifestVersion": "1.0", "description": "Returns information about running processes.", - "type": "Microsoft/ProcessList", + "type": "Microsoft/Process", "version": "0.1.0", "get": { "executable": "process", @@ -15,7 +15,41 @@ "set" ], "input": "stdin", - "preTest": true, + "preTest": false, "return": "state" + }, + "test": { + "executable": "process", + "args": [ + "test" + ], + "input": "stdin", + "return": "state" + }, + "export": { + "executable": "process", + "args": [ + "list" + ] + }, + "schema": { + "embedded": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Process", + "type": "object", + "required": [], + "properties": { + "pid": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "cmdline": { + "type": "string" + } + }, + "additionalProperties": false + } } } diff --git a/process/src/main.rs b/process/src/main.rs index 5bd47034..48e50076 100644 --- a/process/src/main.rs +++ b/process/src/main.rs @@ -4,38 +4,60 @@ mod process_info; use std::env; use std::process::exit; +use std::io::{self, Read}; use sysinfo::{ProcessExt, System, SystemExt, PidExt}; +use crate::process_info::ProcessInfo; -fn print_task_list() { - +fn get_task_list() -> Vec +{ + let mut result = Vec::new(); let mut s = System::new(); s.refresh_processes(); for (pid, process) in s.processes() { - let mut p = process_info::ProcessInfo::new(); + let mut p = ProcessInfo::new(); p.pid = pid.as_u32(); p.name = String::from(process.name()); p.cmdline = format!("{:?}", process.cmd()); - - let json = serde_json::to_string(&p).unwrap(); - println!("{json}"); + result.push(p); } + + result } fn help() { println!("usage: process list"); } +fn print_input() { + let mut buffer: Vec = Vec::new(); + io::stdin().read_to_end(&mut buffer).unwrap(); + let input = String::from_utf8(buffer); + println!("{}", input.unwrap()); +} + fn main() { let args: Vec = env::args().collect(); if args.len() == 2 { // one argument passed match args[1].as_str() { "list" => { - print_task_list(); + for p in get_task_list() + { + let json = serde_json::to_string(&p).unwrap(); + println!("{json}"); + } + exit(0); + }, + "get" => { // used for testing only + print_input(); exit(0); }, "set" => { // used for testing only - println!("{{\"result\":\"Ok\"}}"); + print_input(); + exit(0); + }, + "test" => { // used for testing only + print_input(); exit(0); }, _ => {