Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add run command resource #321

Merged
merged 27 commits into from
Mar 5, 2024
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
b3b298e
start RunCommandOnSet resource
tgauth Feb 15, 2024
026bc72
implement get and set
tgauth Feb 16, 2024
7006d64
add resource manifest
tgauth Feb 16, 2024
c6ad454
add tests and update return format for get and set
tgauth Feb 20, 2024
4d4a340
add tests and add proj to build list
tgauth Feb 21, 2024
04d6893
add tests for missing required input
tgauth Feb 21, 2024
b0967b6
Merge branch 'main' into add-run-command-resource
tgauth Feb 21, 2024
fd9611b
fix clippy
tgauth Feb 21, 2024
a86655b
Merge branch 'add-run-command-resource' of https://github.com/tgauth/…
tgauth Feb 21, 2024
0d43c39
fix tests on linux/macos
tgauth Feb 21, 2024
25b6e1b
fix spacing
tgauth Feb 21, 2024
b417409
fix tests on linux/macos part 2
tgauth Feb 21, 2024
5d62a3b
Merge branch 'main' into add-run-command-resource
tgauth Feb 22, 2024
849b1d0
rename resource and remove yaml processing from utils
tgauth Feb 22, 2024
de6f29d
Merge branch 'main' into add-run-command-resource
tgauth Mar 1, 2024
3f9ccc8
address review feedback
tgauth Mar 1, 2024
ce48d66
fix test
tgauth Mar 1, 2024
2449eef
Merge branch 'main' into add-run-command-resource
tgauth Mar 1, 2024
046001b
print debug message from other os
tgauth Mar 1, 2024
f5c9d39
Merge branch 'add-run-command-resource' of https://github.com/tgauth/…
tgauth Mar 1, 2024
7ac34ca
fix test
tgauth Mar 1, 2024
6d5c64b
remove debug from tests
tgauth Mar 1, 2024
aef1b19
fix merge conflict
tgauth Mar 4, 2024
8401c66
add return type as state for set
tgauth Mar 4, 2024
97a58f6
check changed properties for exit code in tests
tgauth Mar 4, 2024
62284cf
Merge branch 'main' into add-run-command-resource
tgauth Mar 5, 2024
32effa7
update exit_code to exitCode for serialization
tgauth Mar 5, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion build.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,8 @@ $projects = @(
"y2j",
"powershellgroup",
"wmigroup",
"resources/brew"
"resources/brew",
"runcommandonset"
)
$pedantic_unclean_projects = @("ntreg")
$clippy_unclean_projects = @("tree-sitter-dscexpression")
Expand Down
14 changes: 14 additions & 0 deletions runcommandonset/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"] }
45 changes: 45 additions & 0 deletions runcommandonset/RunCommandOnSet.dsc.resource.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
{
"$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"
},
"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"
},
"exit_code": {
"title": "The expected exit code to indicate success, if non-zero. Default is zero for success.",
"type": "integer"
}
},
"additionalProperties": false
}
}
}
54 changes: 54 additions & 0 deletions runcommandonset/src/args.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<String>>,
#[clap(short = 'e', long, help = "The executable to run.")]
executable: Option<String>,
#[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<Vec<String>>,
#[clap(short = 'e', long, help = "The executable to run.")]
executable: Option<String>,
#[clap(short = 'c', long, help = "The expected exit code to indicate success, if non-zero.", default_value = "0")]
exit_code: i32,
}
}
62 changes: 62 additions & 0 deletions runcommandonset/src/main.rs
Original file line number Diff line number Diff line change
@@ -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<u8> = 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());
}
31 changes: 31 additions & 0 deletions runcommandonset/src/runcommand.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<String>>,
// default value for exit code is 0
#[serde(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: Default + PartialEq>(t: &T) -> bool {
t == &T::default()
}
178 changes: 178 additions & 0 deletions runcommandonset/src/utils.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<String>>, executable: Option<String>, exit_code: i32, stdin: Option<String>) -> 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<Vec<String>>) -> (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)
}
Loading
Loading