diff --git a/build.ps1 b/build.ps1 index b25db9b2..67ebc646 100644 --- a/build.ps1 +++ b/build.ps1 @@ -121,7 +121,8 @@ $projects = @( "tools/test_group_resource", "y2j", "wmi-adapter", - "resources/brew" + "resources/brew", + "runcommandonset" ) $pedantic_unclean_projects = @("ntreg") $clippy_unclean_projects = @("tree-sitter-dscexpression") diff --git a/runcommandonset/Cargo.toml b/runcommandonset/Cargo.toml new file mode 100644 index 00000000..63e40d58 --- /dev/null +++ b/runcommandonset/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "runcommandonset" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +atty = { version = "0.2" } +clap = { version = "4.4", features = ["derive"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0", features = ["preserve_order"] } +tracing = { version = "0.1.37" } +tracing-subscriber = { version = "0.3.17", features = ["ansi", "env-filter", "json"] } diff --git a/runcommandonset/RunCommandOnSet.dsc.resource.json b/runcommandonset/RunCommandOnSet.dsc.resource.json new file mode 100644 index 00000000..17d2e9e1 --- /dev/null +++ b/runcommandonset/RunCommandOnSet.dsc.resource.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2023/08/bundled/resource/manifest.json", + "description": "Takes a single-command line to execute on DSC set operation", + "type": "Microsoft.DSC.Transitional/RunCommandOnSet", + "version": "0.1.0", + "get": { + "executable": "runcommandonset", + "args": [ + "get" + ], + "input": "stdin" + }, + "set": { + "executable": "runcommandonset", + "args": [ + "set" + ], + "input": "stdin", + "return": "state" + }, + "schema": { + "embedded": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "RunCommandOnSet", + "type": "object", + "required": [ + "executable" + ], + "properties": { + "arguments": { + "title": "The argument(s), if any, to pass to the executable that runs on set", + "type": "array" + }, + "executable": { + "title": "The executable to run on set", + "type": "string" + }, + "exitCode": { + "title": "The expected exit code to indicate success, if non-zero. Default is zero for success.", + "type": "integer" + } + }, + "additionalProperties": false + } + } +} diff --git a/runcommandonset/src/args.rs b/runcommandonset/src/args.rs new file mode 100644 index 00000000..e4816a55 --- /dev/null +++ b/runcommandonset/src/args.rs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use clap::{Parser, Subcommand, ValueEnum}; + +#[derive(Debug, Clone, PartialEq, Eq, ValueEnum)] +pub enum TraceFormat { + Default, + Plaintext, + Json, +} + +#[derive(Debug, Clone, PartialEq, Eq, ValueEnum)] +pub enum TraceLevel { + Error, + Warning, + Info, + Debug, + Trace +} + +#[derive(Parser)] +#[clap(name = "runcommandonset", version = "0.0.1", about = "Run a command on set", long_about = None)] +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, + #[clap(short = 'f', long, help = "Trace format to use", value_enum, default_value = "json")] + pub trace_format: TraceFormat, +} + +#[derive(Debug, PartialEq, Eq, Subcommand)] +pub enum SubCommand { + #[clap(name = "get", about = "Get formatted command to run on set.")] + Get { + #[clap(short = 'a', long, help = "The arguments to pass to the executable.")] + arguments: Option>, + #[clap(short = 'e', long, help = "The executable to run.")] + executable: Option, + #[clap(short = 'c', long, help = "The expected exit code to indicate success, if non-zero.", default_value = "0")] + exit_code: i32, + }, + #[clap(name = "set", about = "Run formatted command.")] + Set { + #[clap(short = 'a', long, help = "The arguments to pass to the executable.")] + arguments: Option>, + #[clap(short = 'e', long, help = "The executable to run.")] + executable: Option, + #[clap(short = 'c', long, help = "The expected exit code to indicate success, if non-zero.", default_value = "0")] + exit_code: i32, + } +} diff --git a/runcommandonset/src/main.rs b/runcommandonset/src/main.rs new file mode 100644 index 00000000..16cd3543 --- /dev/null +++ b/runcommandonset/src/main.rs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use atty::Stream; +use clap::{Parser}; +use std::{io::{self, Read}, process::exit}; +use tracing::{error, warn, debug}; + +use args::{Arguments, SubCommand}; +use runcommand::{RunCommand}; +use utils::{enable_tracing, invoke_command, parse_input, EXIT_INVALID_ARGS}; + +pub mod args; +pub mod runcommand; +pub mod utils; + +fn main() { + let args = Arguments::parse(); + enable_tracing(&args.trace_level, &args.trace_format); + warn!("This resource is not idempotent"); + + let stdin = if atty::is(Stream::Stdin) { + None + } else { + debug!("Reading input from STDIN"); + let mut buffer: Vec = Vec::new(); + io::stdin().read_to_end(&mut buffer).unwrap(); + let stdin = match String::from_utf8(buffer) { + Ok(stdin) => stdin, + Err(e) => { + error!("Invalid UTF-8 sequence: {e}"); + exit(EXIT_INVALID_ARGS); + }, + }; + // parse_input expects at most 1 input, so wrapping Some(empty input) would throw it off + if stdin.is_empty() { + debug!("Input from STDIN is empty"); + None + } + else { + Some(stdin) + } + }; + + let mut command: RunCommand; + + match args.subcommand { + SubCommand::Get { arguments, executable, exit_code } => { + command = parse_input(arguments, executable, exit_code, stdin); + } + SubCommand::Set { arguments, executable, exit_code } => { + command = parse_input(arguments, executable, exit_code, stdin); + let (exit_code, stdout, stderr) = invoke_command(command.executable.as_ref(), command.arguments.clone()); + // TODO: convert this to tracing json once other PR is merged to handle tracing from resources + eprintln!("Stdout: {stdout}"); + eprintln!("Stderr: {stderr}"); + command.exit_code = exit_code; + } + } + + println!("{}", command.to_json()); +} diff --git a/runcommandonset/src/runcommand.rs b/runcommandonset/src/runcommand.rs new file mode 100644 index 00000000..ae5ce81c --- /dev/null +++ b/runcommandonset/src/runcommand.rs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize, Clone, PartialEq, Serialize)] +pub struct RunCommand { + pub executable: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub arguments: Option>, + // default value for exit code is 0 + #[serde(rename = "exitCode", default, skip_serializing_if = "is_default")] + pub exit_code: i32, +} + +impl RunCommand { + #[must_use] + pub fn to_json(&self) -> String { + match serde_json::to_string(self) { + Ok(json) => json, + Err(e) => { + eprintln!("Failed to serialize to JSON: {e}"); + String::new() + } + } + } +} + +fn is_default(t: &T) -> bool { + t == &T::default() +} diff --git a/runcommandonset/src/utils.rs b/runcommandonset/src/utils.rs new file mode 100644 index 00000000..831b4fc3 --- /dev/null +++ b/runcommandonset/src/utils.rs @@ -0,0 +1,178 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::{io::Read, process::{Command, exit, Stdio}}; +use tracing::{Level, error, debug, trace}; +use tracing_subscriber::{filter::EnvFilter, layer::SubscriberExt, Layer}; + +use crate::args::{TraceFormat, TraceLevel}; +use crate::runcommand; + +pub const EXIT_INVALID_ARGS: i32 = 1; +pub const EXIT_DSC_ERROR: i32 = 2; +pub const EXIT_CODE_MISMATCH: i32 = 3; +pub const EXIT_INVALID_INPUT: i32 = 4; +pub const EXIT_PROCESS_TERMINATED: i32 = 5; + +/// Initialize `RunCommand` struct from input provided via stdin or via CLI arguments. +/// +/// # Arguments +/// +/// * `arguments` - Optional arguments to pass to the command +/// * `executable` - The command to execute +/// * `exit_code` - The expected exit code upon success, if non-zero +/// * `stdin` - Optional JSON or YAML input provided via stdin +/// +/// # Errors +/// +/// Error message then exit if the `RunCommand` struct cannot be initialized from the provided inputs. +pub fn parse_input(arguments: Option>, executable: Option, exit_code: i32, stdin: Option) -> runcommand::RunCommand { + let command: runcommand::RunCommand; + if let Some(input) = stdin { + debug!("Input: {}", input); + command = match serde_json::from_str(&input) { + Ok(json) => json, + Err(err) => { + error!("Error: Input is not valid: {err}"); + exit(EXIT_INVALID_INPUT); + } + } + } else if let Some(executable) = executable { + command = runcommand::RunCommand { + executable, + arguments, + exit_code, + }; + } + else { + error!("Error: Executable is required when input is not provided via stdin"); + exit(EXIT_INVALID_INPUT); + } + command +} + +/// Setup tracing subscriber based on the provided trace level and format. +/// +/// # Arguments +/// +/// * `trace_level` - The level of information of to output +/// * `trace_format` - The format of the output +/// +/// # Errors +/// +/// If unable to initialize the tracing subscriber, an error message is printed and tracing is disabled. +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::Info => Level::INFO, + TraceLevel::Debug => Level::DEBUG, + TraceLevel::Trace => Level::TRACE, + }; + + let filter = EnvFilter::try_from_default_env() + .or_else(|_| EnvFilter::try_new("warning")) + .unwrap_or_default() + .add_directive(tracing_level.into()); + let layer = tracing_subscriber::fmt::Layer::default().with_writer(std::io::stderr); + let fmt = match trace_format { + TraceFormat::Default => { + layer + .with_ansi(true) + .with_level(true) + .with_line_number(true) + .boxed() + }, + TraceFormat::Plaintext => { + layer + .with_ansi(false) + .with_level(true) + .with_line_number(false) + .boxed() + }, + TraceFormat::Json => { + layer + .with_ansi(false) + .with_level(true) + .with_line_number(true) + .json() + .boxed() + } + }; + + let subscriber = tracing_subscriber::Registry::default().with(fmt).with(filter); + + if tracing::subscriber::set_global_default(subscriber).is_err() { + eprintln!("Unable to set global default tracing subscriber. Tracing is diabled."); + } +} + +/// Invoke a command and return the exit code, stdout, and stderr. +/// +/// # Arguments +/// +/// * `executable` - The command to execute +/// * `args` - Optional arguments to pass to the command +/// +/// # Errors +/// +/// Error message then exit if the command fails to execute or stdin/stdout/stderr cannot be opened. +pub fn invoke_command(executable: &str, args: Option>) -> (i32, String, String) { + // originally implemented in dsc_lib/src/dscresources/command_resource.rs + trace!("Invoking command {} with args {:?}", executable, args); + let mut command = Command::new(executable); + + command.stdout(Stdio::piped()); + command.stderr(Stdio::piped()); + if let Some(args) = args { + command.args(args); + } + + let mut child = match command.spawn() { + Ok(child) => child, + Err(e) => { + error!("Failed to execute {}: {e}", executable); + exit(EXIT_DSC_ERROR); + } + }; + + let Some(mut child_stdout) = child.stdout.take() else { + error!("Failed to open stdout for {}", executable); + exit(EXIT_DSC_ERROR); + }; + let mut stdout_buf = Vec::new(); + match child_stdout.read_to_end(&mut stdout_buf) { + Ok(_) => (), + Err(e) => { + error!("Failed to read stdout for {}: {e}", executable); + exit(EXIT_DSC_ERROR); + } + } + + let Some(mut child_stderr) = child.stderr.take() else { + error!("Failed to open stderr for {}", executable); + exit(EXIT_DSC_ERROR); + }; + let mut stderr_buf = Vec::new(); + match child_stderr.read_to_end(&mut stderr_buf) { + Ok(_) => (), + Err(e) => { + error!("Failed to read stderr for {}: {e}", executable); + exit(EXIT_DSC_ERROR); + } + } + + let exit_status = match child.wait() { + Ok(exit_status) => exit_status, + Err(e) => { + error!("Failed to wait for {}: {e}", executable); + exit(EXIT_DSC_ERROR); + } + }; + + let exit_code = exit_status.code().unwrap_or(EXIT_PROCESS_TERMINATED); + let stdout = String::from_utf8_lossy(&stdout_buf).to_string(); + let stderr = String::from_utf8_lossy(&stderr_buf).to_string(); + (exit_code, stdout, stderr) +} diff --git a/runcommandonset/tests/runcommandonset.get.tests.ps1 b/runcommandonset/tests/runcommandonset.get.tests.ps1 new file mode 100644 index 00000000..3e166a7b --- /dev/null +++ b/runcommandonset/tests/runcommandonset.get.tests.ps1 @@ -0,0 +1,38 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'tests for runcommandonset get' { + BeforeAll { + $oldPath = $env:DSC_RESOURCE_PATH + $env:DSC_RESOURCE_PATH = Join-Path $PSScriptRoot ".." + } + + AfterAll { + $env:DSC_RESOURCE_PATH = $oldPath + } + + It 'Input passed for executable, arguments, and exit code' { + $json = @" + { + "executable": "foo", + "arguments": ["bar", "baz"], + "exitCode": 5, + } +"@ + + $result = $json | dsc resource get -r Microsoft.DSC.Transitional/RunCommandOnSet | ConvertFrom-Json + $result.actualState.arguments | Should -BeExactly @('bar', 'baz') + $result.actualState.executable | Should -BeExactly 'foo' + $result.actualState.exitCode | Should -BeExactly 5 + } + + It 'Executable is a required input via CLI arguments' { + $null = runcommandonset get -a foo + $LASTEXITCODE | Should -Be 4 + } + + It 'Executable is a required input via STDIN' { + '{ "arguments": "foo" }' | dsc resource get -r Microsoft.DSC.Transitional/RunCommandOnSet + $LASTEXITCODE | Should -Be 2 + } +} diff --git a/runcommandonset/tests/runcommandonset.set.tests.ps1 b/runcommandonset/tests/runcommandonset.set.tests.ps1 new file mode 100644 index 00000000..94dd30e7 --- /dev/null +++ b/runcommandonset/tests/runcommandonset.set.tests.ps1 @@ -0,0 +1,100 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'tests for runcommandonset set' { + BeforeAll { + $oldPath = $env:DSC_RESOURCE_PATH + $env:DSC_RESOURCE_PATH = Join-Path $PSScriptRoot ".." + } + + AfterEach { + if (Test-Path $TestDrive/output.txt) { + Remove-Item -Path $TestDrive/output.txt + } + } + + AfterAll { + $env:DSC_RESOURCE_PATH = $oldPath + } + + It 'Input for executable and arguments can be sent to the resource' { + $input_json = @" + { + "executable": "pwsh", + "arguments": ["-Command", "echo hello world"] + } +"@ + $input_json | dsc resource set -r Microsoft.DSC.Transitional/RunCommandOnSet + # TODO: test output once DSC PR to capture it is merged + $LASTEXITCODE | Should -Be 0 + } + + It 'STDOUT captured via STDERR when calling resource directly' { + $input_json = @" + { + "executable": "pwsh", + "arguments": ["-Command", "echo hello world"] + } +"@ + $input_json | runcommandonset set 2> $TestDrive/output.txt + $actual = Get-Content -Path $TestDrive/output.txt + $actual | Should -Contain 'Stdout: hello' + $actual | Should -Contain 'world' + $LASTEXITCODE | Should -Be 0 + } + + It 'STDERR captured when calling resource directly with invalid args' { + $json = runcommandonset set -e pwsh -a "echo hello world" 2> $TestDrive/output.txt + $stdout = $json | ConvertFrom-Json + $stdout.exitCode | Should -Be 64 + $expected = "Stderr: The argument 'echo hello world' is not recognized as the name of a script file. Check the spelling of the name, or if a path was included, verify that the path is correct and try again." + $stderr = Get-Content -Path $TestDrive/output.txt + $stderr | Should -Contain $expected + $LASTEXITCODE | Should -Be 0 + } + + It 'Executable is a required input via CLI arguments' { + $null = runcommandonset set -a foo + $LASTEXITCODE | Should -Be 4 + } + + It 'Executable is a required input via STDIN' { + $null = '{ "arguments": "foo" }' | dsc resource set -r Microsoft.DSC.Transitional/RunCommandOnSet + $LASTEXITCODE | Should -Be 2 + } + + It 'Executable can be provided without arguments' { + $result = '{ "executable": "pwsh" }' | dsc resource set -r Microsoft.DSC.Transitional/RunCommandOnSet | ConvertFrom-Json + $result.changedProperties | Should -Be @() + $LASTEXITCODE | Should -Be 0 + } + + It 'Exit code does not need to be provided to detect difference' { + $result = '{ "executable": "pwsh", "arguments": ["invalid input"] }' | dsc resource set -r Microsoft.DSC.Transitional/RunCommandOnSet | ConvertFrom-Json + $result.changedProperties | Should -Be @( 'exitCode' ) + $LASTEXITCODE | Should -Be 0 + } + + 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 + $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 + $LASTEXITCODE | Should -Be 2 + } +}