diff --git a/dsc/examples/hello_world.dsc.bicep b/dsc/examples/hello_world.dsc.bicep index 4d2504a7a..8198de447 100644 --- a/dsc/examples/hello_world.dsc.bicep +++ b/dsc/examples/hello_world.dsc.bicep @@ -9,3 +9,6 @@ resource echo 'Microsoft.DSC.Debug/Echo@2025-08-27' = { output: 'Hello, world!' } } + +// This is waiting on https://github.com/Azure/bicep/issues/17670 to be resolved +// output exampleOutput string = echo.properties.output diff --git a/dsc/tests/dsc_osinfo.tests.ps1 b/dsc/tests/dsc_osinfo.tests.ps1 index 777a8be10..f45e53e74 100644 --- a/dsc/tests/dsc_osinfo.tests.ps1 +++ b/dsc/tests/dsc_osinfo.tests.ps1 @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + Describe 'Tests for osinfo examples' { It 'Config with default parameters and get works' { $out = dsc config get -f $PSScriptRoot/../examples/osinfo_parameters.dsc.yaml | ConvertFrom-Json -Depth 10 diff --git a/dsc/tests/dsc_output.tests.ps1 b/dsc/tests/dsc_output.tests.ps1 new file mode 100644 index 000000000..0fcc9875d --- /dev/null +++ b/dsc/tests/dsc_output.tests.ps1 @@ -0,0 +1,41 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'output tests' { + It 'config with output property works' { + $configYaml = @' + $schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json + variables: + arrayVar: + - 1 + - 2 + - 3 + resources: + - name: echo + type: Microsoft.DSC.Debug/Echo + properties: + output: This is a test + outputs: + simpleText: + type: string + value: Hello World + expression: + type: string + value: "[reference(resourceId('Microsoft.DSC.Debug/Echo', 'echo')).output]" + conditionSucceed: + type: int + condition: "[equals(1, 1)]" + value: "[variables('arrayVar')[1]]" + conditionFail: + type: int + condition: "[equals(1, 2)]" + value: "[variables('arrayVar')[1]]" +'@ + $out = dsc config get -i $configYaml | ConvertFrom-Json -Depth 10 + $LASTEXITCODE | Should -Be 0 + $out.outputs.simpleText | Should -Be 'Hello World' + $out.outputs.expression | Should -Be 'This is a test' + $out.outputs.conditionSucceed | Should -Be 2 + $out.outputs.conditionFail | Should -BeNullOrEmpty + } +} diff --git a/lib/dsc-lib/locales/en-us.toml b/lib/dsc-lib/locales/en-us.toml index 2edd066ef..49a7614a3 100644 --- a/lib/dsc-lib/locales/en-us.toml +++ b/lib/dsc-lib/locales/en-us.toml @@ -80,6 +80,10 @@ circularDependency = "Circular dependency or unresolvable parameter references d userFunctionAlreadyDefined = "User function '%{name}' in namespace '%{namespace}' is already defined" addingUserFunction = "Adding user function '%{name}'" copyCountResultNotInteger = "Copy count result is not an integer: %{expression}" +skippingOutput = "Skipping output for '%{name}' due to condition evaluating to false" +secureOutputSkipped = "Secure output '%{name}' is skipped" +outputTypeNotMatch = "Output '%{name}' type does not match expected type '%{expected_type}'" +copyNotSupported = "Copy for output '%{name}' is currently not supported" [discovery.commandDiscovery] couldNotReadSetting = "Could not read 'resourcePath' setting" diff --git a/lib/dsc-lib/src/configure/config_doc.rs b/lib/dsc-lib/src/configure/config_doc.rs index 3a4b02f27..6bbf6c0b2 100644 --- a/lib/dsc-lib/src/configure/config_doc.rs +++ b/lib/dsc-lib/src/configure/config_doc.rs @@ -129,6 +129,22 @@ pub struct UserFunctionOutput { pub value: String, } +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub enum ValueOrCopy { + Value(String), + Copy(Copy), +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Output { + pub condition: Option, + pub r#type: DataType, + #[serde(flatten)] + pub value_or_copy: ValueOrCopy, +} + #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct Configuration { @@ -140,15 +156,18 @@ pub struct Configuration { #[serde(skip_serializing_if = "Option::is_none")] pub functions: Option>, #[serde(skip_serializing_if = "Option::is_none")] - pub parameters: Option>, + pub metadata: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub variables: Option>, + pub outputs: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub parameters: Option>, pub resources: Vec, #[serde(skip_serializing_if = "Option::is_none")] - pub metadata: Option, + pub variables: Option>, } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct Parameter { #[serde(rename = "type")] pub parameter_type: DataType, @@ -358,6 +377,7 @@ impl Configuration { resources: Vec::new(), functions: None, variables: None, + outputs: None, } } } diff --git a/lib/dsc-lib/src/configure/config_result.rs b/lib/dsc-lib/src/configure/config_result.rs index 6f2be74b2..b5396ab2c 100644 --- a/lib/dsc-lib/src/configure/config_result.rs +++ b/lib/dsc-lib/src/configure/config_result.rs @@ -3,6 +3,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; use crate::dscresources::invoke_result::{GetResult, SetResult, TestResult}; use crate::configure::config_doc::{Configuration, Metadata}; @@ -54,6 +55,8 @@ pub struct ConfigurationGetResult { pub messages: Vec, #[serde(rename = "hadErrors")] pub had_errors: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub outputs: Option>, } impl ConfigurationGetResult { @@ -64,6 +67,7 @@ impl ConfigurationGetResult { results: Vec::new(), messages: Vec::new(), had_errors: false, + outputs: None, } } } @@ -85,6 +89,7 @@ impl From for ConfigurationGetResult { results, messages: test_result.messages, had_errors: test_result.had_errors, + outputs: test_result.outputs, } } } @@ -140,6 +145,8 @@ pub struct ConfigurationSetResult { pub messages: Vec, #[serde(rename = "hadErrors")] pub had_errors: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub outputs: Option>, } impl ConfigurationSetResult { @@ -150,6 +157,7 @@ impl ConfigurationSetResult { results: Vec::new(), messages: Vec::new(), had_errors: false, + outputs: None, } } } @@ -200,6 +208,8 @@ pub struct ConfigurationTestResult { pub messages: Vec, #[serde(rename = "hadErrors")] pub had_errors: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub outputs: Option>, } impl ConfigurationTestResult { @@ -210,6 +220,7 @@ impl ConfigurationTestResult { results: Vec::new(), messages: Vec::new(), had_errors: false, + outputs: None, } } } @@ -228,6 +239,8 @@ pub struct ConfigurationExportResult { pub messages: Vec, #[serde(rename = "hadErrors")] pub had_errors: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub outputs: Option>, } impl ConfigurationExportResult { @@ -238,6 +251,7 @@ impl ConfigurationExportResult { result: None, messages: Vec::new(), had_errors: false, + outputs: None, } } } diff --git a/lib/dsc-lib/src/configure/context.rs b/lib/dsc-lib/src/configure/context.rs index c45296655..abd52948d 100644 --- a/lib/dsc-lib/src/configure/context.rs +++ b/lib/dsc-lib/src/configure/context.rs @@ -25,6 +25,7 @@ pub struct Context { pub dsc_version: Option, pub execution_type: ExecutionKind, pub extensions: Vec, + pub outputs: Map, pub parameters: HashMap, pub process_expressions: bool, pub process_mode: ProcessMode, @@ -47,6 +48,7 @@ impl Context { dsc_version: None, execution_type: ExecutionKind::Actual, extensions: Vec::new(), + outputs: Map::new(), parameters: HashMap::new(), process_expressions: true, process_mode: ProcessMode::Normal, diff --git a/lib/dsc-lib/src/configure/mod.rs b/lib/dsc-lib/src/configure/mod.rs index d86e0a309..1369f1b6f 100644 --- a/lib/dsc-lib/src/configure/mod.rs +++ b/lib/dsc-lib/src/configure/mod.rs @@ -1,9 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -use crate::configure::config_doc::{ExecutionKind, Metadata, Resource, Parameter}; use crate::configure::context::{Context, ProcessMode}; -use crate::configure::{config_doc::{IntOrExpression, RestartRequired}, parameters::Input}; +use crate::configure::{config_doc::{ExecutionKind, IntOrExpression, Metadata, Parameter, Resource, RestartRequired, ValueOrCopy}, parameters::Input}; use crate::discovery::discovery_trait::DiscoveryFilter; use crate::dscerror::DscError; use crate::dscresources::{ @@ -414,6 +413,10 @@ impl Configurator { result.metadata = Some( self.get_result_metadata(Operation::Get) ); + self.process_output()?; + if !self.context.outputs.is_empty() { + result.outputs = Some(self.context.outputs.clone()); + } Ok(result) } @@ -585,6 +588,10 @@ impl Configurator { result.metadata = Some( self.get_result_metadata(Operation::Set) ); + self.process_output()?; + if !self.context.outputs.is_empty() { + result.outputs = Some(self.context.outputs.clone()); + } Ok(result) } @@ -663,6 +670,10 @@ impl Configurator { result.metadata = Some( self.get_result_metadata(Operation::Test) ); + self.process_output()?; + if !self.context.outputs.is_empty() { + result.outputs = Some(self.context.outputs.clone()); + } Ok(result) } @@ -725,6 +736,10 @@ impl Configurator { } result.result = Some(conf); + self.process_output()?; + if !self.context.outputs.is_empty() { + result.outputs = Some(self.context.outputs.clone()); + } Ok(result) } @@ -739,6 +754,48 @@ impl Configurator { Ok(false) } + /// Process the outputs defined in the configuration. + /// + /// # Errors + /// + /// This function will return an error if the output processing fails. + pub fn process_output(&mut self) -> Result<(), DscError> { + if self.config.outputs.is_none() || self.context.execution_type == ExecutionKind::WhatIf { + return Ok(()); + } + if let Some(outputs) = &self.config.outputs { + for (name, output) in outputs { + if let Some(condition) = &output.condition { + let condition_result = self.statement_parser.parse_and_execute(condition, &self.context)?; + if condition_result != Value::Bool(true) { + info!("{}", t!("configure.mod.skippingOutput", name = name)); + continue; + } + } + + if let ValueOrCopy::Value(value) = &output.value_or_copy { + let value_result = self.statement_parser.parse_and_execute(value, &self.context)?; + if output.r#type == DataType::SecureString || output.r#type == DataType::SecureObject { + warn!("{}", t!("configure.mod.secureOutputSkipped", name = name)); + continue; + } + // TODO: handle nullable when supported + if value_result.is_string() && output.r#type != DataType::String || + value_result.is_i64() && output.r#type != DataType::Int || + value_result.is_boolean() && output.r#type != DataType::Bool || + value_result.is_array() && output.r#type != DataType::Array || + value_result.is_object() && output.r#type != DataType::Object { + return Err(DscError::Validation(t!("configure.mod.outputTypeNotMatch", name = name, expected_type = output.r#type).to_string())); + } + self.context.outputs.insert(name.clone(), value_result); + } else { + warn!("{}", t!("configure.mod.copyNotSupported", name = name)); + } + } + } + Ok(()) + } + /// Set the mounted path for the configuration. /// /// # Arguments