Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
44 changes: 44 additions & 0 deletions dsc/tests/dsc_extension_manifest.tests.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

Describe 'Extension Manifests' {
It 'Extension manifests with condition: <condition>' -TestCases @(
@{ condition = "[equals(1, 1)]"; shouldBeFound = $true }
@{ condition = "[equals(1, 0)]"; shouldBeFound = $false }
@{ condition = "[equals(context().os.family,'macOS')]"; shouldBeFound = $IsMacOS }
@{ condition = "[equals(context().os.family,'Linux')]"; shouldBeFound = $IsLinux }
@{ condition = "[equals(context().os.family,'Windows')]"; shouldBeFound = $IsWindows }
) {
param($condition, $shouldBeFound)

$extension_manifest = @"
{
"`$schema": "https://aka.ms/dsc/schemas/v3/bundled/extension/manifest.json",
"type": "Test/Extension",
"condition": "$condition",
"version": "0.1.0",
"import": {
"fileExtensions": ["foo"],
"executable": "dsc"
}
}
"@

try {
$env:DSC_RESOURCE_PATH = $TestDrive
$manifestPath = Join-Path -Path $TestDrive -ChildPath 'Extension.dsc.extension.json'
$extension_manifest | Out-File -FilePath $manifestPath -Encoding utf8
$extensions = dsc extension list | ConvertFrom-Json -Depth 10
$LASTEXITCODE | Should -Be 0
if ($shouldBeFound) {
$extensions.count | Should -Be 1
$extensions.type | Should -BeExactly 'Test/Extension'
}
else {
$extensions.count | Should -Be 0
}
} finally {
$env:DSC_RESOURCE_PATH = $null
}
}
}
23 changes: 23 additions & 0 deletions dsc/tests/dsc_functions.tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -1204,4 +1204,27 @@ Describe 'tests for function expressions' {
$expected = "Microsoft.DSC.Debug/Echo:$([Uri]::EscapeDataString($name))"
$out.results[0].result.actualState.output | Should -BeExactly $expected
}

It 'tryWhich() works for: <expression>' -TestCases @(
@{ expression = "[tryWhich('pwsh')]"; found = $true }
@{ expression = "[tryWhich('nonexistentcommand12345')]"; found = $false }
) {
param($expression, $found)

$config_yaml = @"
`$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json
resources:
- name: Echo
type: Microsoft.DSC.Debug/Echo
properties:
output: "$expression"
"@
$out = dsc -l trace config get -i $config_yaml 2>$TestDrive/error.log | ConvertFrom-Json
$LASTEXITCODE | Should -Be 0 -Because (Get-Content $TestDrive/error.log -Raw)
if ($found) {
$out.results[0].result.actualState.output | Should -Not -BeNullOrEmpty
} else {
$out.results[0].result.actualState.output | Should -BeNullOrEmpty
}
}
}
54 changes: 54 additions & 0 deletions dsc/tests/dsc_resource_manifest.tests.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

Describe 'Resource Manifests' {
It 'Resource manifests with condition: <condition>' -TestCases @(
@{ condition = "[equals(1, 1)]"; shouldBeFound = $true }
@{ condition = "[equals(1, 0)]"; shouldBeFound = $false }
@{ condition = "[equals(context().os.family,'macOS')]"; shouldBeFound = $IsMacOS }
@{ condition = "[equals(context().os.family,'Linux')]"; shouldBeFound = $IsLinux }
@{ condition = "[equals(context().os.family,'Windows')]"; shouldBeFound = $IsWindows }
) {
param($condition, $shouldBeFound)

$resource_manifest = @"
{
"`$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json",
"type": "Test/MyEcho",
"version": "1.0.0",
"condition": "$condition",
"get": {
"executable": "dscecho",
"args": [
{
"jsonInputArg": "--input",
"mandatory": true
}
]
},
"schema": {
"command": {
"executable": "dscecho"
}
}
}
"@

try {
$env:DSC_RESOURCE_PATH = $TestDrive
$manifestPath = Join-Path -Path $TestDrive -ChildPath 'MyEcho.dsc.resource.json'
$resource_manifest | Out-File -FilePath $manifestPath -Encoding utf8
$resources = dsc resource list | ConvertFrom-Json -Depth 10
$LASTEXITCODE | Should -Be 0
if ($shouldBeFound) {
$resources.count | Should -Be 1
$resources.type | Should -BeExactly 'Test/MyEcho'
}
else {
$resources.count | Should -Be 0
}
} finally {
$env:DSC_RESOURCE_PATH = $null
}
}
}
1 change: 1 addition & 0 deletions extensions/bicep/bicep.dsc.extension.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"type": "Microsoft.DSC.Extension/Bicep",
"version": "0.1.0",
"description": "Enable passing Bicep file directly to DSC, but requires bicep executable to be available.",
"condition": "[not(equals(tryWhich('bicep'), null()))]",
"import": {
"fileExtensions": ["bicep"],
"executable": "bicep",
Expand Down
6 changes: 6 additions & 0 deletions lib/dsc-lib/locales/en-us.toml
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ foundResourceWithVersion = "Found matching resource '%{resource}' version %{vers
foundNonAdapterResources = "Found %{count} non-adapter resources"
resourceMissingRequireAdapter = "Resource '%{resource}' is missing 'require_adapter' field."
extensionDiscoverFailed = "Extension '%{extension}' failed to discover resources: %{error}"
conditionNotBoolean = "Condition '%{condition}' did not evaluate to a boolean"
conditionNotMet = "Condition '%{condition}' not met, skipping manifest at '%{path}'"

[dscresources.commandResource]
invokeGet = "Invoking get for '%{resource}'"
Expand Down Expand Up @@ -542,6 +544,10 @@ invoked = "tryGet function"
invalidKeyType = "Invalid key type, must be a string"
invalidIndexType = "Invalid index type, must be an integer"

[functions.tryWhich]
description = "Attempts to locate an executable in the system PATH. Null is returned if the executable is not found otherwise the full path to the executable is returned."
invoked = "tryWhich function"

[functions.union]
description = "Returns a single array or object with all elements from the parameters"
invoked = "union function"
Expand Down
33 changes: 31 additions & 2 deletions lib/dsc-lib/src/discovery/command_discovery.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

use crate::{discovery::discovery_trait::{DiscoveryFilter, DiscoveryKind, ResourceDiscovery}};
use crate::{discovery::discovery_trait::{DiscoveryFilter, DiscoveryKind, ResourceDiscovery}, parser::Statement};
use crate::{locked_is_empty, locked_extend, locked_clone, locked_get};
use crate::configure::context::Context;
use crate::dscresources::dscresource::{Capability, DscResource, ImplementedAs};
use crate::dscresources::resource_manifest::{import_manifest, validate_semver, Kind, ResourceManifest, SchemaKind};
use crate::dscresources::command_resource::invoke_command;
Expand Down Expand Up @@ -607,6 +608,18 @@ fn insert_resource(resources: &mut BTreeMap<String, Vec<DscResource>>, resource:
}
}

fn evaluate_condition(condition: Option<&str>) -> Result<bool, DscError> {
if let Some(cond) = condition {
let mut statement = Statement::new()?;
let result = statement.parse_and_execute(cond, &Context::new())?;
if let Some(bool_result) = result.as_bool() {
return Ok(bool_result);
}
return Err(DscError::Validation(t!("discovery.commandDiscovery.conditionNotBoolean", condition = cond).to_string()));
}
Ok(true)
}

/// Loads a manifest from the given path and returns a vector of `ImportedManifest`.
///
/// # Arguments
Expand Down Expand Up @@ -640,6 +653,10 @@ pub fn load_manifest(path: &Path) -> Result<Vec<ImportedManifest>, DscError> {
}
}
};
if !evaluate_condition(manifest.condition.as_deref())? {
debug!("{}", t!("discovery.commandDiscovery.conditionNotMet", path = path.to_string_lossy(), condition = manifest.condition.unwrap_or_default()));
return Ok(vec![]);
}
let resource = load_resource_manifest(path, &manifest)?;
return Ok(vec![ImportedManifest::Resource(resource)]);
}
Expand All @@ -659,10 +676,15 @@ pub fn load_manifest(path: &Path) -> Result<Vec<ImportedManifest>, DscError> {
}
}
};
if !evaluate_condition(manifest.condition.as_deref())? {
debug!("{}", t!("discovery.commandDiscovery.conditionNotMet", path = path.to_string_lossy(), condition = manifest.condition.unwrap_or_default()));
return Ok(vec![]);
}
let extension = load_extension_manifest(path, &manifest)?;
return Ok(vec![ImportedManifest::Extension(extension)]);
}
if DSC_MANIFEST_LIST_EXTENSIONS.iter().any(|ext| file_name_lowercase.ends_with(ext)) {
let mut resources: Vec<ImportedManifest> = vec![];
let manifest_list = if extension_is_json {
match serde_json::from_str::<ManifestList>(&contents) {
Ok(manifest) => manifest,
Expand All @@ -678,15 +700,22 @@ pub fn load_manifest(path: &Path) -> Result<Vec<ImportedManifest>, DscError> {
}
}
};
let mut resources = vec![];
if let Some(resource_manifests) = &manifest_list.resources {
for res_manifest in resource_manifests {
if !evaluate_condition(res_manifest.condition.as_deref())? {
debug!("{}", t!("discovery.commandDiscovery.conditionNotMet", path = path.to_string_lossy(), condition = res_manifest.condition.as_ref() : {:?}));
continue;
}
let resource = load_resource_manifest(path, res_manifest)?;
resources.push(ImportedManifest::Resource(resource));
}
}
if let Some(extension_manifests) = &manifest_list.extensions {
for ext_manifest in extension_manifests {
if !evaluate_condition(ext_manifest.condition.as_deref())? {
debug!("{}", t!("discovery.commandDiscovery.conditionNotMet", path = path.to_string_lossy(), condition = ext_manifest.condition.as_ref() : {:?}));
continue;
}
let extension = load_extension_manifest(path, ext_manifest)?;
resources.push(ImportedManifest::Extension(extension));
}
Expand Down
3 changes: 3 additions & 0 deletions lib/dsc-lib/src/dscresources/resource_manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ pub struct ResourceManifest {
/// The namespaced name of the resource.
#[serde(rename = "type")]
pub resource_type: String,
/// An optional condition for the resource to be active. If the condition evaluates to false, the resource is skipped.
#[serde(skip_serializing_if = "Option::is_none")]
pub condition: Option<String>,
/// The kind of resource.
#[serde(skip_serializing_if = "Option::is_none")]
pub kind: Option<Kind>,
Expand Down
3 changes: 3 additions & 0 deletions lib/dsc-lib/src/extensions/extension_manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ pub struct ExtensionManifest {
pub r#type: String,
/// The version of the extension using semantic versioning.
pub version: String,
/// An optional condition for the extension to be active. If the condition evaluates to false, the extension is skipped.
#[serde(skip_serializing_if = "Option::is_none")]
pub condition: Option<String>,
/// The description of the extension.
pub description: Option<String>,
/// Tags for the extension.
Expand Down
11 changes: 9 additions & 2 deletions lib/dsc-lib/src/functions/equals.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ impl Function for Equals {
min_args: 2,
max_args: 2,
accepted_arg_ordered_types: vec![
vec![FunctionArgKind::Number, FunctionArgKind::String, FunctionArgKind::Array, FunctionArgKind::Object],
vec![FunctionArgKind::Number, FunctionArgKind::String, FunctionArgKind::Array, FunctionArgKind::Object],
vec![FunctionArgKind::Null, FunctionArgKind::Number, FunctionArgKind::String, FunctionArgKind::Array, FunctionArgKind::Object],
vec![FunctionArgKind::Null, FunctionArgKind::Number, FunctionArgKind::String, FunctionArgKind::Array, FunctionArgKind::Object],
],
remaining_arg_accepted_types: None,
return_types: vec![FunctionArgKind::Boolean],
Expand Down Expand Up @@ -74,6 +74,13 @@ mod tests {
assert_eq!(result, Value::Bool(false));
}

#[test]
fn null_equal() {
let mut parser = Statement::new().unwrap();
let result = parser.parse_and_execute("[equals(null(),null())]", &Context::new()).unwrap();
assert_eq!(result, Value::Bool(true));
}

// TODO: Add tests for arrays once `createArray()` is implemented
// TODO: Add tests for objects once `createObject()` is implemented
}
2 changes: 2 additions & 0 deletions lib/dsc-lib/src/functions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ pub mod uri_component_to_string;
pub mod user_function;
pub mod utc_now;
pub mod variables;
pub mod try_which;

/// The kind of argument that a function accepts.
#[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq, Serialize, JsonSchema)]
Expand Down Expand Up @@ -205,6 +206,7 @@ impl FunctionDispatcher {
Box::new(uri_component_to_string::UriComponentToString{}),
Box::new(utc_now::UtcNow{}),
Box::new(variables::Variables{}),
Box::new(try_which::TryWhich{}),
];
for function in function_list {
functions.insert(function.get_metadata().name.clone(), function);
Expand Down
71 changes: 71 additions & 0 deletions lib/dsc-lib/src/functions/try_which.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

use crate::DscError;
use crate::configure::context::Context;
use crate::functions::{FunctionArgKind, Function, FunctionCategory, FunctionMetadata};
use rust_i18n::t;
use serde_json::Value;
use tracing::debug;
use which::which;

#[derive(Debug, Default)]
pub struct TryWhich {}

impl Function for TryWhich {
fn get_metadata(&self) -> FunctionMetadata {
FunctionMetadata {
name: "tryWhich".to_string(),
description: t!("functions.tryWhich.description").to_string(),
category: vec![FunctionCategory::System],
min_args: 1,
max_args: 1,
accepted_arg_ordered_types: vec![vec![FunctionArgKind::String]],
remaining_arg_accepted_types: None,
return_types: vec![
FunctionArgKind::String,
FunctionArgKind::Null,
],
}
}

fn invoke(&self, args: &[Value], _context: &Context) -> Result<Value, DscError> {
debug!("{}", t!("functions.tryWhich.invoked"));

let exe = args[0].as_str().unwrap();
match which(exe) {
Ok(found_path) => {
let path_str = found_path.to_string_lossy().to_string();
Ok(Value::String(path_str))
},
Err(_) => {
// In tryWhich, we return null if not found
Ok(Value::Null)
}
}
}
}

#[cfg(test)]
mod tests {
use crate::configure::context::Context;
use crate::parser::Statement;
use serde_json::Value;

#[test]
fn exe_exists() {
let mut parser = Statement::new().unwrap();
let result = parser.parse_and_execute("[tryWhich('dsc')]", &Context::new()).unwrap();
#[cfg(windows)]
assert!(result.as_str().unwrap().to_lowercase().ends_with("\\dsc.exe"));
#[cfg(not(windows))]
assert!(result.as_str().unwrap().ends_with("/dsc"));
}

#[test]
fn invalid_exe() {
let mut parser = Statement::new().unwrap();
let result = parser.parse_and_execute("[tryWhich('does_not_exist')]", &Context::new()).unwrap();
assert_eq!(result, Value::Null);
}
}
6 changes: 2 additions & 4 deletions resources/dscecho/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,9 @@ fn main() {
}
},
Output::Object(ref mut obj) => {
*obj = redact(&Value::Object(obj.clone()))
obj.clone_from(redact(&Value::Object(obj.clone()))
.as_object()
.expect("Expected redact() to return a Value::Object")
.clone();
},
.expect("Expected redact() to return a Value::Object")); },
_ => {}
}
}
Expand Down