diff --git a/dsc/locales/en-us.toml b/dsc/locales/en-us.toml index b2126d48d..c9b761889 100644 --- a/dsc/locales/en-us.toml +++ b/dsc/locales/en-us.toml @@ -184,3 +184,4 @@ dscConfigRootAlreadySet = "The current value of DSC_CONFIG_ROOT env var will be settingDscConfigRoot = "Setting DSC_CONFIG_ROOT env var as" removingUtf8Bom = "Removing UTF-8 BOM from input" parametersNotObject = "Parameters must be an object" +invalidDscVersion = "Unable to parse the DSC version from the cargo manifest: %{error}" diff --git a/dsc/src/server/invoke_dsc_config.rs b/dsc/src/server/invoke_dsc_config.rs index bb07215e5..2237e0086 100644 --- a/dsc/src/server/invoke_dsc_config.rs +++ b/dsc/src/server/invoke_dsc_config.rs @@ -2,6 +2,7 @@ // Licensed under the MIT License. use crate::server::mcp_server::McpServer; +use crate::util::current_dsc_version; use dsc_lib::{ configure::{ config_doc::{Configuration, ExecutionKind}, @@ -126,13 +127,11 @@ impl McpServer { } }; - let mut configurator = match Configurator::new(&config_json, ProgressFormat::None) { + let mut configurator = match Configurator::new_with_dsc_version(&config_json, ProgressFormat::None, current_dsc_version()) { Ok(configurator) => configurator, Err(e) => return Err(McpError::internal_error(e.to_string(), None)), }; - configurator.context.dsc_version = Some(env!("CARGO_PKG_VERSION").to_string()); - if what_if.unwrap_or(false) { if !matches!(operation, ConfigOperation::Set) { return Err(McpError::invalid_params( diff --git a/dsc/src/subcommand.rs b/dsc/src/subcommand.rs index 737284350..e776e5c2f 100644 --- a/dsc/src/subcommand.rs +++ b/dsc/src/subcommand.rs @@ -5,7 +5,7 @@ use crate::args::{ConfigSubCommand, SchemaType, ExtensionSubCommand, FunctionSub use crate::resolve::{get_contents, Include}; use crate::resource_command::{get_resource, self}; use crate::tablewriter::Table; -use crate::util::{get_input, get_schema, in_desired_state, set_dscconfigroot, write_object, DSC_CONFIG_ROOT, EXIT_DSC_ASSERTION_FAILED, EXIT_DSC_ERROR, EXIT_INVALID_ARGS, EXIT_INVALID_INPUT, EXIT_JSON_ERROR}; +use crate::util::{current_dsc_version, get_input, get_schema, in_desired_state, set_dscconfigroot, write_object, DSC_CONFIG_ROOT, EXIT_DSC_ASSERTION_FAILED, EXIT_DSC_ERROR, EXIT_INVALID_ARGS, EXIT_INVALID_INPUT, EXIT_JSON_ERROR}; use dsc_lib::types::{FullyQualifiedTypeName, ResourceVersionReq, TypeNameFilter}; use dsc_lib::{ configure::{ @@ -310,7 +310,7 @@ pub fn config(subcommand: &ConfigSubCommand, parameters: &Option, mounte } }; - let mut configurator = match Configurator::new(&json_string, progress_format) { + let mut configurator = match Configurator::new_with_dsc_version(&json_string, progress_format, current_dsc_version()) { Ok(configurator) => configurator, Err(err) => { error!("Error: {err}"); @@ -318,8 +318,6 @@ pub fn config(subcommand: &ConfigSubCommand, parameters: &Option, mounte } }; - configurator.context.dsc_version = Some(env!("CARGO_PKG_VERSION").to_string()); - if let ConfigSubCommand::Set { what_if , .. } = subcommand && *what_if { configurator.context.execution_type = ExecutionKind::WhatIf; } diff --git a/dsc/src/util.rs b/dsc/src/util.rs index 5c21ac3cd..1869c230e 100644 --- a/dsc/src/util.rs +++ b/dsc/src/util.rs @@ -42,6 +42,7 @@ use dsc_lib::{ }, functions::FunctionDefinition, schemas::dsc_repo::{DscRepoSchema, RecognizedSchemaVersion, SchemaForm, SchemaUriPrefix}, + types::SemanticVersion, util::{ get_setting, parse_input_to_json, @@ -101,6 +102,23 @@ impl Default for TracingSetting { } } +/// Returns the current version of DSC as a semantic version. +/// +/// # Returns +/// +/// * `SemanticVersion` - The version of DSC as defined in the cargo manifest. +/// +/// # Panics +/// +/// If the version defined in the cargo manifest isn't a valid semantic version, this function +/// panics and reports the parse error. +pub(crate) fn current_dsc_version() -> SemanticVersion { + match SemanticVersion::parse(env!("CARGO_PKG_VERSION")) { + Ok(version) => version, + Err(err) => panic!("{}", t!("util.invalidDscVersion", error = err)), + } +} + /// Get string representation of JSON value. /// /// # Arguments diff --git a/dsc/tests/dsc_version.tests.ps1 b/dsc/tests/dsc_version.tests.ps1 index 9d2af8218..3138c85f3 100644 --- a/dsc/tests/dsc_version.tests.ps1 +++ b/dsc/tests/dsc_version.tests.ps1 @@ -1,6 +1,11 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +BeforeDiscovery { + $dscVersion = (dsc --version).Split(" ")[1] -as [System.Management.Automation.SemanticVersion] + $isPrerelease = -not [string]::IsNullOrEmpty($dscVersion.PreReleaseLabel) +} + Describe 'tests for metadata versioning' { It 'returns the correct dsc semantic version in metadata' { $config_yaml = @" @@ -36,10 +41,38 @@ Describe 'tests for metadata versioning' { } It 'returns no error if DSC version satisfies configuration requirement' { + $dscVersion = (dsc --version).Split(" ")[1] -as [System.Management.Automation.SemanticVersion] + # A prerelease build only satisfies a requirement that defines a prerelease segment for the + # same release, so pin a prerelease build to its release cycle, like '^3.4.0-preview'. + $versionReq = if ($dscVersion.PreReleaseLabel) { + '^{0}.{1}.{2}-{3}' -f $dscVersion.Major, $dscVersion.Minor, $dscVersion.Patch, $dscVersion.PreReleaseLabel.Split('.')[0] + } else { + '>=3.1' + } + $config_yaml = @" + `$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json + directives: + version: '$versionReq' + resources: + - name: Echo + type: Microsoft.DSC.Debug/Echo + properties: + output: 'Hello, World!' +"@ + $out = $config_yaml | dsc config get -f - 2>$testdrive/error.log + $errorLog = Get-Content -Path $testdrive/error.log -Raw + $errorLog | Should -BeNullOrEmpty + $LASTEXITCODE | Should -Be 0 + $result = $out | ConvertFrom-Json + $result.results[0].result.actualState.output | Should -BeExactly 'Hello, World!' -Because $out + } + + It 'validates the version directive against the running DSC version' { + $dscVersion = (dsc --version).Split(" ")[1] $config_yaml = @" `$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json directives: - version: '>=3.1' + version: '=$dscVersion' resources: - name: Echo type: Microsoft.DSC.Debug/Echo @@ -53,4 +86,47 @@ Describe 'tests for metadata versioning' { $result = $out | ConvertFrom-Json $result.results[0].result.actualState.output | Should -BeExactly 'Hello, World!' -Because $out } + + It 'reports the running DSC version when the requirement is not satisfied' { + $dscVersion = (dsc --version).Split(" ")[1] + $config_yaml = @" + `$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json + directives: + version: '<3.2.0' + resources: + - name: Echo + type: Microsoft.DSC.Debug/Echo + properties: + output: 'Hello, World!' +"@ + $null = $config_yaml | dsc config get -f - 2>$testdrive/error.log + $errorLog = Get-Content -Path $testdrive/error.log -Raw + $errorLog | Should -BeLike "*Validation*Configuration requires DSC version '<3.2.0', but the current version is '$dscVersion'*" + $LASTEXITCODE | Should -Be 2 + } + + It 'requires a prerelease segment to match a prerelease DSC version for requirement: ' -Skip:(-not $isPrerelease) -TestCases @( + @{ requirement = '={0}.{1}.{2}'; satisfied = $false } + @{ requirement = '>={0}.{1}.{2}'; satisfied = $false } + @{ requirement = '<{0}.{1}.{2}'; satisfied = $false } + @{ requirement = '^{0}.{1}.{2}-{3}'; satisfied = $true } + @{ requirement = '>={0}.{1}.{2}-{4}'; satisfied = $true } + ) { + param($requirement, $satisfied) + + $dscVersion = (dsc --version).Split(" ")[1] -as [System.Management.Automation.SemanticVersion] + $versionReq = $requirement -f $dscVersion.Major, $dscVersion.Minor, $dscVersion.Patch, $dscVersion.PreReleaseLabel.Split('.')[0], $dscVersion.PreReleaseLabel + $config_yaml = @" + `$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json + directives: + version: '$versionReq' + resources: + - name: Echo + type: Microsoft.DSC.Debug/Echo + properties: + output: 'Hello, World!' +"@ + $null = $config_yaml | dsc config get -f - 2>$testdrive/error.log + $LASTEXITCODE | Should -Be ($satisfied ? 0 : 2) -Because (Get-Content -Path $testdrive/error.log -Raw) + } } diff --git a/lib/dsc-lib/src/configure/context.rs b/lib/dsc-lib/src/configure/context.rs index 1f66f5fb4..d38629912 100644 --- a/lib/dsc-lib/src/configure/context.rs +++ b/lib/dsc-lib/src/configure/context.rs @@ -3,6 +3,7 @@ use chrono::{DateTime, Local}; use crate::{configure::config_doc::{ExecutionKind, Operation, UserFunctionDefinition}, extensions::dscextension::DscExtension}; +use crate::types::SemanticVersion; use dsc_lib_security_context::{get_security_context, SecurityContext}; use serde_json::{Map, Value}; use std::{collections::HashMap, path::PathBuf}; @@ -23,7 +24,12 @@ pub enum ProcessMode { pub struct Context { pub copy: HashMap, pub copy_current_loop_name: String, - pub dsc_version: Option, + /// The semantic version of DSC that is processing the configuration. + /// + /// Defaults to the version of the `dsc-lib` crate. A host that knows its own version, like + /// the `dsc` CLI, sets it with + /// [`Configurator::new_with_dsc_version`](crate::configure::Configurator::new_with_dsc_version). + pub dsc_version: SemanticVersion, pub execution_type: ExecutionKind, pub extensions: Vec, pub lambda_raw_args: std::cell::RefCell>>, @@ -52,7 +58,7 @@ impl Context { Self { copy: HashMap::new(), copy_current_loop_name: String::new(), - dsc_version: None, + dsc_version: dsc_lib_version(), execution_type: ExecutionKind::Actual, extensions: Vec::new(), lambda_raw_args: std::cell::RefCell::new(None), @@ -86,6 +92,22 @@ impl Default for Context { } } +/// Returns the version of the `dsc-lib` crate as a semantic version. +/// +/// This is the default DSC version for a [`Context`] when the host doesn't provide its own. +/// +/// # Panics +/// +/// Panics if the version in the cargo manifest isn't a valid semantic version. Cargo rejects +/// such a manifest at build time, so this can't happen for a built crate. +fn dsc_lib_version() -> SemanticVersion { + let manifest_version = env!("CARGO_PKG_VERSION"); + match SemanticVersion::parse(manifest_version) { + Ok(version) => version, + Err(err) => panic!("unable to parse '{manifest_version}' as a semantic version: {err}"), + } +} + #[cfg(target_os = "windows")] fn get_default_os_system_root() -> PathBuf { // use SYSTEMDRIVE env var to get the default target path, append trailing separator diff --git a/lib/dsc-lib/src/configure/mod.rs b/lib/dsc-lib/src/configure/mod.rs index 3c01965a2..467c18c0c 100644 --- a/lib/dsc-lib/src/configure/mod.rs +++ b/lib/dsc-lib/src/configure/mod.rs @@ -499,11 +499,52 @@ impl Configurator { /// /// This function will return an error if the configuration is invalid or the underlying discovery fails. pub fn new(json: &str, progress_format: ProgressFormat) -> Result { + Self::new_with_context(json, progress_format, Context::new()) + } + + /// Create a new `Configurator` instance for a specific version of DSC. + /// + /// The version is used to validate the `version` directive of the configuration and is + /// reported in the result metadata. Use this constructor when the host, like the `dsc` CLI, + /// knows its own version. [`Configurator::new`] uses the version of the `dsc-lib` crate instead. + /// + /// # Arguments + /// + /// * `json` - The configuration to use in JSON. + /// * `progress_format` - The format to report progress in. + /// * `dsc_version` - The semantic version of DSC that is processing the configuration. + /// + /// # Errors + /// + /// This function will return an error if the configuration is invalid or the underlying discovery fails. + pub fn new_with_dsc_version(json: &str, progress_format: ProgressFormat, dsc_version: SemanticVersion) -> Result { + let mut context = Context::new(); + context.dsc_version = dsc_version; + Self::new_with_context(json, progress_format, context) + } + + /// Create a new `Configurator` instance with a prepared context. + /// + /// The public constructors prepare the context and delegate to this function. It validates + /// the configuration against the context, which must already define the values that + /// validation depends on, like the DSC version, and then registers the discovered extensions + /// on the context. + /// + /// # Arguments + /// + /// * `json` - The configuration to use in JSON. + /// * `progress_format` - The format to report progress in. + /// * `context` - The context to process the configuration with. + /// + /// # Errors + /// + /// This function will return an error if the configuration is invalid or the underlying discovery fails. + fn new_with_context(json: &str, progress_format: ProgressFormat, context: Context) -> Result { let discovery = Discovery::new(); let mut config = Configurator { json: json.to_owned(), config: Configuration::new(), - context: Context::new(), + context, discovery: discovery.clone(), statement_parser: Statement::new()?, progress_format, @@ -1250,11 +1291,6 @@ impl Configurator { fn get_result_metadata(&self, operation: Operation) -> Metadata { let end_datetime = chrono::Local::now(); - let version = self - .context - .dsc_version - .clone() - .unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_string()); Metadata { microsoft: Some( MicrosoftDscMetadata { @@ -1265,7 +1301,7 @@ impl Configurator { restart_required: self.context.restart_required.clone(), security_context: Some(self.context.security_context.clone()), start_datetime: Some(self.context.start_datetime.to_rfc3339()), - version: Some(version), + version: Some(self.context.dsc_version.to_string()), copy_loops: None, } ), @@ -1278,7 +1314,7 @@ impl Configurator { execution_information.duration = Some(end_datetime.signed_duration_since(self.context.start_datetime).to_string()); execution_information.end_datetime = Some(end_datetime.to_rfc3339()); execution_information.start_datetime = Some(self.context.start_datetime.to_rfc3339()); - execution_information.version = self.context.dsc_version.clone(); + execution_information.version = Some(self.context.dsc_version.to_string()); execution_information.execution_type = Some(self.context.execution_type.clone()); execution_information.operation = Some(operation); execution_information.restart_required = self.context.restart_required.clone(); @@ -1295,11 +1331,9 @@ impl Configurator { check_security_context(config.metadata.as_ref(), config_security_context.as_ref())?; if let Some(directives) = &config.directives - && let Some(version_req) = &directives.version { - let dsc_version = SemanticVersion::parse(env!("CARGO_PKG_VERSION"))?; - if !version_req.matches(&dsc_version) { - return Err(DscError::Validation(t!("configure.mod.versionNotSatisfied", required_version = version_req, current_version = env!("CARGO_PKG_VERSION")).to_string())); - } + && let Some(version_req) = &directives.version + && !version_req.matches(&self.context.dsc_version) { + return Err(DscError::Validation(t!("configure.mod.versionNotSatisfied", required_version = version_req, current_version = self.context.dsc_version).to_string())); } let mut resource_discovery_mode = ResourceDiscoveryMode::PreDeployment;