diff --git a/CHANGELOG.md b/CHANGELOG.md index 213665c64..c54115702 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,13 +10,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - #1117: Add `sync` command for incremental loading of changed files in dev-mode modules. Detects modified files since last sync using SHA-1 hash and recompiles only what is stale. Supports `-delete` for processing removed files and `-test` for running changed test-phase unit tests. - #106: A module can now specify `` to prevent installation in non-%SYS namespaces. +- #1158: Installing deployed code modules will automatically filter for the current IRIS version ### Changed - Minimum supported Python version is now 3.9 +- #1158: `search` and `install` no longer list deployed modules that were not built for the current IRIS platform. Use `search -versions` to see all versions and their supported platforms. ### Fixed - Performance: Studio project creation on package load in dev mode is now 80% faster. - #994: Prevent crash in `zn` command when target namespace lacks IPM mappings +- #987: Fixed issue where installing dependencies with ORAS would fail due to failure to properly get the manifest ## [0.10.9] - 2026-08-05 diff --git a/src/cls/IPM/Lifecycle/Base.cls b/src/cls/IPM/Lifecycle/Base.cls index f5847b62f..f5be2c312 100644 --- a/src/cls/IPM/Lifecycle/Base.cls +++ b/src/cls/IPM/Lifecycle/Base.cls @@ -1120,6 +1120,7 @@ Method %Compile(ByRef pParams) As %Status // Just copy the first level of subscripts (resource names), not all the gory details. set tOneResourceKey = "" + set tAnyToCompile = 0 for { set tOneResourceKey = $order(tOneResourceList(tOneResourceKey)) quit:(tOneResourceKey="") @@ -1127,6 +1128,14 @@ Method %Compile(ByRef pParams) As %Status continue } set tCompileFlagMap(tFlags,tResource.Name,tOneResourceKey) = "" + set tAnyToCompile = 1 + } + + // Everything under this resource is already deployed, so no compile pass will + // ever claim it. Leaving it in tCompilableMap would stall the loop below, which + // only clears resources it compiles. + if 'tAnyToCompile { + kill tCompilableMap(tResource.Name) } } } @@ -1201,8 +1210,9 @@ Method %Compile(ByRef pParams) As %Status } } - // Cycle detection - if ('tCompiledSomething && '..HasDeployedResources) { + // Cycle detection. Anything not compilable has already been removed above, so a + // resource still waiting its turn here is waiting on itself. + if 'tCompiledSomething { set tFailedResources = "" set tResource = "" for { @@ -1713,8 +1723,8 @@ Method %Document(ByRef pParams) As %Status set tSC = ..Module.BuildDependencyGraph(.tDependencyGraph) $$$ThrowOnError(tSC) - // tDependencyGraph format: - // tDependencyGraph() = $ListBuild(, , ) + // tDependencyGraph format: see %IPM.Storage.Module:BuildDependencyGraph for the node + // layout. Only and are used here. // tDependencyGraph(,_" "_) = // being empty means it's already installed. // All these modules need to already be installed. @@ -1867,8 +1877,7 @@ Method %MakeDeployed(ByRef pParams) As %Status if '$isobject(..Module.SystemRequirements) { set ..Module.SystemRequirements = ##class(%IPM.Storage.SystemRequirements).%New() } - set tSystemVersion = $system.Version.GetMajor() _ "." _ $system.Version.GetMinor() - set ..Module.SystemRequirements.VersionString = tSystemVersion + set ..Module.SystemRequirements.VersionString = $$$CurrentPlatformVersion $$$ThrowOnError(..Module.%Save()) if $data(tDeployClassArray) { if (tVerbose) { diff --git a/src/cls/IPM/Main.cls b/src/cls/IPM/Main.cls index 13afceb39..ddf9d2226 100644 --- a/src/cls/IPM/Main.cls +++ b/src/cls/IPM/Main.cls @@ -2261,7 +2261,7 @@ ClassMethod ShowModulesForRepository( } elseif pSearchString'="" { set where=" WHERE name = ?" } - set tQuery = "SELECT Name,Version,Repository,Description,Origin,AllVersions FROM %IPM_Utils.Module_GetModuleList(?,?) "_where + set tQuery = "SELECT Name,Version,Repository,Description,Origin,AllVersions,PlatformVersions FROM %IPM_Utils.Module_GetModuleList(?,?) "_where if (pSearchString = "") { set tRes = ##class(%SQL.Statement).%ExecDirect(, tQuery, pRepoName, pShowAllVersions) } else { @@ -2279,6 +2279,7 @@ ClassMethod ShowModulesForRepository( set list = list + 1 set list(list) = $listbuild(name, tRes.Version) set list(list, "Origin") = tRes.Origin + set list(list, "PlatformVersions") = tRes.PlatformVersions if pShowDesc { set list(list, "Description") = tRes.Description } @@ -2552,7 +2553,7 @@ ClassMethod Install( set tSearchCriteria.Name = $$$lcase(tModuleName) set tSearchCriteria.VersionExpression = tVersion set tSearchCriteria.Keywords = tKeywords - $$$ThrowOnError(##class(%IPM.Repo.Utils).SearchRepositoriesForModule(tSearchCriteria,.tResults)) + $$$ThrowOnError(##class(%IPM.Repo.Utils).SearchRepositoriesForModule(tSearchCriteria,.tResults,,.tPlatformFiltered)) if (tResults.Count() > 0) { set tResult = "" @@ -2586,19 +2587,31 @@ ClassMethod Install( if '$get(tParams("Update"),0) { set tParams("cmd") = "install" set tParams("Install") = 1 - } - if tResult.Deployed { - set platformVersion = $system.Version.GetMajor() _ "." _$system.Version.GetMinor() - set tResult.PlatformVersion = platformVersion - if ('tResult.PlatformVersions.Find(platformVersion)) { - $$$ThrowStatus($$$ERROR($$$GeneralError, "Deployed package '" _ tModuleName _ "' " _ tResult.VersionString _ " not supported on this platform " _ platformVersion _ ".")) - } } $$$ThrowOnError(log.SetSource(tResult.ServerName)) $$$ThrowOnError(log.SetVersion(tResult.Version)) $$$ThrowOnError(##class(%IPM.Utils.Module).LoadQualifiedReference(tResult, .tParams, , log)) } } else { + // The module may have matched but been excluded for being deployed for other platforms + // only, which deserves a better message than "not found". + // Only a deployed module with platforms that don't include this one is ever filtered, + // so there is always something to report here. + if $isobject(tPlatformFiltered) && (tPlatformFiltered.Count() > 0) { + // Report the newest filtered version: the one an unversioned install resolves to. + // A pinned install filters only its own version, so this names that version too. + #dim tFiltered As %IPM.Storage.ModuleInfo + #dim tCandidate As %IPM.Storage.ModuleInfo + set tFiltered = tPlatformFiltered.GetAt(1) + for tIdx = 2:1:tPlatformFiltered.Count() { + set tCandidate = tPlatformFiltered.GetAt(tIdx) + if tCandidate.Version.Follows(tFiltered.Version) { + set tFiltered = tCandidate + } + } + set tMsg = "'" _ tFiltered.Name _ "' " _ tFiltered.VersionString _ " is not available for platform " _ $$$CurrentPlatformVersion _ ". Available platforms: " _ tFiltered.FormatPlatformVersions() _ ". Run 'search " _ tFiltered.Name _ " -versions' to see all versions." + $$$ThrowStatus($$$ERROR($$$GeneralError, tMsg)) + } set tPrefix = "" if (tModuleName '= "") { if (tVersion '= "") { @@ -4225,6 +4238,7 @@ ClassMethod DisplayModules( $listbuild("Author.Person", $$$Yellow, "Author"), $listbuild("Origin", $$$Default), $listbuild("AllVersions", $$$Default, "Versions"), + $listbuild("PlatformVersions", $$$Default, "Platforms"), $listbuild("Repository", $$$Default), $listbuild("Installed In", $$$Default) ) diff --git a/src/cls/IPM/Repo/Manager.cls b/src/cls/IPM/Repo/Manager.cls index e874d7ad1..cda716cbc 100644 --- a/src/cls/IPM/Repo/Manager.cls +++ b/src/cls/IPM/Repo/Manager.cls @@ -1,3 +1,5 @@ +Include %IPM.Common + Class %IPM.Repo.Manager Extends %IPM.General.Singleton { @@ -47,13 +49,18 @@ Method CheckServiceCache( } /// Returns a list of all resolved module references matching the search criteria from configured repositories. +/// pPlatformFilteredResults receives the deployed results that matched the criteria but were excluded +/// for having no artifact for this platform, so callers can distinguish "no such module" from +/// "not built for this platform" without searching again. Method SearchRepositoriesForModule( pSearchCriteria As %IPM.Repo.SearchCriteria, Output pResults As %Library.ListOfObjects(ELEMENTTYPE="%IPM.Storage.QualifiedModuleInfo"), - pForceRemoteRepo As %Boolean = 0) As %Status + pForceRemoteRepo As %Boolean = 0, + Output pPlatformFilteredResults As %Library.ListOfObjects(ELEMENTTYPE="%IPM.Storage.ModuleInfo")) As %Status { set tSC = $$$OK set pResults = ##class(%Library.ListOfObjects).%New() + set pPlatformFilteredResults = ##class(%Library.ListOfObjects).%New() try { set registry = pSearchCriteria.Registry set pSearchCriteria.Name = $$$lcase(pSearchCriteria.Name) @@ -66,6 +73,7 @@ Method SearchRepositoriesForModule( set tSC = $$$ERROR($$$SQLCode,tRes.%SQLCODE,tRes.%Message) quit } + set currentPlatform = $$$CurrentPlatformVersion while (tRes.%Next(.tSC)) { if $$$ISERR(tSC) { quit @@ -101,7 +109,17 @@ Method SearchRepositoriesForModule( set tServerResults = tClient.ListModules(tClientCriteria) for i=1:1:tServerResults.Count() { set item = tServerResults.GetAt(i) - #; Sorting versions + if 'item.IsCompatibleWithPlatform(currentPlatform) { + do pPlatformFilteredResults.Insert(item) + continue + } + // Record which platform's artifact this result refers to; callers need it to build + // the tag. Left empty for repositories that don't report platforms, and for + // artifacts that aren't platform-specific at all. + if item.Deployed && (item.PlatformVersions.Find(currentPlatform) > 0) { + set item.PlatformVersion = currentPlatform + } + // Sorting versions set versions(+item.Version.Major, +item.Version.Minor, +item.Version.Patch, " "_item.Version.Patch_"_"_item.Version.Prerelease, repoID, tServer.Name) = item } } diff --git a/src/cls/IPM/Repo/Oras/PackageService.cls b/src/cls/IPM/Repo/Oras/PackageService.cls index 058d6562c..6bd04150e 100644 --- a/src/cls/IPM/Repo/Oras/PackageService.cls +++ b/src/cls/IPM/Repo/Oras/PackageService.cls @@ -151,10 +151,7 @@ Method GetModule( pModuleReference As %IPM.Storage.ModuleInfo, Output AsArchive As %Boolean = 0) As %Stream.Object { - set tag = $$$Semver2Tag(pModuleReference.VersionString) - if (pModuleReference.Deployed) { - set tag = tag _ $$$OrasTagPlatformSeparator _ pModuleReference.PlatformVersion - } + set tag = ..TagForReference(pModuleReference) set status = ..Pull(..Location, pModuleReference.Name, tag, ..Namespace, ..Username, ..Password, ..Token, ..TokenAuthMethod, .stream) $$$ThrowOnError(status) #; module is pulled as a .tgz file @@ -164,8 +161,31 @@ Method GetModule( Method GetModuleManifest(pModuleReference As %IPM.Storage.ModuleInfo) As %Stream.Object { - set name = pModuleReference.Name _ ":" _ $$$Semver2Tag(pModuleReference.VersionString) - return ..GetModuleXML(..Location, name, ..Namespace, ..Username, ..Password, ..Token, ..TokenAuthMethod) + set name = pModuleReference.Name _ ":" _ ..TagForReference(pModuleReference) + // GetModuleXML returns a string, but callers expect a stream per %IPM.Repo.IPackageService + set moduleXML = ..GetModuleXML(..Location, name, ..Namespace, ..Username, ..Password, ..Token, ..TokenAuthMethod) + if (moduleXML = "") { + $$$ThrowStatus($$$ERROR($$$GeneralError, "No manifest found for '" _ name _ "' in registry " _ ..Location)) + } + set stream = ##class(%Stream.GlobalCharacter).%New() + $$$ThrowOnError(stream.Write(moduleXML)) + $$$ThrowOnError(stream.Rewind()) + return stream +} + +/// Returns the registry tag holding the artifact for the given module reference. +/// A deployed module is tagged with a platform version suffix, since one version of it +/// is built separately for each supported platform. +ClassMethod TagForReference(moduleReference As %IPM.Storage.ModuleInfo) As %String [ Private ] +{ + set tag = $$$Semver2Tag(moduleReference.VersionString) + if moduleReference.Deployed { + if (moduleReference.PlatformVersion = "") { + $$$ThrowStatus($$$ERROR($$$GeneralError, "Cannot locate deployed module '" _ moduleReference.Name _ "' " _ moduleReference.VersionString _ " without a platform version.")) + } + set tag = tag _ $$$OrasTagPlatformSeparator _ moduleReference.PlatformVersion + } + return tag } Method HasModule(pModuleReference As %IPM.Storage.ModuleInfo) As %Boolean @@ -213,6 +233,29 @@ ClassMethod AggregatePlatformVersions( return ##class(%IPM.Utils.EmbeddedPython).FromPythonList(sortedTags) } +/// Reorders versionsList so that versions with an artifact for currentPlatform come first, each +/// group keeping the newest-first order it arrived in. A caller that reports only the first version +/// therefore reports the newest installable one, falling back to the newest version overall when +/// the module has no artifact for this platform at all. +ClassMethod PreferCompatibleVersions( + versionsList As %List, + ByRef aggregatedPlatformVersion, + currentPlatform As %String) As %List +{ + set compatible = "", incompatible = "" + set ptr = 0 + while $listnext(versionsList, ptr, version) { + set platformVersions = $get(aggregatedPlatformVersion(version)) + // An empty platform means the version is not platform-specific, so anything can use it. + if ($listfind(platformVersions, "") > 0) || ($listfind(platformVersions, currentPlatform) > 0) { + set compatible = compatible _ $listbuild(version) + } else { + set incompatible = incompatible _ $listbuild(version) + } + } + return compatible _ incompatible +} + /// moduleList is a list of objects of type %IPM.Storage.ModuleInfo to which this method adds entries /// name is the name of the package for which we are enumerating versions Method ListModulesFromTagString( @@ -224,6 +267,11 @@ Method ListModulesFromTagString( { set allTagsString = ..GetAllTags(..Location, name, "", client) set allVersionsList = ..AggregatePlatformVersions($listfromstring(allTagsString, ", "), .aggregatedPlatformVersion) + // The tri-states of AllVersions that report a single version should report one this platform + // can actually install. The empty state reports every version, so its order doesn't matter. + if (searchCriteria.AllVersions '= "") { + set allVersionsList = ..PreferCompatibleVersions(allVersionsList, .aggregatedPlatformVersion, $$$CurrentPlatformVersion) + } set pointer = 0 while $listnext(allVersionsList,pointer,moduleVersion) { // filter by version @@ -300,7 +348,9 @@ Method ListModulesFromTagString( set tModRef.Repository = artifactMetadata.ImageSource set tModRef.Description = artifactMetadata.ImageDescription set tModRef.Deployed = artifactMetadata.IPMDeployed - if (artifactMetadata.IPMDeployed '= "") { + // Skipped for AllVersions, which reports a single entry standing in for every version; + // these platforms belong to just one of them. + if (artifactMetadata.IPMDeployed '= "") && 'searchCriteria.AllVersions { set pvPtr = 0 while $listnext(platformVersions, pvPtr, pv) { $$$ThrowOnError(tModRef.PlatformVersions.Insert(pv)) diff --git a/src/cls/IPM/Repo/Oras/PublishService.cls b/src/cls/IPM/Repo/Oras/PublishService.cls index 43bbe3b7a..3b0b76039 100644 --- a/src/cls/IPM/Repo/Oras/PublishService.cls +++ b/src/cls/IPM/Repo/Oras/PublishService.cls @@ -41,6 +41,9 @@ Method PublishModule(pModule As %IPM.Repo.Remote.ModuleInfo) As %Status set metadata.IPMModuleV1XML = moduleXML do metadata.%JSONExportToString(.metaDataString) if (pModule.Deployed) { + if (pModule.PlatformVersion = "") { + $$$ThrowStatus($$$ERROR($$$GeneralError, "Cannot publish deployed module '" _ pModule.Name _ "' without a platform version.")) + } set tag = tag _ $$$OrasTagPlatformSeparator _ pModule.PlatformVersion } diff --git a/src/cls/IPM/Repo/Remote/PackageService.cls b/src/cls/IPM/Repo/Remote/PackageService.cls index 9bd3c2788..d00c38abd 100644 --- a/src/cls/IPM/Repo/Remote/PackageService.cls +++ b/src/cls/IPM/Repo/Remote/PackageService.cls @@ -102,7 +102,7 @@ Method GetModule( set deployed = pModuleReference.Deployed if deployed { - set platformVersion = $system.Version.GetMajor() _ "." _$system.Version.GetMinor() + set platformVersion = $$$CurrentPlatformVersion } set tSC = tRequest.Get($$$URLENCODE(tRequest.Location _ "packages/" _ name _ "/" _ version _ $select(deployed: "/" _ platformVersion, 1: ""))) diff --git a/src/cls/IPM/Repo/Utils.cls b/src/cls/IPM/Repo/Utils.cls index 586dea15e..50c14be6b 100644 --- a/src/cls/IPM/Repo/Utils.cls +++ b/src/cls/IPM/Repo/Utils.cls @@ -4,17 +4,20 @@ Class %IPM.Repo.Utils { /// Returns a list of all resolved module references matching the search criteria from configured repositories. +/// See %IPM.Repo.Manager:SearchRepositoriesForModule for pPlatformFilteredResults. ClassMethod SearchRepositoriesForModule( pSearchCriteria As %IPM.Repo.SearchCriteria, Output pResults As %Library.ListOfObjects(ELEMENTTYPE="%IPM.Storage.QualifiedModuleInfo"), - pForceRemoteRepo As %Boolean = 0) As %Status + pForceRemoteRepo As %Boolean = 0, + Output pPlatformFilteredResults As %Library.ListOfObjects(ELEMENTTYPE="%IPM.Storage.ModuleInfo")) As %Status { set tSC = $$$OK - set pResults = $$$NULLOREF + set pResults = "" + set pPlatformFilteredResults = "" try { set tManager = ##class(%IPM.Repo.Manager).%Get(.tSC) $$$ThrowOnError(tSC) - set tSC = tManager.SearchRepositoriesForModule(pSearchCriteria,.pResults) + set tSC = tManager.SearchRepositoriesForModule(pSearchCriteria,.pResults,,.pPlatformFilteredResults) } catch e { set tSC = e.AsStatus() } diff --git a/src/cls/IPM/ResourceProcessor/Default/Document.cls b/src/cls/IPM/ResourceProcessor/Default/Document.cls index 97d2a3363..c8b535c0d 100644 --- a/src/cls/IPM/ResourceProcessor/Default/Document.cls +++ b/src/cls/IPM/ResourceProcessor/Default/Document.cls @@ -184,6 +184,13 @@ Method OnPhase( write !, "Loading deployed resource from Studio project: "_..ResourceReference.Name } set ..CompileFromProject = 1 + // Mark deployed so Compile skips source compilation of Deploy=true + // resources. Persisted because the module is reopened from the + // database between here and Compile. + if '..ResourceReference.Module.Deployed { + set ..ResourceReference.Module.Deployed = 1 + $$$ThrowOnError(..ResourceReference.Module.%Save()) + } if '$data($$$DeployedProjectInstalled(tDeployedProjectPath)) { set sc = ##class(%Studio.Project).InstallFromFile(tDeployedProjectPath, "") if $$$ISERR(sc) { diff --git a/src/cls/IPM/Storage/Module.cls b/src/cls/IPM/Storage/Module.cls index d0fd9c194..57076d263 100644 --- a/src/cls/IPM/Storage/Module.cls +++ b/src/cls/IPM/Storage/Module.cls @@ -881,6 +881,8 @@ ClassMethod HasScope( ///
  • Version string of the module to be installed
  • ///
  • Display name of the module
  • ///
  • Scope of the module, or empty string if the dependency is not scoped
  • +///
  • Whether the module is deployed
  • +///
  • Platform version the deployed module was built for, or empty string if not deployed
  • /// ///

    /// Parameters:
    @@ -1141,7 +1143,7 @@ Method ProcessSingleDependencyIterative( localObj.Version.Satisfies(searchExpr) && ((version = "") || (version = localObj.VersionString)) if installedVersionValid && '(localObj.Version.IsSnapshot() && pForceSnapshotReload) { - set pDependencyGraph(pDep.Name) = $listbuild(pDepth,"",localObj.VersionString,pDep.DisplayName,pDep.Scope) + set pDependencyGraph(pDep.Name) = $listbuild(pDepth,"",localObj.VersionString,pDep.DisplayName,pDep.Scope,localObj.Deployed,localObj.PlatformVersion) set pDependencyGraph(pDep.Name,pParentInfo) = pDep.VersionString // Add to work queue for next depth @@ -1203,7 +1205,8 @@ Method ProcessSingleDependencyIterative( } set pDependencyGraph(pDep.Name) = $listbuild( - pDepth, qualifiedReference.ServerName, moduleObj.VersionString, pDep.DisplayName, pDep.Scope + pDepth, qualifiedReference.ServerName, moduleObj.VersionString, pDep.DisplayName, pDep.Scope, + qualifiedReference.Deployed, qualifiedReference.PlatformVersion ) set pDependencyGraph(pDep.Name,pParentInfo) = pDep.VersionString @@ -1223,9 +1226,7 @@ Method ProcessSingleDependencyIterative( if '$data(pManifestCache(manifestKey), moduleObj) { // Cache miss so retrieve set tSC = repoManager.RetrieveModuleManifest(qualifiedReference,.manifest) - if $$$ISERR(tSC) { - quit - } + $$$ThrowOnError(tSC) set moduleObj = ##class(%IPM.Utils.Module).GetModuleObjectFromStream(manifest,.found) if 'found { @@ -1238,7 +1239,8 @@ Method ProcessSingleDependencyIterative( // occurs if needed. set depth = $select(previousDepth=0:pDepth,previousDepth>pDepth:previousDepth,1:pDepth) set dependencyGraph(pDep.Name) = $listbuild( - depth,qualifiedReference.ServerName,moduleObj.VersionString,pDep.DisplayName,pDep.Scope + depth,qualifiedReference.ServerName,moduleObj.VersionString,pDep.DisplayName,pDep.Scope, + qualifiedReference.Deployed,qualifiedReference.PlatformVersion ) set dependencyGraph(pDep.Name,pParentInfo) = pDep.VersionString diff --git a/src/cls/IPM/Storage/ModuleInfo.cls b/src/cls/IPM/Storage/ModuleInfo.cls index a6d62e1c4..6a63264a5 100644 --- a/src/cls/IPM/Storage/ModuleInfo.cls +++ b/src/cls/IPM/Storage/ModuleInfo.cls @@ -1,4 +1,5 @@ /// Just a Serial Object of ModuleInfo so that it can be persisted in other objects. +/// Adds the platform compatibility information repositories report for deployed modules. Class %IPM.Storage.ModuleInfo Extends (%SerialObject, %IPM.General.ModuleInfo) [ StorageStrategy = "" ] { @@ -6,6 +7,33 @@ Parameter DEFAULTGLOBAL = "^IPM.Storage.ModuleInfo"; Property PlatformVersions As list Of %String(%JSONFIELDNAME = "platform_versions"); +/// Returns true if this module is installable on the given platform. Non-deployed modules are not +/// platform-specific and always match. A deployed module is built and tagged per platform, so it is +/// only installable when this platform is among the platforms it reports. +Method IsCompatibleWithPlatform(platformVersion As %String) As %Boolean +{ + if '..Deployed { + return 1 + } + return $listfind(..PlatformVersionList(), platformVersion) > 0 +} + +/// Returns PlatformVersions as a comma-separated string, e.g. "2024.1, 2025.1", or "" if empty. +Method FormatPlatformVersions() As %String +{ + return $listtostring(..PlatformVersionList(), ", ") +} + +/// Returns PlatformVersions as a $list, so it can be inspected with $listfind/$listtostring. +Method PlatformVersionList() As %List [ Private ] +{ + set result = "" + for i=1:1:..PlatformVersions.Count() { + set result = result _ $listbuild(..PlatformVersions.GetAt(i)) + } + return result +} + Storage Default { diff --git a/src/cls/IPM/Storage/QualifiedModuleInfo.cls b/src/cls/IPM/Storage/QualifiedModuleInfo.cls index 71ad0fa9b..d0daac66a 100644 --- a/src/cls/IPM/Storage/QualifiedModuleInfo.cls +++ b/src/cls/IPM/Storage/QualifiedModuleInfo.cls @@ -19,6 +19,7 @@ Method %OnNew( set ..Name = pResolvedReference.Name set ..Version = pResolvedReference.Version set ..Deployed = pResolvedReference.Deployed + set ..PlatformVersion = pResolvedReference.PlatformVersion set ..PlatformVersions = pResolvedReference.PlatformVersions set ..VersionString = pResolvedReference.VersionString set ..AllVersions = pResolvedReference.AllVersions diff --git a/src/cls/IPM/Utils/Module.cls b/src/cls/IPM/Utils/Module.cls index 87e3208a2..edae48207 100644 --- a/src/cls/IPM/Utils/Module.cls +++ b/src/cls/IPM/Utils/Module.cls @@ -630,13 +630,20 @@ ClassMethod BuildAllDependencyGraphs( #dim tModRef As %IPM.Storage.ModuleInfo set tModRef = tList.GetAt(i) - // Retrieve module manifest - set tManifest = tPackageService.GetModuleManifest(tModRef) + // Retrieve module manifest. A missing or unreadable manifest for one module must + // not abort discovery across every repository, so record it and move on. + try { + set tManifest = tPackageService.GetModuleManifest(tModRef) + } catch manifestEx { + set pErrorList($increment(pErrorList)) = $listbuild(tRepoName,tModRef.Name,tModRef.VersionString,manifestEx.AsStatus()) + continue + } set tReader = ##class(%XML.Reader).%New() set tSC = tReader.OpenStream(tManifest) if $$$ISERR(tSC) { - quit + set pErrorList($increment(pErrorList)) = $listbuild(tRepoName,tModRef.Name,tModRef.VersionString,tSC) + continue } do tReader.Correlate("Module","%IPM.Storage.Module") do tReader.Next(.tModule,.tCorrSC) @@ -870,7 +877,7 @@ ClassMethod GetAvailableModuleNamesClose(ByRef qHandle As %Binary) As %Status [ Query GetModuleList( pServer As %String, - pShowAllVersions As %Boolean) As %Query(ROWSPEC = "Name:%String,Version:%String,Repository:%String,Description:%String,Origin:%String,AllVersions:%String") [ SqlProc ] + pShowAllVersions As %Boolean) As %Query(ROWSPEC = "Name:%String,Version:%String,Repository:%String,Description:%String,Origin:%String,AllVersions:%String,PlatformVersions:%String") [ SqlProc ] { } @@ -903,9 +910,13 @@ ClassMethod GetModuleListExecute( write !, "Skipping repo '" _ pServer _ "': " _ $system.Status.GetErrorText(e.AsStatus()) } if $isobject(tList) { + set platformVersion = $$$CurrentPlatformVersion for i=1:1:tList.Count() { set tMod = tList.GetAt(i) - set qHandle($increment(qHandle)) = $listbuild(tMod.Name,tMod.Version.ToString(),tMod.Repository,tMod.Description, tMod.Origin, tMod.AllVersions) + if 'pShowAllVersions && 'tMod.IsCompatibleWithPlatform(platformVersion) { + continue + } + set qHandle($increment(qHandle)) = $listbuild(tMod.Name,tMod.Version.ToString(),tMod.Repository,tMod.Description, tMod.Origin, tMod.AllVersions, tMod.FormatPlatformVersions()) } } } @@ -1391,7 +1402,9 @@ ClassMethod ConstructInvertedDependencyGraph( quit } // Invert dependency graph, subscripts (, ) - set pInvertedDependencyGraph(moduleName) = $list(data, 2, 3) + // Repository, version, deployed flag and platform version. See BuildDependencyGraph + // for the full node layout. + set pInvertedDependencyGraph(moduleName) = $list(data, 2, 3) _ $list(data, 6, 7) set dependentKey = "" for { set dependentKey = $order(pDependencyGraph(moduleName, dependentKey)) @@ -1425,12 +1438,14 @@ ClassMethod GetFlatDependencyListFromInvertedDependencyGraph(ByRef pInvertedDepe // If it has no remaining dependencies if $data(pInvertedDependencyGraph(moduleName)) < 10 { - set $listbuild(repositoryName, versionString) = $get(pInvertedDependencyGraph(moduleName)) + set $listbuild(repositoryName, versionString, deployed, platformVersion) = $get(pInvertedDependencyGraph(moduleName)) // Create qualified module reference to add to list set moduleReference = ##class(%IPM.Storage.QualifiedModuleInfo).%New() set moduleReference.Name = moduleName set moduleReference.VersionString = versionString set moduleReference.ServerName = repositoryName + set moduleReference.Deployed = deployed + set moduleReference.PlatformVersion = platformVersion $$$ThrowOnError(orderedDependencyList.Insert(moduleReference)) // Remove node from graph kill pInvertedDependencyGraph(moduleName) diff --git a/src/inc/IPM/Common.inc b/src/inc/IPM/Common.inc index 0cbaec169..c185324f3 100644 --- a/src/inc/IPM/Common.inc +++ b/src/inc/IPM/Common.inc @@ -77,6 +77,8 @@ ROUTINE %IPM.Common [Type=INC] #; Separator for package version and platform version in OCI tag , e.g. "1.0.0__2024.1" #Define OrasTagPlatformSeparator "__" #Define OrasRequiresFlexiblePython "Flexible Python Runtime is not configured. You must configure it with Python 3.9+ and reinstall IPM to use ORAS registries." +#; Platform version deployed code is built for and tagged with, e.g. "2024.1" +#Define CurrentPlatformVersion ($System.Version.GetMajor()_"."_$System.Version.GetMinor()) #; Place where Deployed.xml is stored #Define DeployedXMLDir "studio-project" diff --git a/tests/integration_tests/Test/PM/Integration/OrasDeployedPlatformFilter.cls b/tests/integration_tests/Test/PM/Integration/OrasDeployedPlatformFilter.cls new file mode 100644 index 000000000..7de190a37 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/OrasDeployedPlatformFilter.cls @@ -0,0 +1,315 @@ +Include %IPM.Common + +/// Integration tests for deployed module platform filtering against a real ORAS (zot) registry. +Class Test.PM.Integration.OrasDeployedPlatformFilter Extends Test.PM.Integration.Base +{ + +/// Fixture directory under _data holding every module these tests use. The folder +/// parameters below are relative to it. +Parameter FixtureRoot = "platform-filter"; + +Parameter ModuleFolder = "test/v1"; + +/// Same module name as ModuleFolder, but version 2.0.0. +Parameter ModuleFolderV2 = "test/v2"; + +Parameter ModuleName = "platform-filter-test"; + +/// Depends on ModuleName at ">=1.0.0", which both published versions satisfy. +Parameter ConsumerFolder = "consumer/range"; + +Parameter ConsumerName = "platform-filter-consumer"; + +/// Depends on ModuleName at "^2.0.0", which only the incompatible version satisfies. +Parameter PinnedConsumerFolder = "consumer/pinned"; + +Parameter PinnedConsumerName = "platform-filter-consumer-pinned"; + +/// Platform guaranteed not to match any real IRIS instance. +Parameter IncompatiblePlatform = "9999.1"; + +/// Oras registry URL. Uses the docker-compose service name by default. +Parameter OrasURL = "http://oras:5000"; + +/// Point the namespace at the local zot registry once. Every test publishes to it and +/// unpublishes in OnAfterOneTest, so no test needs its own registry setup. +Method OnBeforeAllTests() As %Status +{ + do $$$AssertStatusOK(##class(%IPM.Main).Shell("repo -delete-all"), "Cleared repos") + do $$$AssertStatusOK(##class(%IPM.Main).Shell("repo -name oras -o -url " _ ..#OrasURL _ " -publish 1"), "Configured ORAS registry") + return $$$OK +} + +Method OnAfterAllTests() As %Status +{ + do $$$AssertStatusOK(##class(%IPM.Main).Shell("repo -delete-all"), "Cleared repos") + do $$$AssertStatusOK(##class(%IPM.Main).Shell("repo -reset-defaults"), "Reset repos to defaults") + return $$$OK +} + +Method OnAfterOneTest(testName As %String) As %Status +{ + do ##class(%IPM.Main).Shell("uninstall " _ ..#ConsumerName) + do ##class(%IPM.Main).Shell("uninstall " _ ..#PinnedConsumerName) + do ##class(%IPM.Main).Shell("uninstall " _ ..#ModuleName) + do ##class(%IPM.Main).Shell("unpublish oras/" _ ..#ModuleName _ " all -force") + do ..DeleteTgz(..TgzPath(..#ModuleFolder)) + do ..DeleteTgz(..TgzPath(..#ModuleFolderV2)) + return $$$OK +} + +/// A module published for the current platform is both visible and installable. +Method TestCompatiblePlatformVisibleAndInstallable() +{ + do ..LoadAndPackage() + do $$$AssertStatusOK(##class(%IPM.Main).Shell("publish " _ ..#ModuleName _ " -v -only"), "Published for current platform") + + do $$$AssertTrue(..ModuleVisibleInList(..#ModuleName), "Compatible module is resolvable") + do $$$AssertTrue(..QueryListedModule(..#ModuleName, .platforms), "Compatible module is listed by list-modules") + do $$$AssertEquals(platforms, $$$CurrentPlatformVersion, "list-modules reports the platform it was published for") + + // Uninstall the locally loaded copy first, so the install really comes from the registry + do $$$AssertStatusOK(##class(%IPM.Main).Shell("uninstall " _ ..#ModuleName), "Uninstalled before reinstall") + do $$$AssertStatusOK(##class(%IPM.Main).Shell("install " _ ..#ModuleName), "Install from compatible platform tag succeeded") + do $$$AssertEquals(..InstalledVersion(..#ModuleName), "1.0.0", "Installed 1.0.0 from the registry") +} + +/// A module available only for an incompatible platform is filtered out of repository listings. +Method TestIncompatiblePlatformHiddenFromList() +{ + set tgzPath = ..LoadAndPackage() + do $$$AssertStatusOK(..PublishWithPlatform(tgzPath, ..#IncompatiblePlatform), "Published for incompatible platform") + + do $$$AssertNotTrue(..ModuleVisibleInList(..#ModuleName), "Incompatible module is not resolvable") + do $$$AssertNotTrue(..QueryListedModule(..#ModuleName), "Incompatible module is hidden from list-modules") +} + +/// Installing a module available only for an incompatible platform reports the platforms it +/// is actually available for, rather than a bare "not found". +Method TestIncompatiblePlatformInstallError() +{ + set currentPlatform = $$$CurrentPlatformVersion + + set tgzPath = ..LoadAndPackage() + do $$$AssertStatusOK(..PublishWithPlatform(tgzPath, ..#IncompatiblePlatform), "Published for incompatible platform") + + set installSC = ##class(%IPM.Main).Shell("install " _ ..#ModuleName) + do $$$AssertStatusNotOK(installSC, "Install of incompatible-platform module fails") + + set errText = $system.Status.GetErrorText(installSC) + do $$$AssertTrue(errText [ ..#IncompatiblePlatform, "Error names the available platform ("_..#IncompatiblePlatform_")") + do $$$AssertTrue(errText [ currentPlatform, "Error mentions the current platform ("_currentPlatform_")") +} + +/// With 1.0.0 available for the current platform and 2.0.0 only for an incompatible one, +/// an install with no version constraint must resolve 1.0.0. +Method TestPlatformFilterPicksLowerCompatibleVersion() +{ + do ..PublishBothVersions() + + // Repository results are sorted highest-version-first and Install takes the first + // match, so 2.0.0 would win here if the platform filter did not exclude it. + do $$$AssertStatusOK(##class(%IPM.Main).Shell("install " _ ..#ModuleName), "Installed with no version constraint") + do $$$AssertEquals(..InstalledVersion(..#ModuleName), "1.0.0", "Resolved compatible 1.0.0 rather than incompatible 2.0.0") + // The version reported by the module's own code, so this also proves the 1.0.0 payload + // was installed rather than just the 1.0.0 manifest + do $$$AssertEquals($classmethod("PlatformFilterTest.Public", "Version"), "1.0.0", "1.0.0 sources were installed") +} + +/// A dependent whose range (">=1.0.0") is satisfied by both published versions must get the +/// one that is compatible with this platform. Exercises the dependency resolution path in +/// %IPM.Storage.Module rather than the install path in %IPM.Main. +Method TestDependencyResolvesToCompatibleVersion() +{ + do ..PublishBothVersions() + + set consumerDir = ..FixtureDir(..#ConsumerFolder) + do $$$AssertStatusOK(##class(%IPM.Main).Shell("load -v -nodev " _ consumerDir), "Loaded consumer from " _ consumerDir) + do $$$AssertEquals(..InstalledVersion(..#ModuleName), "1.0.0", "Dependency resolved to compatible 1.0.0") + do $$$AssertEquals($classmethod("PlatformFilterTest.Public", "Version"), "1.0.0", "1.0.0 sources were installed") +} + +/// A dependent pinned to "^2.0.0" can only be satisfied by the incompatible version, +/// so resolution must fail rather than silently falling back to 1.0.0. +Method TestDependencyPinnedToIncompatibleVersionFails() +{ + do ..PublishBothVersions() + + set consumerDir = ..FixtureDir(..#PinnedConsumerFolder) + set loadSC = ##class(%IPM.Main).Shell("load -v -nodev " _ consumerDir) + do $$$AssertStatusNotOK(loadSC, "Load fails when the only satisfying version is platform-incompatible") + // Check the specific message, not just that something failed. An unrelated error + // would otherwise let this test pass without resolution ever being exercised + set errText = $system.Status.GetErrorText(loadSC) + do $$$AssertTrue(errText [ "Could not find satisfactory version", "Failed for lack of a satisfying version") + do $$$AssertTrue(errText [ ..#ModuleName, "Error names the unresolvable dependency") + do $$$AssertEquals(..InstalledVersion(..#ModuleName), "", "Incompatible 2.0.0 was not installed as a fallback") +} + +/// Packaging twice in a row must succeed. The first pass persists Module.Deployed = 1, which the +/// Compile phase of the second pass reads to skip Deploy="true" resources. Those resources used to +/// stay behind in the compile-order map, which reported them as an unresolvable compile order. +Method TestRepackageWithDeployedFlagPersisted() +{ + set tgzPath = ..LoadAndPackage() + + set module = ##class(%IPM.Storage.Module).NameOpen(..#ModuleName,, .sc) + do $$$AssertStatusOK(sc, "Reopened module after packaging") + do $$$AssertTrue(module.Deployed, "Packaging marked the module deployed") + + do $$$AssertStatusOK(##class(%IPM.Main).Shell("package " _ ..#ModuleName _ " -only -v -path " _ tgzPath), "Repackaged with Deployed already set") + + // Skipping compilation is the intent here, not a side effect: the second pass has no sources to + // recompile from, because a bare package does not re-import them. The deployed class from the + // first pass has to survive it. + do $$$AssertEquals($classmethod("PlatformFilterTest.Main", "Hello"), "Hello from PlatformFilterTest.Main", "Deployed class still works after repackaging") +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Absolute path to a fixture directory, given its path relative to FixtureRoot. +/// The relative path is split on "/" so each segment becomes its own path component. +ClassMethod FixtureDir(relativePath As %String) As %String +{ + set segmentCount = $length(relativePath, "/") + set subfolders = 1 + segmentCount + set subfolders(1) = ..#FixtureRoot + for i = 1:1:segmentCount { + set subfolders(i + 1) = $piece(relativePath, "/", i) + } + return ..GetModuleDir(subfolders...) +} + +/// Path a fixture is packaged to, without the .tgz extension. The fixture's relative +/// path is flattened into the filename so every version gets its own tarball directly +/// under the temp directory, and teardown can find it from the folder parameter alone. +ClassMethod TgzPath(moduleFolder As %String) As %String +{ + return $$$FileTempDir _ ..#FixtureRoot _ "-" _ $translate(moduleFolder, "/", "-") +} + +/// Load a module fixture from _data, package it, and return the path to the .tgz without +/// the extension. +Method LoadAndPackage(moduleFolder As %String = "") As %String +{ + if (moduleFolder = "") { + set moduleFolder = ..#ModuleFolder + } + // -nodev is required: the deploy branch of the Reload phase is skipped in developer mode + set folder = ..FixtureDir(moduleFolder) + do $$$AssertStatusOK(##class(%IPM.Main).Shell("load -v -nodev " _ folder), "Loaded module from " _ folder) + + set tgzPath = ..TgzPath(moduleFolder) + do $$$AssertStatusOK(##class(%IPM.Main).Shell("package " _ ..#ModuleName _ " -only -v -path " _ tgzPath), "Packaged module to " _ tgzPath) + return tgzPath +} + +/// Publish platform-filter-test twice: 1.0.0 for the current platform and 2.0.0 for an +/// incompatible one. +Method PublishBothVersions() +{ + // 1.0.0 for the current platform, via the real publish command. The MakeDeployed phase + // stamps SystemRequirements with this IRIS version during packaging; publish tags with it. + do ..LoadAndPackage(..#ModuleFolder) + do $$$AssertStatusOK(##class(%IPM.Main).Shell("publish " _ ..#ModuleName _ " -v -only"), "Published 1.0.0 for current platform") + do $$$AssertStatusOK(##class(%IPM.Main).Shell("uninstall " _ ..#ModuleName), "Uninstalled 1.0.0 before loading 2.0.0") + + // 2.0.0 for an incompatible platform. The publish command can only ever stamp the + // current platform, so this tag has to be pushed directly. + set v2Tgz = ..LoadAndPackage(..#ModuleFolderV2) + do $$$AssertStatusOK(..PublishWithPlatform(v2Tgz, ..#IncompatiblePlatform), "Published 2.0.0 for incompatible platform") + do $$$AssertStatusOK(##class(%IPM.Main).Shell("uninstall " _ ..#ModuleName), "Uninstalled 2.0.0 after publishing") +} + +/// Publish the packaged module with an explicit platform version tag by calling +/// PublishModule directly, bypassing the normal publish shell command which would +/// tag with the current IRIS platform version. +/// +/// The version and manifest come from the module object currently loaded in this +/// namespace, so they match whichever fixture LoadAndPackage was given. +Method PublishWithPlatform( + tgzPath As %String, + platformVersion As %String) As %Status +{ + set sc = $$$OK + try { + set module = ##class(%IPM.Storage.Module).NameOpen(..#ModuleName,, .sc) + $$$ThrowOnError(sc) + + set orasDef = ##class(%IPM.Repo.Oras.Definition).%New() + set orasDef.URL = ..#OrasURL + set publishSvc = orasDef.GetPublishService() + + set modInfo = ##class(%IPM.Repo.Remote.ModuleInfo).%New() + set modInfo.Name = ..#ModuleName + set modInfo.VersionString = module.VersionString + set modInfo.Deployed = 1 + set modInfo.PlatformVersion = platformVersion + + set payloadStream = ##class(%Stream.FileBinary).%New() + set payloadStream.Filename = tgzPath _ ".tgz" + do modInfo.Payload.CopyFrom(payloadStream) + + // Export the real manifest, exactly as the publish command does, so it carries + // the Deployed flag and SystemRequirements that MakeDeployed stamped + set manifestStream = ##class(%Stream.GlobalCharacter).%New() + $$$ThrowOnError(module.XMLExportToStream(manifestStream)) + set modInfo.Manifest = manifestStream + + set sc = publishSvc.PublishModule(modInfo) + } catch ex { + set sc = ex.AsStatus() + } + return sc +} + +/// Returns true if any configured repository offers the named module. Queries the resolution +/// API directly rather than parsing the terminal output of repo -list-modules. +ClassMethod ModuleVisibleInList(moduleName As %String) As %Boolean +{ + set criteria = ##class(%IPM.Repo.SearchCriteria).%New() + set criteria.Name = $$$lcase(moduleName) + set sc = ##class(%IPM.Repo.Utils).SearchRepositoriesForModule(criteria, .results) + if $$$ISERR(sc) || '$isobject(results) { + return 0 + } + return results.Count() > 0 +} + +/// Returns true if the named module is offered by the oras repository according to the query +/// that backs repo -list-modules, setting platformVersions to the platforms it reports. +ClassMethod QueryListedModule( + moduleName As %String, + Output platformVersions As %String) As %Boolean +{ + set platformVersions = "" + set rs = ##class(%SQL.Statement).%ExecDirect(, + "SELECT PlatformVersions FROM %IPM_Utils.Module_GetModuleList(?,?) WHERE Name = ?", + "oras", 0, moduleName) + if 'rs.%Next() { + return 0 + } + set platformVersions = rs.%Get("PlatformVersions") + return 1 +} + +/// Version of the named module as currently installed, or "" if it is not installed. +ClassMethod InstalledVersion(moduleName As %String) As %String +{ + set module = ##class(%IPM.Storage.Module).NameOpen(moduleName,, .sc) + if $$$ISERR(sc) || '$isobject(module) { + return "" + } + return module.VersionString +} + +ClassMethod DeleteTgz(tgzPath As %String) +{ + if ##class(%File).Exists(tgzPath _ ".tgz") { + do ##class(%File).Delete(tgzPath _ ".tgz") + } +} + +} diff --git a/tests/integration_tests/Test/PM/Integration/_data/platform-filter/consumer/pinned/module.xml b/tests/integration_tests/Test/PM/Integration/_data/platform-filter/consumer/pinned/module.xml new file mode 100644 index 000000000..f702e7c6c --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/platform-filter/consumer/pinned/module.xml @@ -0,0 +1,20 @@ + + + + + platform-filter-consumer-pinned + 1.0.0 + module + src + + + + platform-filter-test + + ^2.0.0 + + + + + diff --git a/tests/integration_tests/Test/PM/Integration/_data/platform-filter/consumer/pinned/src/PlatformFilterConsumerPinned/App.cls b/tests/integration_tests/Test/PM/Integration/_data/platform-filter/consumer/pinned/src/PlatformFilterConsumerPinned/App.cls new file mode 100644 index 000000000..e0e11dae8 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/platform-filter/consumer/pinned/src/PlatformFilterConsumerPinned/App.cls @@ -0,0 +1,9 @@ +Class PlatformFilterConsumerPinned.App +{ + +ClassMethod Hello() As %String +{ + return "Hello from PlatformFilterConsumerPinned.App" +} + +} diff --git a/tests/integration_tests/Test/PM/Integration/_data/platform-filter/consumer/range/module.xml b/tests/integration_tests/Test/PM/Integration/_data/platform-filter/consumer/range/module.xml new file mode 100644 index 000000000..dcd17669a --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/platform-filter/consumer/range/module.xml @@ -0,0 +1,21 @@ + + + + + platform-filter-consumer + 1.0.0 + module + src + + + + platform-filter-test + + >=1.0.0 + + + + + diff --git a/tests/integration_tests/Test/PM/Integration/_data/platform-filter/consumer/range/src/PlatformFilterConsumer/App.cls b/tests/integration_tests/Test/PM/Integration/_data/platform-filter/consumer/range/src/PlatformFilterConsumer/App.cls new file mode 100644 index 000000000..6d1b1b4b3 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/platform-filter/consumer/range/src/PlatformFilterConsumer/App.cls @@ -0,0 +1,9 @@ +Class PlatformFilterConsumer.App +{ + +ClassMethod Hello() As %String +{ + return "Hello from PlatformFilterConsumer.App" +} + +} diff --git a/tests/integration_tests/Test/PM/Integration/_data/platform-filter/test/v1/module.xml b/tests/integration_tests/Test/PM/Integration/_data/platform-filter/test/v1/module.xml new file mode 100644 index 000000000..0a088a265 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/platform-filter/test/v1/module.xml @@ -0,0 +1,13 @@ + + + + + platform-filter-test + 1.0.0 + module + src + + + + + diff --git a/tests/integration_tests/Test/PM/Integration/_data/platform-filter/test/v1/src/PlatformFilterTest/Main.cls b/tests/integration_tests/Test/PM/Integration/_data/platform-filter/test/v1/src/PlatformFilterTest/Main.cls new file mode 100644 index 000000000..aba02fdb2 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/platform-filter/test/v1/src/PlatformFilterTest/Main.cls @@ -0,0 +1,12 @@ +/// Deployed resource. Its presence makes the module a deployed module, so packaging +/// records the current IRIS version in SystemRequirements and publishing tags the +/// artifact with a platform version. +Class PlatformFilterTest.Main +{ + +ClassMethod Hello() As %String +{ + return "Hello from PlatformFilterTest.Main" +} + +} diff --git a/tests/integration_tests/Test/PM/Integration/_data/platform-filter/test/v1/src/PlatformFilterTest/Public.cls b/tests/integration_tests/Test/PM/Integration/_data/platform-filter/test/v1/src/PlatformFilterTest/Public.cls new file mode 100644 index 000000000..79876f119 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/platform-filter/test/v1/src/PlatformFilterTest/Public.cls @@ -0,0 +1,12 @@ +/// Non-deployed resource, so the module has both a deployed and a non-deployed +/// Studio project during packaging. Version() reports the module version, so tests +/// can tell which of the two published versions is actually installed. +Class PlatformFilterTest.Public +{ + +ClassMethod Version() As %String +{ + return "1.0.0" +} + +} diff --git a/tests/integration_tests/Test/PM/Integration/_data/platform-filter/test/v2/module.xml b/tests/integration_tests/Test/PM/Integration/_data/platform-filter/test/v2/module.xml new file mode 100644 index 000000000..56aecca18 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/platform-filter/test/v2/module.xml @@ -0,0 +1,13 @@ + + + + + platform-filter-test + 2.0.0 + module + src + + + + + diff --git a/tests/integration_tests/Test/PM/Integration/_data/platform-filter/test/v2/src/PlatformFilterTest/Main.cls b/tests/integration_tests/Test/PM/Integration/_data/platform-filter/test/v2/src/PlatformFilterTest/Main.cls new file mode 100644 index 000000000..aba02fdb2 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/platform-filter/test/v2/src/PlatformFilterTest/Main.cls @@ -0,0 +1,12 @@ +/// Deployed resource. Its presence makes the module a deployed module, so packaging +/// records the current IRIS version in SystemRequirements and publishing tags the +/// artifact with a platform version. +Class PlatformFilterTest.Main +{ + +ClassMethod Hello() As %String +{ + return "Hello from PlatformFilterTest.Main" +} + +} diff --git a/tests/integration_tests/Test/PM/Integration/_data/platform-filter/test/v2/src/PlatformFilterTest/Public.cls b/tests/integration_tests/Test/PM/Integration/_data/platform-filter/test/v2/src/PlatformFilterTest/Public.cls new file mode 100644 index 000000000..9bea49869 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/platform-filter/test/v2/src/PlatformFilterTest/Public.cls @@ -0,0 +1,12 @@ +/// Non-deployed resource, so the module has both a deployed and a non-deployed +/// Studio project during packaging. Version() reports the module version, so tests +/// can tell which of the two published versions is actually installed. +Class PlatformFilterTest.Public +{ + +ClassMethod Version() As %String +{ + return "2.0.0" +} + +} diff --git a/tests/unit_tests/Test/PM/Unit/DeployedModuleFilter.cls b/tests/unit_tests/Test/PM/Unit/DeployedModuleFilter.cls new file mode 100644 index 000000000..3607ca4f4 --- /dev/null +++ b/tests/unit_tests/Test/PM/Unit/DeployedModuleFilter.cls @@ -0,0 +1,114 @@ +/// Unit tests for the platform filtering of deployed modules: the compatibility check on +/// %IPM.Storage.ModuleInfo and the version reordering in %IPM.Repo.Oras.PackageService. +/// Everything is built in memory — no registry or ORAS service needed. +Class Test.PM.Unit.DeployedModuleFilter Extends %UnitTest.TestCase +{ + +/// Deployed module whose PlatformVersions includes the current platform should pass the filter. +Method TestDeployedCompatiblePlatformIncluded() +{ + set mod = ..BuildDeployedMod("2024.1", "2025.1") + do $$$AssertTrue(mod.IsCompatibleWithPlatform("2024.1"), "Compatible platform is included") + do $$$AssertTrue(mod.IsCompatibleWithPlatform("2025.1"), "Second compatible platform is included") +} + +/// Deployed module whose PlatformVersions does not include the current platform should return false. +Method TestDeployedIncompatiblePlatformExcluded() +{ + set mod = ..BuildDeployedMod("2024.1", "2025.1") + do $$$AssertNotTrue(mod.IsCompatibleWithPlatform("2025.2"), "Incompatible platform is excluded") + do $$$AssertNotTrue(mod.IsCompatibleWithPlatform("9999.9"), "Unknown platform is excluded") +} + +/// Non-deployed modules always return true regardless of platform. +Method TestNonDeployedAlwaysCompatible() +{ + set mod = ##class(%IPM.Storage.ModuleInfo).%New() + set mod.Name = "mymodule" + set mod.VersionString = "1.0.0" + set mod.Deployed = 0 + do mod.PlatformVersions.Insert("2024.1") + + do $$$AssertTrue(mod.IsCompatibleWithPlatform("9999.9"), "Non-deployed is always compatible") +} + +/// FormatPlatformVersions returns comma-separated list. +Method TestFormatPlatformVersions() +{ + set mod = ..BuildDeployedMod("2024.1", "2025.1") + do $$$AssertEquals(mod.FormatPlatformVersions(), "2024.1, 2025.1", "Two platforms formatted correctly") +} + +/// FormatPlatformVersions returns empty string for empty list. +Method TestFormatPlatformVersionsEmpty() +{ + set mod = ##class(%IPM.Storage.ModuleInfo).%New() + set mod.Name = "mymodule" + set mod.VersionString = "1.0.0" + do $$$AssertEquals(mod.FormatPlatformVersions(), "", "Empty list returns empty string") +} + +/// The compatible version must be promoted ahead of the newer incompatible one, so a caller +/// taking the first entry installs something that works on this platform. +Method TestPreferCompatibleVersionsPromotesCompatible() +{ + set platforms("2.0.0") = $listbuild("9999.1") + set platforms("1.0.0") = $listbuild("2024.1") + set reordered = ##class(%IPM.Repo.Oras.PackageService).PreferCompatibleVersions($listbuild("2.0.0", "1.0.0"), .platforms, "2024.1") + do $$$AssertEquals($listtostring(reordered), "1.0.0,2.0.0", "Compatible 1.0.0 promoted ahead of incompatible 2.0.0") +} + +/// Within each group the newest-first order the versions arrived in must be preserved. +Method TestPreferCompatibleVersionsKeepsOrderWithinGroups() +{ + set platforms("3.0.0") = $listbuild("2024.1") + set platforms("2.0.0") = $listbuild("9999.1") + set platforms("1.0.0") = $listbuild("2024.1") + set reordered = ##class(%IPM.Repo.Oras.PackageService).PreferCompatibleVersions($listbuild("3.0.0", "2.0.0", "1.0.0"), .platforms, "2024.1") + do $$$AssertEquals($listtostring(reordered), "3.0.0,1.0.0,2.0.0", "Newest-first order kept within each group") +} + +/// A version with no platform suffix is not platform-specific, so it counts as compatible. +Method TestPreferCompatibleVersionsTreatsEmptyPlatformAsCompatible() +{ + set platforms("2.0.0") = $listbuild("9999.1") + set platforms("1.0.0") = $listbuild("") + set reordered = ##class(%IPM.Repo.Oras.PackageService).PreferCompatibleVersions($listbuild("2.0.0", "1.0.0"), .platforms, "2024.1") + do $$$AssertEquals($listtostring(reordered), "1.0.0,2.0.0", "Version without a platform counts as compatible") +} + +/// With nothing compatible the order is unchanged, so the caller still reports the newest +/// version and the platform filter downstream produces the error message. +Method TestPreferCompatibleVersionsAllIncompatible() +{ + set platforms("2.0.0") = $listbuild("9999.1") + set platforms("1.0.0") = $listbuild("9999.2") + set reordered = ##class(%IPM.Repo.Oras.PackageService).PreferCompatibleVersions($listbuild("2.0.0", "1.0.0"), .platforms, "2024.1") + do $$$AssertEquals($listtostring(reordered), "2.0.0,1.0.0", "Order unchanged when nothing is compatible") +} + +/// An empty version list must not error. +Method TestPreferCompatibleVersionsEmptyList() +{ + set reordered = ##class(%IPM.Repo.Oras.PackageService).PreferCompatibleVersions("", .platforms, "2024.1") + do $$$AssertEquals(reordered, "", "Empty list returns empty list") +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Build a deployed ModuleInfo with the given platform version strings. +ClassMethod BuildDeployedMod(platforms...) As %IPM.Storage.ModuleInfo +{ + set mod = ##class(%IPM.Storage.ModuleInfo).%New() + set mod.Name = "mymodule" + set mod.VersionString = "1.0.0" + set mod.Deployed = 1 + for i=1:1:$get(platforms) { + do mod.PlatformVersions.Insert(platforms(i)) + } + return mod +} + +}