diff --git a/dsc/tests/dsc.exit_code.tests.ps1 b/dsc/tests/dsc.exit_code.tests.ps1 new file mode 100644 index 00000000..05bec814 --- /dev/null +++ b/dsc/tests/dsc.exit_code.tests.ps1 @@ -0,0 +1,18 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'exit code tests' { + It 'non-zero exit code in manifest has corresponding message' { + $result = dsc resource get -r Test/ExitCode --input "{ exitCode: 8 }" 2>&1 + $result | Should -Match 'ERROR.*?[Exit code 8].*?manifest description: Placeholder from manifest for exit code 8' + } + It 'non-zero exit code not in manifest has generic message' { + $result = dsc resource get -r Test/ExitCode --input "{ exitCode: 1 }" 2>&1 + $result | Should -Match 'ERROR.*?Error.*?[Exit code 1]' + } + It 'success exit code executes without error' { + $result = dsc resource get -r Test/ExitCode --input "{ exitCode: 0 }" | ConvertFrom-Json + $result.actualState.exitCode | Should -Be 0 + $LASTEXITCODE | Should -Be 0 + } +} diff --git a/dsc_lib/src/discovery/command_discovery.rs b/dsc_lib/src/discovery/command_discovery.rs index 8c431bec..c507f4f3 100644 --- a/dsc_lib/src/discovery/command_discovery.rs +++ b/dsc_lib/src/discovery/command_discovery.rs @@ -221,7 +221,7 @@ impl ResourceDiscovery for CommandDiscovery { let mut adapter_resources_count = 0; // invoke the list command let list_command = manifest.adapter.unwrap().list; - let (exit_code, stdout, stderr) = match invoke_command(&list_command.executable, list_command.args, None, Some(&adapter.directory), None) + let (exit_code, stdout, stderr) = match invoke_command(&list_command.executable, list_command.args, None, Some(&adapter.directory), None, &manifest.exit_codes) { Ok((exit_code, stdout, stderr)) => (exit_code, stdout, stderr), Err(e) => { diff --git a/dsc_lib/src/dscerror.rs b/dsc_lib/src/dscerror.rs index 12ab5c1f..e0dfca4f 100644 --- a/dsc_lib/src/dscerror.rs +++ b/dsc_lib/src/dscerror.rs @@ -20,6 +20,9 @@ pub enum DscError { #[error("Command: Executable '{0}' [Exit code {1}] {2}")] CommandExit(String, i32, String), + #[error("Command: Resource '{0}' [Exit code {1}] manifest description: {2}")] + CommandExitFromManifest(String, i32, String), + #[error("CommandOperation: {0} for executable '{1}'")] CommandOperation(String, String), diff --git a/dsc_lib/src/dscresources/command_resource.rs b/dsc_lib/src/dscresources/command_resource.rs index fd2d997a..28472599 100644 --- a/dsc_lib/src/dscresources/command_resource.rs +++ b/dsc_lib/src/dscresources/command_resource.rs @@ -57,7 +57,7 @@ pub fn invoke_get(resource: &ResourceManifest, cwd: &str, filter: &str) -> Resul } 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)?; + let (_exit_code, stdout, stderr) = invoke_command(&get.executable, args, command_input.stdin.as_deref(), Some(cwd), command_input.env, &resource.exit_codes)?; if resource.kind == Some(Kind::Resource) { debug!("Verifying output of get '{}' using '{}'", &resource.resource_type, &get.executable); verify_json(resource, cwd, &stdout)?; @@ -156,8 +156,8 @@ pub fn invoke_set(resource: &ResourceManifest, cwd: &str, desired: &str, skip_te let args = process_args(&get.args, desired); let command_input = get_command_input(&get.input, desired)?; - info!("Getting current state for {} by invoking get '{}' using '{}'", operation_type, &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)?; + 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, &resource.exit_codes)?; if resource.kind == Some(Kind::Resource) { debug!("Verifying output of get '{}' using '{}'", &resource.resource_type, &get.executable); @@ -187,7 +187,7 @@ pub fn invoke_set(resource: &ResourceManifest, cwd: &str, desired: &str, skip_te } info!("Invoking {} '{}' using '{}'", operation_type, &resource.resource_type, &set.executable); - let (exit_code, stdout, stderr) = invoke_command(&set.executable, args, input_desired, Some(cwd), env)?; + let (exit_code, stdout, stderr) = invoke_command(&set.executable, args, input_desired, Some(cwd), env, &resource.exit_codes)?; match set.returns { Some(ReturnKind::State) => { @@ -280,7 +280,7 @@ pub fn invoke_test(resource: &ResourceManifest, cwd: &str, expected: &str) -> Re let command_input = get_command_input(&test.input, expected)?; info!("Invoking test '{}' using '{}'", &resource.resource_type, &test.executable); - let (exit_code, stdout, stderr) = invoke_command(&test.executable, args, command_input.stdin.as_deref(), Some(cwd), command_input.env)?; + let (exit_code, stdout, stderr) = invoke_command(&test.executable, args, command_input.stdin.as_deref(), Some(cwd), command_input.env, &resource.exit_codes)?; if resource.kind == Some(Kind::Resource) { debug!("Verifying output of test '{}' using '{}'", &resource.resource_type, &test.executable); @@ -394,7 +394,7 @@ pub fn invoke_delete(resource: &ResourceManifest, cwd: &str, filter: &str) -> Re let command_input = get_command_input(&delete.input, filter)?; info!("Invoking delete '{}' using '{}'", &resource.resource_type, &delete.executable); - let (_exit_code, _stdout, _stderr) = invoke_command(&delete.executable, args, command_input.stdin.as_deref(), Some(cwd), command_input.env)?; + let (_exit_code, _stdout, _stderr) = invoke_command(&delete.executable, args, command_input.stdin.as_deref(), Some(cwd), command_input.env, &resource.exit_codes)?; Ok(()) } @@ -425,7 +425,7 @@ pub fn invoke_validate(resource: &ResourceManifest, cwd: &str, config: &str) -> let command_input = get_command_input(&validate.input, config)?; info!("Invoking validate '{}' using '{}'", &resource.resource_type, &validate.executable); - let (_exit_code, stdout, _stderr) = invoke_command(&validate.executable, args, command_input.stdin.as_deref(), Some(cwd), command_input.env)?; + let (_exit_code, stdout, _stderr) = invoke_command(&validate.executable, args, command_input.stdin.as_deref(), Some(cwd), command_input.env, &resource.exit_codes)?; let result: ValidateResult = serde_json::from_str(&stdout)?; Ok(result) } @@ -446,7 +446,7 @@ pub fn get_schema(resource: &ResourceManifest, cwd: &str) -> Result { - let (_exit_code, stdout, _stderr) = invoke_command(&command.executable, command.args.clone(), None, Some(cwd), None)?; + let (_exit_code, stdout, _stderr) = invoke_command(&command.executable, command.args.clone(), None, Some(cwd), None, &resource.exit_codes)?; Ok(stdout) }, SchemaKind::Embedded(ref schema) => { @@ -501,7 +501,7 @@ pub fn invoke_export(resource: &ResourceManifest, cwd: &str, input: Option<&str> args = process_args(&export.args, ""); } - let (_exit_code, stdout, stderr) = invoke_command(&export.executable, args, command_input.stdin.as_deref(), Some(cwd), command_input.env)?; + let (_exit_code, stdout, stderr) = invoke_command(&export.executable, args, command_input.stdin.as_deref(), Some(cwd), command_input.env, &resource.exit_codes)?; let mut instances: Vec = Vec::new(); for line in stdout.lines() { @@ -547,7 +547,7 @@ pub fn invoke_resolve(resource: &ResourceManifest, cwd: &str, input: &str) -> Re 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 (_exit_code, stdout, _stderr) = invoke_command(&resolve.executable, args, command_input.stdin.as_deref(), Some(cwd), command_input.env, &resource.exit_codes)?; let result: ResolveResult = serde_json::from_str(&stdout)?; Ok(result) } @@ -565,7 +565,7 @@ pub fn invoke_resolve(resource: &ResourceManifest, cwd: &str, input: &str) -> Re /// /// Error is returned if the command fails to execute or stdin/stdout/stderr cannot be opened. #[allow(clippy::implicit_hasher)] -pub fn invoke_command(executable: &str, args: Option>, input: Option<&str>, cwd: Option<&str>, env: Option>) -> Result<(i32, String, String), DscError> { +pub fn invoke_command(executable: &str, args: Option>, input: Option<&str>, cwd: Option<&str>, env: Option>, exit_codes: &Option>) -> Result<(i32, String, String), DscError> { debug!("Invoking command '{}' with args {:?}", executable, args); let mut command = Command::new(executable); if input.is_some() { @@ -629,6 +629,11 @@ pub fn invoke_command(executable: &str, args: Option>, input: Option }; if exit_code != 0 { + if let Some(exit_codes) = exit_codes { + if let Some(error_message) = exit_codes.get(&exit_code) { + return Err(DscError::CommandExitFromManifest(executable.to_string(), exit_code, error_message.to_string())); + } + } return Err(DscError::Command(executable.to_string(), exit_code, cleaned_stderr)); } diff --git a/tools/dsctest/dscexitcode.dsc.resource.json b/tools/dsctest/dscexitcode.dsc.resource.json new file mode 100644 index 00000000..554dc494 --- /dev/null +++ b/tools/dsctest/dscexitcode.dsc.resource.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2024/04/bundled/resource/manifest.json", + "type": "Test/ExitCode", + "version": "0.1.0", + "get": { + "executable": "dsctest", + "args": [ + "exit-code", + { + "jsonInputArg": "--input" + } + ] + }, + "exitCodes": { + "0": "Success", + "8": "Placeholder from manifest for exit code 8" + }, + "schema": { + "command": { + "executable": "dsctest", + "args": [ + "schema", + "-s", + "exit-code" + ] + } + } +} diff --git a/tools/dsctest/src/args.rs b/tools/dsctest/src/args.rs index 51b69e83..e394a8a8 100644 --- a/tools/dsctest/src/args.rs +++ b/tools/dsctest/src/args.rs @@ -8,6 +8,7 @@ pub enum Schemas { Delete, Echo, Exist, + ExitCode, Sleep, Trace, WhatIf, @@ -41,6 +42,12 @@ pub enum SubCommand { input: String, }, + #[clap(name = "exit-code", about = "Return the exit code specified in the input")] + ExitCode { + #[clap(name = "input", short, long, help = "The input to the exit code command as JSON")] + input: String, + }, + #[clap(name = "schema", about = "Get the JSON schema for a subcommand")] Schema { #[clap(name = "subcommand", short, long, help = "The subcommand to get the schema for")] diff --git a/tools/dsctest/src/exit_code.rs b/tools/dsctest/src/exit_code.rs new file mode 100644 index 00000000..478f903c --- /dev/null +++ b/tools/dsctest/src/exit_code.rs @@ -0,0 +1,12 @@ +// 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 ExitCode { + #[serde(rename = "exitCode")] + pub exit_code: i32, +} diff --git a/tools/dsctest/src/main.rs b/tools/dsctest/src/main.rs index cd3790d2..91760736 100644 --- a/tools/dsctest/src/main.rs +++ b/tools/dsctest/src/main.rs @@ -5,6 +5,7 @@ mod args; mod delete; mod echo; mod exist; +mod exit_code; mod sleep; mod trace; mod whatif; @@ -15,11 +16,13 @@ use schemars::schema_for; use crate::delete::Delete; use crate::echo::Echo; use crate::exist::{Exist, State}; +use crate::exit_code::ExitCode; use crate::sleep::Sleep; use crate::trace::Trace; use crate::whatif::WhatIf; use std::{thread, time::Duration}; +#[allow(clippy::too_many_lines)] fn main() { let args = Args::parse(); let json = match args.subcommand { @@ -60,6 +63,20 @@ fn main() { serde_json::to_string(&exist).unwrap() }, + SubCommand::ExitCode { input } => { + let exit_code = match serde_json::from_str::(&input) { + Ok(exit_code) => exit_code, + Err(err) => { + eprintln!("Error JSON does not match schema: {err}"); + std::process::exit(1); + } + }; + if exit_code.exit_code != 0 { + eprintln!("Exiting with code: {}", exit_code.exit_code); + std::process::exit(exit_code.exit_code); + } + input + }, SubCommand::Schema { subcommand } => { let schema = match subcommand { Schemas::Delete => { @@ -71,6 +88,9 @@ fn main() { Schemas::Exist => { schema_for!(Exist) }, + Schemas::ExitCode => { + schema_for!(ExitCode) + }, Schemas::Sleep => { schema_for!(Sleep) },