diff --git a/dsc/src/util.rs b/dsc/src/util.rs index 4b18fac6c..deedef391 100644 --- a/dsc/src/util.rs +++ b/dsc/src/util.rs @@ -41,6 +41,7 @@ use dsc_lib::{ extension_manifest::ExtensionManifest, }, functions::FunctionDefinition, + schemas::dsc_repo::{DscRepoSchema, RecognizedSchemaVersion, SchemaForm, SchemaUriPrefix}, util::{ get_setting, parse_input_to_json, @@ -163,37 +164,37 @@ pub fn add_fields_to_json(json: &str, fields_to_add: &HashMap) - pub fn get_schema(schema: SchemaType) -> Schema { match schema { SchemaType::AdaptedDscResourceManifest => { - schema_for!(AdaptedDscResourceManifest) + repo_schema::() }, SchemaType::Configuration => { - schema_for!(Configuration) + repo_schema::() }, SchemaType::ConfigurationExportResult => { - schema_for!(ConfigurationExportResult) + repo_schema::() }, SchemaType::ConfigurationGetResult => { - schema_for!(ConfigurationGetResult) + repo_schema::() }, SchemaType::ConfigurationSetResult => { - schema_for!(ConfigurationSetResult) + repo_schema::() }, SchemaType::ConfigurationTestResult => { - schema_for!(ConfigurationTestResult) + repo_schema::() }, SchemaType::DscResource => { - schema_for!(DscResource) + repo_schema::() }, SchemaType::ExtensionDiscoverResult => { - schema_for!(DiscoverResult) + repo_schema::() }, SchemaType::ExtensionManifest => { - schema_for!(ExtensionManifest) + repo_schema::() }, SchemaType::FunctionDefinition => { - schema_for!(FunctionDefinition) + repo_schema::() }, SchemaType::GetResult => { - schema_for!(GetResult) + repo_schema::() }, SchemaType::Include => { schema_for!(Include) @@ -202,35 +203,48 @@ pub fn get_schema(schema: SchemaType) -> Schema { schema_for!(ManifestList) }, SchemaType::ResolveResult => { - schema_for!(ResolveResult) + repo_schema::() }, SchemaType::Resource => { - schema_for!(Resource) + repo_schema::() }, SchemaType::ResourceGetResult => { - schema_for!(ResourceGetResult) + repo_schema::() }, SchemaType::ResourceSetResult => { - schema_for!(ResourceSetResult) + repo_schema::() }, SchemaType::ResourceTestResult => { - schema_for!(ResourceTestResult) + repo_schema::() }, SchemaType::ResourceManifest => { - schema_for!(ResourceManifest) + repo_schema::() }, SchemaType::RestartRequired => { - schema_for!(RestartRequired) + repo_schema::() }, SchemaType::SetResult => { - schema_for!(SetResult) + repo_schema::() }, SchemaType::TestResult => { - schema_for!(TestResult) + repo_schema::() }, } } +fn repo_schema() -> Schema { + let schema_form = if T::SCHEMA_SHOULD_BUNDLE { + SchemaForm::Bundled + } else { + SchemaForm::Canonical + }; + T::generate_schema( + RecognizedSchemaVersion::default(), + schema_form, + SchemaUriPrefix::AkaDotMs + ) +} + /// Write the JSON object to the console /// /// # Arguments diff --git a/lib/dsc-lib-jsonschema/locales/en-us.toml b/lib/dsc-lib-jsonschema/locales/en-us.toml index d0a0b263b..1c28d2d4b 100644 --- a/lib/dsc-lib-jsonschema/locales/en-us.toml +++ b/lib/dsc-lib-jsonschema/locales/en-us.toml @@ -5,6 +5,10 @@ unrecognizedSchemaUri = "Unrecognized $schema URI" validSchemaUrisAre = "Valid schema URIs are" missingTranslation = "unable to retrieve translation for undefined key '#{key}'" +[dsc_repo.recognized_schema_version] +unrecognizedVersion = "Unrecognized schema version folder" +validVersionsAre = "Valid schema version folders are" + [transforms.idiomaticize_externally_tagged_enum] applies_to = "invalid application of idiomaticize_externally_tagged_enum; missing 'oneOf' keyword in transforming schema: %{transforming_schema}" oneOf_array = "invalid application of idiomaticize_externally_tagged_enum; 'oneOf' isn't an array in transforming schema: %{transforming_schema}" diff --git a/lib/dsc-lib-jsonschema/src/dsc_repo/mod.rs b/lib/dsc-lib-jsonschema/src/dsc_repo/mod.rs index 40a99f2e0..e149350e5 100644 --- a/lib/dsc-lib-jsonschema/src/dsc_repo/mod.rs +++ b/lib/dsc-lib-jsonschema/src/dsc_repo/mod.rs @@ -14,6 +14,7 @@ pub use crate::schema_i18n; mod recognized_schema_version; pub use recognized_schema_version::RecognizedSchemaVersion; +pub use recognized_schema_version::UnrecognizedSchemaVersion; mod schema_form; pub use schema_form::SchemaForm; diff --git a/lib/dsc-lib-jsonschema/src/dsc_repo/recognized_schema_version.rs b/lib/dsc-lib-jsonschema/src/dsc_repo/recognized_schema_version.rs index 38ac3161d..dcb25f86f 100644 --- a/lib/dsc-lib-jsonschema/src/dsc_repo/recognized_schema_version.rs +++ b/lib/dsc-lib-jsonschema/src/dsc_repo/recognized_schema_version.rs @@ -6,4 +6,56 @@ //! checks the git tags for non-prerelease versions of DSC to generate the enum type with all of the //! correct values. The enum can be used transparently throughout the rest of the libraries. +use rust_i18n::t; +use thiserror::Error; + include!(concat!(env!("OUT_DIR"), "/recognized_schema_version.rs")); + +/// Defines the error when parsing a string that isn't a recognized schema version folder. +#[derive(Error, Debug, Clone, PartialEq)] +#[error( + "{t}: {0}. {t2}: {1:?}", + t = t!("dsc_repo.recognized_schema_version.unrecognizedVersion"), + t2 = t!("dsc_repo.recognized_schema_version.validVersionsAre") +)] +pub struct UnrecognizedSchemaVersion(pub String, pub Vec); + +impl std::str::FromStr for RecognizedSchemaVersion { + type Err = UnrecognizedSchemaVersion; + + fn from_str(s: &str) -> Result { + let candidate = s.trim(); + Self::all() + .into_iter() + .find(|version| version.to_string().eq_ignore_ascii_case(candidate)) + .ok_or_else(|| UnrecognizedSchemaVersion( + candidate.to_string(), + Self::all().iter().map(ToString::to_string).collect() + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn from_str_round_trips_every_recognized_version() { + for version in RecognizedSchemaVersion::all() { + let parsed: RecognizedSchemaVersion = version.to_string().parse().unwrap(); + assert_eq!(parsed, version); + } + } + + #[test] + fn from_str_is_case_insensitive_and_trims() { + let parsed: RecognizedSchemaVersion = " VNEXT ".parse().unwrap(); + assert_eq!(parsed, RecognizedSchemaVersion::VNext); + } + + #[test] + fn from_str_rejects_unrecognized_versions() { + assert!("v99.0.0".parse::().is_err()); + assert!("not-a-version".parse::().is_err()); + } +} diff --git a/lib/dsc-lib/src/dscresources/adapted_resource_manifest.rs b/lib/dsc-lib/src/dscresources/adapted_resource_manifest.rs index e13d38db7..fcb8b1c6e 100644 --- a/lib/dsc-lib/src/dscresources/adapted_resource_manifest.rs +++ b/lib/dsc-lib/src/dscresources/adapted_resource_manifest.rs @@ -25,7 +25,7 @@ pub enum AdaptedPathOrContent { #[serde(deny_unknown_fields, rename_all = "camelCase")] #[dsc_repo_schema( base_name = "manifest", - folder_path = "resource", + folder_path = "resource/adapted", should_bundle = true, schema_field( name = schema_version, @@ -36,7 +36,7 @@ pub enum AdaptedPathOrContent { pub struct AdaptedDscResourceManifest { /// The version of the resource manifest schema. #[serde(rename = "$schema")] - #[schemars(schema_with = "ResourceManifest::recognized_schema_uris_subschema")] + #[schemars(schema_with = "AdaptedDscResourceManifest::recognized_schema_uris_union_subschema")] pub schema_version: String, /// The namespaced name of the resource. #[serde(rename="type")] @@ -64,3 +64,16 @@ pub struct AdaptedDscResourceManifest { /// The JSON Schema of the resource. pub schema: Map, } + +impl AdaptedDscResourceManifest { + fn recognized_schema_uris_union_subschema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + let mut subschema = ::recognized_schema_uris_subschema(generator); + let uris: Vec = Self::recognized_schema_uris() + .into_iter() + .chain(ResourceManifest::recognized_schema_uris()) + .map(Value::String) + .collect(); + subschema.insert("enum".to_string(), Value::Array(uris)); + subschema + } +} diff --git a/lib/dsc-lib/src/dscresources/invoke_result.rs b/lib/dsc-lib/src/dscresources/invoke_result.rs index 1658f56a0..9115d2896 100644 --- a/lib/dsc-lib/src/dscresources/invoke_result.rs +++ b/lib/dsc-lib/src/dscresources/invoke_result.rs @@ -193,7 +193,7 @@ pub struct DeleteResult { } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, DscRepoSchema)] -#[dsc_repo_schema(base_name = "delete", folder_path = "outputs/resource")] +#[dsc_repo_schema(base_name = "delete.whatIf", folder_path = "outputs/resource")] #[serde(deny_unknown_fields)] pub struct DeleteWhatIfResult { #[serde(rename = "whatIf", skip_serializing_if = "Option::is_none")] diff --git a/xtask/locales/en-us.toml b/xtask/locales/en-us.toml index 38f87e629..dba331f7a 100644 --- a/xtask/locales/en-us.toml +++ b/xtask/locales/en-us.toml @@ -2,7 +2,14 @@ about = "xtask provides build helpers for the DSC rust project." schemaAbout = "Commands for managing DSC repository schemas." schemaExportAbout = "Export DSC schemas to disk." +schemaExportVersionHelp = "The schema version folder to export, like 'v3.2' or 'vNext'. May be specified multiple times. Defaults to 'vNext'." +schemaExportReleaseHelp = "A release version like '3.3.0'. Exports the patch, minor, and major version folders for the release, like 'v3.3.0', 'v3.3', and 'v3'." + +[main] +invalidReleaseVersion = "Invalid release version; expected a full version like '3.3.0'" +unrecognizedReleaseFolder = "Schema version folder isn't recognized; if the release was just tagged, refresh 'lib/dsc-lib-jsonschema/.versions.json' by running '.versions.ps1' and rebuild" [schemas.export] serializationFailure = "Failed to serialize JSON Schema as string" ioError = "Failed to export JSON Schema, IO error" +duplicatePath = "Multiple schemas export to the same path; check the `dsc_repo_schema` attributes for a `base_name`/`folder_path` collision" diff --git a/xtask/src/args.rs b/xtask/src/args.rs index 03e8001ba..eb54330b4 100644 --- a/xtask/src/args.rs +++ b/xtask/src/args.rs @@ -2,6 +2,7 @@ // Licensed under the MIT License. use clap::{Parser, Subcommand}; +use dsc_lib::schemas::dsc_repo::RecognizedSchemaVersion; use rust_i18n::t; #[derive(Debug, Parser)] @@ -24,5 +25,12 @@ pub enum SubCommand { #[derive(Debug, PartialEq, Eq, Subcommand)] pub enum SchemaSubCommand { #[clap(name = "export", about = t!("args.schemaExportAbout").to_string())] - Export + Export { + /// The schema version folder(s) to export. Repeatable. Defaults to `vNext`. + #[clap(long = "schema-version", help = t!("args.schemaExportVersionHelp").to_string())] + schema_versions: Vec, + /// A release version that expands to its patch, minor, and major version folders. + #[clap(long = "release", conflicts_with = "schema_versions", help = t!("args.schemaExportReleaseHelp").to_string())] + release: Option, + } } diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 35ef085d2..1f60e95e1 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -3,7 +3,7 @@ use clap::Parser; use dsc_lib::schemas::dsc_repo::RecognizedSchemaVersion; -use rust_i18n::i18n; +use rust_i18n::{i18n, t}; use thiserror::Error; use crate::{ @@ -19,7 +19,11 @@ pub(crate) mod schemas { #[derive(Debug, Error)] pub(crate) enum XTaskError { #[error(transparent)] - SchemaExport(#[from] SchemaExportError) + SchemaExport(#[from] SchemaExportError), + #[error("{t}: {0}", t = t!("main.invalidReleaseVersion"))] + InvalidReleaseVersion(String), + #[error("{t}: {0}", t = t!("main.unrecognizedReleaseFolder"))] + UnrecognizedReleaseFolder(String), } i18n!("locales", fallback = "en-us"); @@ -29,10 +33,85 @@ fn main() -> Result<(), XTaskError> { match args.subcommand { SubCommand::Schema { sub_command } => match sub_command { - SchemaSubCommand::Export => { - export_schemas(RecognizedSchemaVersion::VNext)?; + SchemaSubCommand::Export { schema_versions, release } => { + for schema_version in resolve_export_versions(schema_versions, release.as_deref())? { + export_schemas(schema_version)?; + } Ok(()) }, }, } } + +fn resolve_export_versions( + schema_versions: Vec, + release: Option<&str> +) -> Result, XTaskError> { + let Some(release) = release else { + return Ok(if schema_versions.is_empty() { + vec![RecognizedSchemaVersion::VNext] + } else { + schema_versions + }); + }; + + let version = release.trim().trim_start_matches('v'); + let segments: Vec<&str> = version.split('.').collect(); + let is_numeric = |segment: &&str| !segment.is_empty() && segment.chars().all(|c| c.is_ascii_digit()); + if segments.len() != 3 || !segments.iter().all(is_numeric) { + return Err(XTaskError::InvalidReleaseVersion(release.to_string())); + } + + let folders = [ + format!("v{}.{}.{}", segments[0], segments[1], segments[2]), + format!("v{}.{}", segments[0], segments[1]), + format!("v{}", segments[0]), + ]; + folders.iter().map(|folder| { + folder.parse::() + .map_err(|_| XTaskError::UnrecognizedReleaseFolder(folder.clone())) + }).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_defaults_to_vnext() { + let versions = resolve_export_versions(Vec::new(), None).unwrap(); + assert_eq!(versions, vec![RecognizedSchemaVersion::VNext]); + } + + #[test] + fn resolve_passes_through_explicit_versions() { + let requested = vec![RecognizedSchemaVersion::VNext, RecognizedSchemaVersion::default()]; + let versions = resolve_export_versions(requested.clone(), None).unwrap(); + assert_eq!(versions, requested); + } + + #[test] + fn resolve_release_expands_to_patch_minor_and_major_folders() { + let latest = RecognizedSchemaVersion::latest().to_string(); + let release = latest.trim_start_matches('v').to_string(); + let versions = resolve_export_versions(Vec::new(), Some(&release)).unwrap(); + assert_eq!(versions.len(), 3); + assert_eq!(versions[0].to_string(), latest); + } + + #[test] + fn resolve_release_rejects_partial_versions() { + assert!(matches!( + resolve_export_versions(Vec::new(), Some("3.2")), + Err(XTaskError::InvalidReleaseVersion(_)) + )); + } + + #[test] + fn resolve_release_rejects_unrecognized_versions() { + assert!(matches!( + resolve_export_versions(Vec::new(), Some("99.0.0")), + Err(XTaskError::UnrecognizedReleaseFolder(_)) + )); + } +} diff --git a/xtask/src/schemas/export.rs b/xtask/src/schemas/export.rs index 936ec8cd2..fdfb4a8b8 100644 --- a/xtask/src/schemas/export.rs +++ b/xtask/src/schemas/export.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -use std::{fs, ops::Add, path::PathBuf, sync::LazyLock}; +use std::{collections::HashSet, fs, ops::Add, path::PathBuf, sync::LazyLock}; use dsc_lib::schemas::{ dsc_repo::{DscRepoSchema, RecognizedSchemaVersion}, @@ -20,6 +20,10 @@ pub(crate) enum SchemaExportError { /// Raised when an IO error prevents exporting a schema to the file system. #[error("{t}: {0}", t = t!("schemas.export.ioError"))] IOError(#[from] std::io::Error), + /// Raised when two exported types resolve to the same file path, which would silently + /// overwrite one schema with another. + #[error("{t}: {0}", t = t!("schemas.export.duplicatePath"))] + DuplicatePath(String), } /// Helper static to retrieve the root folder once and use repeatedly when exporting schemas to the @@ -30,8 +34,18 @@ static PROJECT_DIR: LazyLock = LazyLock::new(|| { }); /// Writes the given JSON Schema to the filesystem relative to the project folder. -pub(crate) fn write_schema(relative_path: PathBuf, schema: Schema) -> Result<(), SchemaExportError> { - +/// +/// The `written_paths` set records every path exported during the run so that two types +/// resolving to the same path fail the export instead of silently overwriting each other. +pub(crate) fn write_schema( + written_paths: &mut HashSet, + relative_path: PathBuf, + schema: Schema +) -> Result<(), SchemaExportError> { + if !written_paths.insert(relative_path.clone()) { + return Err(SchemaExportError::DuplicatePath(relative_path.display().to_string())); + } + let json_schema = serde_json::to_string_pretty(&schema.to_value_with_stable_order())?.add("\n"); let path = PROJECT_DIR.clone().join("schemas").join(relative_path); let folder = path.parent().unwrap(); @@ -46,11 +60,12 @@ pub(crate) fn write_schema(relative_path: PathBuf, schema: Schema) -> Result<(), } macro_rules! export_type_schemas { - ($schema_version:expr => $($type_to_export:ty),+) => { + ($written_paths:expr, $schema_version:expr => $($type_to_export:ty),+) => { { $( for schema_form in <$type_to_export>::get_valid_schema_forms() { write_schema( + $written_paths, <$type_to_export>::get_schema_relative_path($schema_version, schema_form).into(), <$type_to_export>::generate_exportable_schema($schema_version, schema_form) )?; @@ -63,16 +78,21 @@ macro_rules! export_type_schemas { pub(crate) fn export_schemas( schema_version: RecognizedSchemaVersion ) -> Result<(), SchemaExportError> { + let mut written_paths: HashSet = HashSet::new(); export_type_schemas!( + &mut written_paths, schema_version => + dsc_lib::configure::config_doc::ConfigDirective, dsc_lib::configure::config_doc::Configuration, dsc_lib::configure::config_doc::DataType, + dsc_lib::configure::config_doc::ExecutionInformation, dsc_lib::configure::config_doc::ExecutionKind, dsc_lib::configure::config_doc::Metadata, dsc_lib::configure::config_doc::Operation, dsc_lib::configure::config_doc::Output, dsc_lib::configure::config_doc::Parameter, dsc_lib::configure::config_doc::Resource, + dsc_lib::configure::config_doc::ResourceDirective, dsc_lib::configure::config_doc::ResourceDiscoveryMode, dsc_lib::configure::config_doc::RestartRequired, dsc_lib::configure::config_doc::SecurityContextKind, @@ -87,6 +107,7 @@ pub(crate) fn export_schemas( dsc_lib::configure::config_result::ResourceGetResult, dsc_lib::configure::config_result::ResourceMessage, dsc_lib::configure::config_result::ResourceSetResult, + dsc_lib::configure::config_result::ResourceTestResult, dsc_lib::dscresources::adapted_resource_manifest::AdaptedDscResourceManifest, dsc_lib::dscresources::dscresource::Capability, dsc_lib::dscresources::dscresource::DscResource, @@ -104,6 +125,7 @@ pub(crate) fn export_schemas( dsc_lib::dscresources::resource_manifest::Adapter, dsc_lib::dscresources::resource_manifest::DeleteMethod, dsc_lib::dscresources::resource_manifest::ExportMethod, + dsc_lib::dscresources::resource_manifest::ExportSchemaKind, dsc_lib::dscresources::resource_manifest::GetArgKind, dsc_lib::dscresources::resource_manifest::GetMethod, dsc_lib::dscresources::resource_manifest::InputKind, @@ -111,6 +133,7 @@ pub(crate) fn export_schemas( dsc_lib::dscresources::resource_manifest::ResolveMethod, dsc_lib::dscresources::resource_manifest::ResourceManifest, dsc_lib::dscresources::resource_manifest::ReturnKind, + dsc_lib::dscresources::resource_manifest::SchemaArgKind, dsc_lib::dscresources::resource_manifest::SchemaKind, dsc_lib::dscresources::resource_manifest::SetDeleteArgKind, dsc_lib::dscresources::resource_manifest::SetMethod, @@ -126,12 +149,14 @@ pub(crate) fn export_schemas( dsc_lib::functions::FunctionArgKind, dsc_lib::functions::FunctionCategory, dsc_lib::functions::FunctionDefinition, + dsc_lib::types::DateVersion, dsc_lib::types::ExitCodesMap, dsc_lib::types::FullyQualifiedTypeName, dsc_lib::types::ResourceVersion, dsc_lib::types::ResourceVersionReq, dsc_lib::types::SemanticVersion, dsc_lib::types::SemanticVersionReq, + dsc_lib::types::Tag, dsc_lib::types::TagList );