diff --git a/crates/spk-schema/src/requirements_list.rs b/crates/spk-schema/src/requirements_list.rs index 2ebc790e74..7e18f8d0dc 100644 --- a/crates/spk-schema/src/requirements_list.rs +++ b/crates/spk-schema/src/requirements_list.rs @@ -6,7 +6,7 @@ use std::collections::HashSet; use std::fmt::Write; use serde::{Deserialize, Serialize}; -use spk_schema_foundation::name::PkgName; +use spk_schema_foundation::name::{OptName, PkgName}; use spk_schema_foundation::version::Compatibility; use spk_schema_ident::{BuildIdent, PinPolicy}; @@ -116,6 +116,17 @@ impl RequirementsList { Compatibility::incompatible(format!("No request exists for {}", theirs.name())) } + /// Remove a requirement from this list. + /// + /// All requests with the same name as the given name are removed + /// from the list. + pub fn remove_all(&mut self, name: &N) + where + N: AsRef + ?Sized, + { + self.0.retain(|existing| existing.name() != name.as_ref()); + } + /// Render all requests with a package pin using the given resolved packages. pub fn render_all_pins( &mut self, diff --git a/crates/spk-schema/src/spec.rs b/crates/spk-schema/src/spec.rs index 6e3dee9987..b17a83c7cf 100644 --- a/crates/spk-schema/src/spec.rs +++ b/crates/spk-schema/src/spec.rs @@ -189,32 +189,40 @@ impl TemplateExt for SpecTemplate { Ok(v) => v, }; + let api = template_value.get(&serde_yaml::Value::String("api".to_string())); + + if api.is_none() { + tracing::warn!( + "Spec file is missing the 'api' field, this may be an error in the future" + ); + tracing::warn!(" > for specs in the original spk format, add 'api: v0/package'"); + } + + let name_field = match api { + Some(serde_yaml::Value::String(api)) if api == "v0/platform" => "platform", + _ => "pkg", + }; + let pkg = template_value - .get(&serde_yaml::Value::String("pkg".to_string())) + .get(&serde_yaml::Value::String(name_field.to_string())) .ok_or_else(|| { - crate::Error::String(format!("Missing pkg field in spec file: {file_path:?}")) + crate::Error::String(format!( + "Missing {name_field} field in spec file: {file_path:?}" + )) })?; + let pkg = pkg.as_str().ok_or_else(|| { crate::Error::String(format!( - "Invalid value for 'pkg' field: expected string, got {pkg:?} in {file_path:?}" + "Invalid value for '{name_field}' field: expected string, got {pkg:?} in {file_path:?}" )) })?; + let name = PkgNameBuf::from_str( // it should never be possible for split to return 0 results // but this trick avoids the use of unwrap pkg.split('/').next().unwrap_or(pkg), )?; - if template_value - .get(&serde_yaml::Value::String("api".to_string())) - .is_none() - { - tracing::warn!( - "Spec file is missing the 'api' field, this may be an error in the future" - ); - tracing::warn!(" > for specs in the original spk format, add 'api: v0/package'"); - } - Ok(Self { file_path, name, @@ -234,6 +242,8 @@ impl TemplateExt for SpecTemplate { pub enum SpecRecipe { #[serde(rename = "v0/package")] V0Package(super::v0::Spec), + #[serde(rename = "v0/platform")] + V0Platform(super::v0::Platform), } impl Recipe for SpecRecipe { @@ -244,6 +254,7 @@ impl Recipe for SpecRecipe { fn ident(&self) -> &VersionIdent { match self { SpecRecipe::V0Package(r) => Recipe::ident(r), + SpecRecipe::V0Platform(r) => Recipe::ident(r), } } @@ -268,6 +279,16 @@ impl Recipe for SpecRecipe { .map(SpecVariant::V0) .collect(), ), + SpecRecipe::V0Platform(r) => Cow::Owned( + // use into_owned instead of iter().cloned() in case it's + // already an owned instance + #[allow(clippy::unnecessary_to_owned)] + r.default_variants() + .into_owned() + .into_iter() + .map(SpecVariant::V0) + .collect(), + ), } } @@ -277,6 +298,7 @@ impl Recipe for SpecRecipe { { match self { SpecRecipe::V0Package(r) => r.resolve_options(variant), + SpecRecipe::V0Platform(r) => r.resolve_options(variant), } } @@ -286,6 +308,7 @@ impl Recipe for SpecRecipe { { match self { SpecRecipe::V0Package(r) => r.get_build_requirements(variant), + SpecRecipe::V0Platform(r) => r.get_build_requirements(variant), } } @@ -299,12 +322,18 @@ impl Recipe for SpecRecipe { .into_iter() .map(SpecTest::V0) .collect()), + SpecRecipe::V0Platform(r) => Ok(r + .get_tests(stage, variant)? + .into_iter() + .map(SpecTest::V0) + .collect()), } } fn generate_source_build(&self, root: &Path) -> Result { match self { SpecRecipe::V0Package(r) => r.generate_source_build(root).map(Spec::V0Package), + SpecRecipe::V0Platform(r) => r.generate_source_build(root).map(Spec::V0Package), } } @@ -318,6 +347,9 @@ impl Recipe for SpecRecipe { SpecRecipe::V0Package(r) => r .generate_binary_build(variant, build_env) .map(Spec::V0Package), + SpecRecipe::V0Platform(r) => r + .generate_binary_build(variant, build_env) + .map(Spec::V0Package), } } } @@ -326,6 +358,7 @@ impl Named for SpecRecipe { fn name(&self) -> &PkgName { match self { SpecRecipe::V0Package(r) => r.name(), + SpecRecipe::V0Platform(r) => r.name(), } } } @@ -334,6 +367,7 @@ impl HasVersion for SpecRecipe { fn version(&self) -> &Version { match self { SpecRecipe::V0Package(r) => r.version(), + SpecRecipe::V0Platform(r) => r.version(), } } } @@ -342,6 +376,7 @@ impl Versioned for SpecRecipe { fn compat(&self) -> &Compat { match self { SpecRecipe::V0Package(spec) => spec.compat(), + SpecRecipe::V0Platform(spec) => spec.compat(), } } } @@ -382,6 +417,11 @@ impl FromYaml for SpecRecipe { .map_err(|err| SerdeError::new(yaml, SerdeYamlError(err)))?; Ok(Self::V0Package(inner)) } + ApiVersion::V0Platform => { + let inner = serde_yaml::from_str(&yaml) + .map_err(|err| SerdeError::new(yaml, SerdeYamlError(err)))?; + Ok(Self::V0Platform(inner)) + } } } } @@ -638,6 +678,11 @@ impl FromYaml for Spec { .map_err(|err| SerdeError::new(yaml, SerdeYamlError(err)))?; Ok(Self::V0Package(inner)) } + ApiVersion::V0Platform => { + let inner = serde_yaml::from_str(&yaml) + .map_err(|err| SerdeError::new(yaml, SerdeYamlError(err)))?; + Ok(Self::V0Package(inner)) + } } } } @@ -652,6 +697,8 @@ impl AsRef for Spec { pub enum ApiVersion { #[serde(rename = "v0/package")] V0Package, + #[serde(rename = "v0/platform")] + V0Platform, } impl Default for ApiVersion { diff --git a/crates/spk-schema/src/v0/mod.rs b/crates/spk-schema/src/v0/mod.rs index c9e9ecdcf5..d492adfff1 100644 --- a/crates/spk-schema/src/v0/mod.rs +++ b/crates/spk-schema/src/v0/mod.rs @@ -2,11 +2,13 @@ // SPDX-License-Identifier: Apache-2.0 // https://github.com/imageworks/spk +mod platform; mod spec; mod test_spec; mod variant; mod variant_spec; +pub use platform::Platform; pub use spec::Spec; pub use test_spec::TestSpec; pub use variant::Variant; diff --git a/crates/spk-schema/src/v0/platform.rs b/crates/spk-schema/src/v0/platform.rs new file mode 100644 index 0000000000..7d4c1c78ec --- /dev/null +++ b/crates/spk-schema/src/v0/platform.rs @@ -0,0 +1,457 @@ +// Copyright (c) Sony Pictures Imageworks, et al. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/imageworks/spk + +use std::borrow::Cow; +use std::path::Path; + +use serde::{Deserialize, Serialize}; +use spk_schema_foundation::ident_build::Build; +use spk_schema_foundation::name::PkgName; +use spk_schema_foundation::option_map::{host_options, OptionMap, Stringified}; +use spk_schema_foundation::spec_ops::{HasVersion, Named, Versioned}; +use spk_schema_foundation::version::Version; +use spk_schema_ident::{ + BuildIdent, + InclusionPolicy, + PkgRequest, + Request, + RequestedBy, + VersionIdent, +}; + +use super::{Spec, TestSpec}; +use crate::foundation::version::Compat; +use crate::ident::is_false; +use crate::metadata::Meta; +use crate::option::VarOpt; +use crate::{ + BuildEnv, + BuildSpec, + Deprecate, + DeprecateMut, + InputVariant, + Opt, + Package, + Recipe, + RequirementsList, + Result, + Script, + TestStage, + Variant, +}; + +#[cfg(test)] +#[path = "./platform_test.rs"] +mod platform_test; + +#[derive(Debug, Clone, Hash, PartialEq, Eq, Ord, PartialOrd, Serialize)] +pub struct PlatformRequirementsPatch { + #[serde(skip_serializing_if = "Option::is_none")] + add: Option, + #[serde(skip_serializing_if = "Option::is_none")] + remove: Option, +} + +#[derive(Debug, Clone, Hash, PartialEq, Eq, Ord, PartialOrd, Serialize)] +#[serde(untagged)] +pub enum PlatformRequirements { + BareAdd(RequirementsList), + Patch(PlatformRequirementsPatch), +} + +impl PlatformRequirements { + /// Handle an "add" operation, adding a request to the given list. + /// + /// The request's attributes may be modified to conform to the expected + /// behavior for platform requirements. + fn process_add(dest: &mut RequirementsList, add: &RequirementsList) { + for request in add.iter() { + let mut request = request.clone(); + if let Request::Pkg(pkg) = &mut request { + pkg.inclusion_policy = InclusionPolicy::IfAlreadyPresent; + }; + dest.insert_or_replace(request); + } + } + + /// Update the given spec with the requirements for this platform. + fn update_spec_for_binary_build( + &self, + spec: &mut super::Spec, + _build_env: &E, + ) -> Result<()> + where + E: BuildEnv, + P: Package, + { + match self { + PlatformRequirements::BareAdd(add) => { + PlatformRequirements::process_add(&mut spec.install.requirements, add); + } + PlatformRequirements::Patch(patch) => { + // Removes are performed first; an inherited request can be + // removed and re-added with a different set of components. + if let Some(remove) = &patch.remove { + for request in remove.iter() { + spec.install.requirements.remove_all(request.name()); + } + } + + if let Some(add) = &patch.add { + PlatformRequirements::process_add(&mut spec.install.requirements, add); + } + } + } + + Ok(()) + } +} + +#[derive(Debug, Clone, Hash, PartialEq, Eq, Ord, PartialOrd, Serialize)] +pub struct Platform { + pub platform: VersionIdent, + #[serde(default, skip_serializing_if = "Meta::is_default")] + pub meta: Meta, + #[serde(default, skip_serializing_if = "Compat::is_default")] + pub compat: Compat, + #[serde(default, skip_serializing_if = "is_false")] + pub deprecated: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub requirements: Option, +} + +impl Deprecate for Platform { + fn is_deprecated(&self) -> bool { + self.deprecated + } +} + +impl DeprecateMut for Platform { + fn deprecate(&mut self) -> Result<()> { + self.deprecated = true; + Ok(()) + } + + fn undeprecate(&mut self) -> Result<()> { + self.deprecated = false; + Ok(()) + } +} + +impl Named for Platform { + fn name(&self) -> &PkgName { + self.platform.name() + } +} + +impl HasVersion for Platform { + fn version(&self) -> &Version { + self.platform.version() + } +} + +impl Versioned for Platform { + fn compat(&self) -> &Compat { + &self.compat + } +} + +impl Recipe for Platform { + type Output = Spec; + type Variant = super::Variant; + type Test = TestSpec; + + fn ident(&self) -> &VersionIdent { + &self.platform + } + + fn default_variants(&self) -> Cow<'_, Vec> { + Cow::Owned(vec![Self::Variant::default()]) + } + + fn resolve_options(&self, _variant: &V) -> Result + where + V: Variant, + { + Ok(OptionMap::default()) + } + + fn get_build_requirements(&self, variant: &V) -> Result> + where + V: Variant, + { + let options = self.resolve_options(variant)?; + let build_digest = Build::Digest(options.digest()); + + let mut requirements = RequirementsList::default(); + + if let Some(base) = self.base.as_ref() { + requirements.insert_or_merge(Request::Pkg(PkgRequest::from_ident( + base.clone().into_any(None), + RequestedBy::BinaryBuild(self.ident().to_build(build_digest.clone())), + )))?; + } + + Ok(Cow::Owned(requirements)) + } + + fn get_tests(&self, _stage: TestStage, _variant: &V) -> Result> + where + V: Variant, + { + Ok(Vec::new()) + } + + fn generate_source_build(&self, _root: &Path) -> Result { + Ok(Spec::new(self.platform.clone().into_build(Build::Source))) + } + + fn generate_binary_build(&self, variant: &V, build_env: &E) -> Result + where + V: InputVariant, + E: BuildEnv, + P: Package, + { + let Self { + platform, + meta, + compat, + deprecated: _deprecated, + base, + requirements, + } = self; + + // Translate the platform spec into a "normal" recipe and delegate to + // that recipe's generate_binary_build method. + let mut spec = super::Spec::new(platform.clone()); + spec.compat = compat.clone(); + spec.meta = meta.clone(); + + // Platforms have no sources + spec.sources = Vec::new(); + + // Supply a safe no-op build script and standard host/os + // related option names but leave the values for the later + // steps of building to fill in. + let mut build_host_options = Vec::new(); + for (name, _value) in host_options()?.iter() { + build_host_options.push(Opt::Var(VarOpt::new(name)?)); + } + spec.build = BuildSpec { + script: Script::new([""]), + options: build_host_options, + ..Default::default() + }; + + // Add base requirements, if any, first. + if let Some(base) = base.as_ref() { + let build_env = build_env.build_env(); + let base = build_env + .iter() + .find(|package| package.name() == base.name()) + .ok_or_else(|| { + crate::Error::String(format!( + "base platform '{}' not found in build environment", + base.name() + )) + })?; + for requirement in base.runtime_requirements().iter() { + spec.install + .requirements + .insert_or_replace(requirement.clone()); + } + } + + if let Some(requirements) = requirements.as_ref() { + requirements.update_spec_for_binary_build(&mut spec, build_env)?; + } + + spec.generate_binary_build(variant, build_env) + } +} + +// A private visitor struct that may be extended to aid linting in future. +// It is currently identical to the PlatformRequirementsPatch struct. +// If it does not change or gain extra methods when linting is added, +// then it can probably be removed and the public PlatformRequirementsPatch +// used in its place. +// TODO: update this when linting/warning support is added +#[derive(Default)] +struct PlatformRequirementsPatchVisitor { + add: Option, + remove: Option, +} + +impl From for PlatformRequirementsPatch { + fn from(visitor: PlatformRequirementsPatchVisitor) -> Self { + Self { + add: visitor.add, + remove: visitor.remove, + } + } +} + +impl From for PlatformRequirementsPatchVisitor { + fn from(patch: PlatformRequirementsPatch) -> Self { + Self { + add: patch.add, + remove: patch.remove, + } + } +} + +#[derive(Default)] +struct PlatformRequirementsVisitor { + bare_add: Option, + patch: Option, +} + +impl From for PlatformRequirements { + fn from(visitor: PlatformRequirementsVisitor) -> Self { + if let Some(add) = visitor.bare_add { + Self::BareAdd(add) + } else if let Some(patch) = visitor.patch { + Self::Patch(patch.into()) + } else { + Self::BareAdd(RequirementsList::default()) + } + } +} + +impl From for PlatformRequirementsVisitor { + fn from(requirements: PlatformRequirements) -> Self { + match requirements { + PlatformRequirements::BareAdd(add) => Self { + bare_add: Some(add), + ..Default::default() + }, + PlatformRequirements::Patch(patch) => Self { + patch: Some(patch.into()), + ..Default::default() + }, + } + } +} + +impl<'de> serde::de::Visitor<'de> for PlatformRequirementsVisitor { + type Value = PlatformRequirements; + + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a platform requirements specification") + } + + fn visit_map(self, mut map: A) -> std::result::Result + where + A: serde::de::MapAccess<'de>, + { + let mut patch = PlatformRequirementsPatchVisitor::default(); + while let Some(key) = map.next_key::()? { + match key.as_str() { + "add" => { + patch.add = Some(map.next_value::()?); + } + "remove" => { + patch.remove = Some(map.next_value::()?); + } + _ => { + // ignore any unrecognized field, but consume the value anyway + // TODO: could we warn about fields that look like typos? + map.next_value::()?; + } + } + } + + Ok(PlatformRequirements::Patch(patch.into())) + } + + fn visit_seq(self, seq: A) -> std::result::Result + where + A: serde::de::SeqAccess<'de>, + { + let requirements = + RequirementsList::deserialize(serde::de::value::SeqAccessDeserializer::new(seq))?; + + Ok(PlatformRequirements::BareAdd(requirements)) + } +} + +impl<'de> Deserialize<'de> for PlatformRequirementsVisitor { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::de::Deserializer<'de>, + { + Ok(deserializer + .deserialize_any(PlatformRequirementsVisitor::default())? + .into()) + } +} + +#[derive(Default)] +struct PlatformVisitor { + platform: Option, + base: Option, + meta: Option, + compat: Option, + deprecated: Option, + requirements: Option, +} + +impl<'de> serde::de::Visitor<'de> for PlatformVisitor { + type Value = Platform; + + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a platform specification") + } + + fn visit_map(mut self, mut map: A) -> std::result::Result + where + A: serde::de::MapAccess<'de>, + { + while let Some(key) = map.next_key::()? { + match key.as_str() { + "platform" => self.platform = Some(map.next_value::()?), + "base" => self.base = Some(map.next_value::()?), + "meta" => self.meta = Some(map.next_value::()?), + "compat" => self.compat = Some(map.next_value::()?), + "deprecated" => self.deprecated = Some(map.next_value::()?), + "requirements" => { + self.requirements = Some(map.next_value::()?) + } + _ => { + // ignore any unrecognized field, but consume the value anyway + // TODO: could we warn about fields that look like typos? + map.next_value::()?; + } + } + } + + let platform = self + .platform + .take() + .ok_or_else(|| serde::de::Error::missing_field("platform"))?; + + Ok(Platform { + meta: self.meta.take().unwrap_or_default(), + compat: self.compat.take().unwrap_or_default(), + deprecated: self.deprecated.take().unwrap_or_default(), + platform, + base: self.base.take(), + requirements: self.requirements.take().map(Into::into), + }) + } +} + +impl<'de> Deserialize<'de> for Platform +where + VersionIdent: serde::de::DeserializeOwned, +{ + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::de::Deserializer<'de>, + { + deserializer.deserialize_map(PlatformVisitor::default()) + } +} diff --git a/crates/spk-schema/src/v0/platform_test.rs b/crates/spk-schema/src/v0/platform_test.rs new file mode 100644 index 0000000000..84db8c51da --- /dev/null +++ b/crates/spk-schema/src/v0/platform_test.rs @@ -0,0 +1,197 @@ +// Copyright (c) Sony Pictures Imageworks, et al. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/imageworks/spk + +use std::collections::HashMap; + +use rstest::rstest; +use spk_schema_foundation::option_map; +use spk_schema_foundation::option_map::host_options; +use spk_schema_ident::{BuildIdent, InclusionPolicy, Request}; + +use super::Platform; +use crate::v0::Spec; +use crate::Opt::Var; +use crate::{BuildEnv, Recipe}; + +#[rstest] +fn test_platform_is_valid_with_only_api_and_name() { + let _spec: Platform = serde_yaml::from_str( + r#" + platform: test-platform + api: v0/platform + "#, + ) + .unwrap(); +} + +#[rstest] +#[case::add_form( + r#" + platform: test-platform + api: v0/platform + requirements: + - pkg: test-requirement + "# +)] +#[case::patch_form( + r#" + platform: test-platform + api: v0/platform + requirements: + add: + - pkg: test-requirement + "# +)] +fn test_platform_add_pkg_requirement(#[case] spec: &str) { + struct TestBuildEnv(); + + impl BuildEnv for TestBuildEnv { + type Package = Spec; + + fn build_env(&self) -> Vec { + Vec::new() + } + + fn env_vars(&self) -> HashMap { + HashMap::new() + } + } + + let build_env = TestBuildEnv(); + + let spec: Platform = serde_yaml::from_str(spec).unwrap(); + + let build = spec + .generate_binary_build(&option_map! {}, &build_env) + .unwrap(); + + let host_options = host_options().unwrap(); + let build_options = build + .build + .options + .iter() + .filter_map(|o| match o { + Var(varopt) => Some(varopt.var.clone()), + _ => None, + }) + .collect::>(); + for (name, _value) in host_options.iter() { + assert!(build_options.contains(name)); + } + + assert_eq!(build.install.requirements.len(), 1); + assert!( + matches!(&build.install.requirements[0], Request::Pkg(pkg) if pkg.pkg.name() == "test-requirement") + ); + assert!( + matches!(&build.install.requirements[0], Request::Pkg(pkg) if pkg.inclusion_policy == InclusionPolicy::IfAlreadyPresent) + ); +} + +#[rstest] +fn test_platform_inheritance() { + struct TestBuildEnv(); + + impl BuildEnv for TestBuildEnv { + type Package = Spec; + + fn build_env(&self) -> Vec { + vec![serde_yaml::from_str( + r#" + api: package/v0 + pkg: base/1.0.0/3TCOOP2W + install: + requirements: + - pkg: inherit-me + "#, + ) + .unwrap()] + } + + fn env_vars(&self) -> HashMap { + HashMap::new() + } + } + + let build_env = TestBuildEnv(); + + let spec: Platform = serde_yaml::from_str( + r#" + platform: test-platform + base: base + api: v0/platform + requirements: + - pkg: test-requirement + "#, + ) + .unwrap(); + + let build = spec + .generate_binary_build(&option_map! {}, &build_env) + .unwrap(); + + assert_eq!(build.install.requirements.len(), 2); + assert!( + matches!(&build.install.requirements[0], Request::Pkg(pkg) if pkg.pkg.name() == "inherit-me") + ); + assert!( + matches!(&build.install.requirements[1], Request::Pkg(pkg) if pkg.pkg.name() == "test-requirement") + ); +} + +#[rstest] +fn test_platform_inheritance_with_override_and_removal() { + struct TestBuildEnv(); + + impl BuildEnv for TestBuildEnv { + type Package = Spec; + + fn build_env(&self) -> Vec { + vec![serde_yaml::from_str( + r#" + api: package/v0 + pkg: base/1.0.0/3TCOOP2W + install: + requirements: + - pkg: inherit-me1/1.0.0 + - pkg: inherit-me2/1.0.0 + - pkg: inherit-me3/1.0.0 + "#, + ) + .unwrap()] + } + + fn env_vars(&self) -> HashMap { + HashMap::new() + } + } + + let build_env = TestBuildEnv(); + + let spec: Platform = serde_yaml::from_str( + r#" + platform: test-platform + base: base + api: v0/platform + requirements: + add: + - pkg: inherit-me1/2.0.0 + remove: + - pkg: inherit-me2 + "#, + ) + .unwrap(); + + let build = spec + .generate_binary_build(&option_map! {}, &build_env) + .unwrap(); + + assert_eq!(build.install.requirements.len(), 2); + assert!( + matches!(&build.install.requirements[0], Request::Pkg(pkg) if pkg.pkg.name() == "inherit-me1" && pkg.pkg.version.to_string() == "2.0.0") + ); + assert!( + matches!(&build.install.requirements[1], Request::Pkg(pkg) if pkg.pkg.name() == "inherit-me3") + ); +} diff --git a/docs/use/spec.md b/docs/use/spec.md index 003e4caab8..c8c34acf31 100644 --- a/docs/use/spec.md +++ b/docs/use/spec.md @@ -414,6 +414,85 @@ install: - { var: abi, static: cp27m } ``` +#### Platform Package Specs + +Platforms are a convenience for writing the package spec for +"meta-packages" used to specify versions of a set of packages. They +are used to constrain builds of other packages and do not contain +usable programs, libraries or code themselves. + +A typical platform package has an empty build options list, and one or +more install requirements, all in `IfAlreadyPresent` mode. These +install requirements describe the versions of dependencies that target +compatibility with some application execution environment, like a +DCC. + +A platform spec reduces the amount of boilerplate needed to set up a +platform package compared to using the v0/package recipe format. A +platform spec will be filled in with appropriate defaults +automatically for a platform. + +The platform spec also provides a way to inherit package requirements +from another package, such as another platform. This allows platforms +to be based on other platforms without respecifying the same +requirements, e.g. a DCC specific platform can pull in the +requirements in a company or site specific platform. The expectation +is that a platform would only inherit from other platforms, but that +is not strictly required. + +When a platform is built, it produces an ordinary v0/package just like +a "normal" v0/package spec would do, and it is treated like any other +package for use by downstream consumers. All its requirements will +always be `IfAlreadyPresent` ones. + +An example platform spec: + +```yaml +platform: company-platform +api: v0/platform +requirements: + - pkg: gcc/9.3.1 + - pkg: python/3.9 + - pkg: imath/3 +``` + +The `platform:` fields provides the name of the platform package. The +`api:` field indicates this is a platform spec. + +The `requirements` field contains the list of requirements in the +platform. These will have `IfAlreadyPresent` added to them +automatically, it does not need to be specified for them. + +An example platform spec that inherits from the 'company-platform' and makes adjustments of its own: + +```yaml +platform: dcc-platform +base: company-platform +api: v0/platform +requirements: + add: + - pkg: python: 3.7 + remove: + - pkg: imath +``` + +The `base:` field indicates which platform this platform spec is based on (inherits the requiremented from). + +Specifying a requirement drectly with `- ` is the same as specifying it with `add:`, but is a shorthand for convienence. + +The `add:` and `remove:` entries indicate changes to the requirements inherited from the base platform. `add:` means "add or replace". `remove:` means remove entirely. `remove:` will work on components of packages, if they are specified, as well as full packages. Removing requirement is done before adding when determining the platform's final requirements. + +This is another way of specifying the same `dcc-platform` without basing it on the `company-platform`: + +```yaml +platform: dcc-platform +api: v0/platform +requirements: + - pkg: gcc/9.3.1 + - pkg: python/3.7 + - pkg: some-package/1.2.3 +``` + ### Testing Tests can also be defined in the package spec file. SPK currently supports three types of tests that validate different aspects of the package. Tests are defined by a bash script and _stage_.