From f37b9d2c318d1907c8df7347788182df7b0aa7ba Mon Sep 17 00:00:00 2001 From: isc-dchui Date: Mon, 24 Aug 2026 11:39:26 -0400 Subject: [PATCH 1/7] Add install deployed code platform version filter --- CHANGELOG.md | 2 + src/cls/IPM/Main.cls | 30 +- src/cls/IPM/Repo/Manager.cls | 9 + src/cls/IPM/Repo/Oras/PackageService.cls | 29 +- src/cls/IPM/Repo/Oras/PublishService.cls | 3 + src/cls/IPM/Repo/SearchCriteria.cls | 3 + .../ResourceProcessor/Default/Document.cls | 7 + src/cls/IPM/Storage/Module.cls | 14 +- src/cls/IPM/Storage/ModuleInfo.cls | 25 +- src/cls/IPM/Storage/QualifiedModuleInfo.cls | 1 + src/cls/IPM/Utils/Module.cls | 16 +- .../OrasDeployedPlatformFilter.cls | 267 ++++++++++++++++++ .../PlatformFilterConsumerPinned/App.cls | 9 + .../module.xml | 19 ++ .../PlatformFilterConsumer/App.cls | 9 + .../_data/platform-filter-consumer/module.xml | 20 ++ .../PlatformFilterTest/Main.cls | 12 + .../PlatformFilterTest/Public.cls | 12 + .../_data/platform-filter-test-v2/module.xml | 12 + .../PlatformFilterTest/Main.cls | 12 + .../PlatformFilterTest/Public.cls | 12 + .../_data/platform-filter-test/module.xml | 12 + .../Test/PM/Unit/DeployedModuleFilter.cls | 79 ++++++ 23 files changed, 589 insertions(+), 25 deletions(-) create mode 100644 tests/integration_tests/Test/PM/Integration/OrasDeployedPlatformFilter.cls create mode 100644 tests/integration_tests/Test/PM/Integration/_data/platform-filter-consumer-pinned/PlatformFilterConsumerPinned/App.cls create mode 100644 tests/integration_tests/Test/PM/Integration/_data/platform-filter-consumer-pinned/module.xml create mode 100644 tests/integration_tests/Test/PM/Integration/_data/platform-filter-consumer/PlatformFilterConsumer/App.cls create mode 100644 tests/integration_tests/Test/PM/Integration/_data/platform-filter-consumer/module.xml create mode 100644 tests/integration_tests/Test/PM/Integration/_data/platform-filter-test-v2/PlatformFilterTest/Main.cls create mode 100644 tests/integration_tests/Test/PM/Integration/_data/platform-filter-test-v2/PlatformFilterTest/Public.cls create mode 100644 tests/integration_tests/Test/PM/Integration/_data/platform-filter-test-v2/module.xml create mode 100644 tests/integration_tests/Test/PM/Integration/_data/platform-filter-test/PlatformFilterTest/Main.cls create mode 100644 tests/integration_tests/Test/PM/Integration/_data/platform-filter-test/PlatformFilterTest/Public.cls create mode 100644 tests/integration_tests/Test/PM/Integration/_data/platform-filter-test/module.xml create mode 100644 tests/unit_tests/Test/PM/Unit/DeployedModuleFilter.cls diff --git a/CHANGELOG.md b/CHANGELOG.md index 213665c64..d7d4f7619 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ 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 @@ -17,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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/Main.cls b/src/cls/IPM/Main.cls index 13afceb39..afa805a42 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,Deployed,PlatformVersions FROM %IPM_Utils.Module_GetModuleList(?,?) "_where if (pSearchString = "") { set tRes = ##class(%SQL.Statement).%ExecDirect(, tQuery, pRepoName, pShowAllVersions) } else { @@ -2279,6 +2279,9 @@ ClassMethod ShowModulesForRepository( set list = list + 1 set list(list) = $listbuild(name, tRes.Version) set list(list, "Origin") = tRes.Origin + if tRes.Deployed && (tRes.PlatformVersions '= "") { + set list(list, "PlatformVersions") = tRes.PlatformVersions + } if pShowDesc { set list(list, "Description") = tRes.Description } @@ -2586,19 +2589,29 @@ 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 { + // Zero results may mean the module was filtered out for being deployed for other + // platforms only, which deserves a better message than "not found". Search again + // without the filter to tell the two cases apart. + set diagCriteria = ##class(%IPM.Repo.SearchCriteria).%New() + set diagCriteria.Name = $$$lcase(tModuleName) + set diagCriteria.VersionExpression = tVersion + set diagCriteria.Registry = tRegistry + set diagCriteria.IgnorePlatformFilter = 1 + set diagSC = ##class(%IPM.Repo.Utils).SearchRepositoriesForModule(diagCriteria, .diagResults) + if $$$ISOK(diagSC) && $isobject(diagResults) && (diagResults.Count() > 0) { + set diagResult = diagResults.GetAt(1) + if diagResult.Deployed && (diagResult.PlatformVersions.Count() > 0) { + set platformVersion = $system.Version.GetMajor() _ "." _ $system.Version.GetMinor() + set msg = "'" _ tModuleName _ "' " _ diagResult.VersionString _ " is not available for platform " _ platformVersion _ ". Available platforms: " _ diagResult.FormatPlatformVersions() _ ". Run 'search " _ tModuleName _ " -versions' to see all versions." + $$$ThrowStatus($$$ERROR($$$GeneralError, msg)) + } + } 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..37876b5cd 100644 --- a/src/cls/IPM/Repo/Manager.cls +++ b/src/cls/IPM/Repo/Manager.cls @@ -66,6 +66,7 @@ Method SearchRepositoriesForModule( set tSC = $$$ERROR($$$SQLCode,tRes.%SQLCODE,tRes.%Message) quit } + set currentPlatform = $system.Version.GetMajor() _ "." _ $system.Version.GetMinor() while (tRes.%Next(.tSC)) { if $$$ISERR(tSC) { quit @@ -101,6 +102,14 @@ Method SearchRepositoriesForModule( set tServerResults = tClient.ListModules(tClientCriteria) for i=1:1:tServerResults.Count() { set item = tServerResults.GetAt(i) + if 'pSearchCriteria.IgnorePlatformFilter && 'item.IsCompatibleWithPlatform(currentPlatform) { + continue + } + #; Record which platform's artifact this result refers to; callers need it to build + #; the tag. Skipped when the list is empty, as for repositories without platforms. + if item.Deployed && (item.PlatformVersion = "") && (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..82b6a31a2 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,28 @@ 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)) + do 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 { + set tag = tag _ $$$OrasTagPlatformSeparator _ moduleReference.PlatformVersion + } + return tag } Method HasModule(pModuleReference As %IPM.Storage.ModuleInfo) As %Boolean 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/SearchCriteria.cls b/src/cls/IPM/Repo/SearchCriteria.cls index 487b95504..6e1955fed 100644 --- a/src/cls/IPM/Repo/SearchCriteria.cls +++ b/src/cls/IPM/Repo/SearchCriteria.cls @@ -25,6 +25,9 @@ Property Keywords As list Of %String; /// empty: resolving dependencies Property AllVersions As %Boolean; +/// When true, do not filter deployed modules by the current IRIS platform version +Property IgnorePlatformFilter As %Boolean; + Method KeywordsSet(%value) As %Status { if $isobject(%value) { diff --git a/src/cls/IPM/ResourceProcessor/Default/Document.cls b/src/cls/IPM/ResourceProcessor/Default/Document.cls index 97d2a3363..743714021 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 for + // Deploy=true resources. Persisted because the * phase reopens + // the module from the database before Compile runs. + 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..b12bffe9f 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. +/// Serializable representation of a module available in a repository, including platform compatibility information for deployed modules. +/// Can be embedded in other persistent objects. Class %IPM.Storage.ModuleInfo Extends (%SerialObject, %IPM.General.ModuleInfo) [ StorageStrategy = "" ] { @@ -6,6 +7,28 @@ 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 and deployed modules with no platform list are always compatible. +/// Deployed modules with a platform list are compatible only if platformVersion appears in the list. +Method IsCompatibleWithPlatform(platformVersion As %String) As %Boolean +{ + if '..Deployed || (..PlatformVersions.Count() = 0) { + return 1 + } + return ..PlatformVersions.Find(platformVersion) > 0 +} + +/// Returns PlatformVersions as a comma-separated string, e.g. "2024.1, 2025.1". +/// Returns "" when the list is empty. +Method FormatPlatformVersions() As %String +{ + set result = "" + for i=1:1:..PlatformVersions.Count() { + set result = result _ $select(i > 1: ", ", 1: "") _ ..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..c49976b34 100644 --- a/src/cls/IPM/Utils/Module.cls +++ b/src/cls/IPM/Utils/Module.cls @@ -870,7 +870,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,Deployed:%Boolean,PlatformVersions:%String") [ SqlProc ] { } @@ -903,9 +903,13 @@ ClassMethod GetModuleListExecute( write !, "Skipping repo '" _ pServer _ "': " _ $system.Status.GetErrorText(e.AsStatus()) } if $isobject(tList) { + set platformVersion = $system.Version.GetMajor() _ "." _ $system.Version.GetMinor() 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.Deployed, tMod.FormatPlatformVersions()) } } } @@ -1391,7 +1395,9 @@ ClassMethod ConstructInvertedDependencyGraph( quit } // Invert dependency graph, subscripts (, ) - set pInvertedDependencyGraph(moduleName) = $list(data, 2, 3) + #; Repository and version, then the deployed flag and platform version, which + #; the installer needs to locate the artifact built for this platform + set pInvertedDependencyGraph(moduleName) = $list(data, 2, 3) _ $list(data, 6, 7) set dependentKey = "" for { set dependentKey = $order(pDependencyGraph(moduleName, dependentKey)) @@ -1425,12 +1431,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/tests/integration_tests/Test/PM/Integration/OrasDeployedPlatformFilter.cls b/tests/integration_tests/Test/PM/Integration/OrasDeployedPlatformFilter.cls new file mode 100644 index 000000000..ec229de89 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/OrasDeployedPlatformFilter.cls @@ -0,0 +1,267 @@ +Include %IPM.Common + +/// Integration tests for deployed module platform filtering against a real ORAS (zot) registry. +/// Requires the zot registry service to be running at the URL in the OrasURL parameter. +/// +/// Tests: +/// 1. A module published for the current platform is visible in list-modules and installs successfully. +/// 2. A module published only for an incompatible platform (9999.1) is hidden from list-modules. +/// 3. Installing an incompatible-platform-only module gives a helpful error naming the available platforms. +/// 4. With 1.0.0 published for the current platform and 2.0.0 for an incompatible one, an +/// unconstrained install resolves 1.0.0 rather than the newer 2.0.0. +/// 5. A dependent whose version range spans both published versions resolves the compatible one. +/// 6. A dependent pinned to the incompatible version fails to resolve. +Class Test.PM.Integration.OrasDeployedPlatformFilter Extends Test.PM.Integration.Base +{ + +Parameter ModuleFolder = "platform-filter-test"; + +/// Same module name as ModuleFolder, but version 2.0.0. +Parameter ModuleFolderV2 = "platform-filter-test-v2"; + +Parameter ModuleName = "platform-filter-test"; + +/// Depends on ModuleName at ">=1.0.0", which both published versions satisfy. +Parameter ConsumerFolder = "platform-filter-consumer"; + +Parameter ConsumerName = "platform-filter-consumer"; + +/// Depends on ModuleName at "^2.0.0", which only the incompatible version satisfies. +Parameter PinnedConsumerFolder = "platform-filter-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"; + +/// Teardown runs here rather than at the end of each test so that it still happens when a +/// test errors out partway through. Leftover published tags are the dangerous kind of +/// leakage: a stale current-platform tag would make the "hidden from list" tests fail. +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) + // Must run before the registry is reset, while the oras repo still exists + do ##class(%IPM.Main).Shell("unpublish oras/" _ ..#ModuleName _ " all -force") + do ..DeleteTgz($$$FileTempDir _ ..#ModuleFolder) + do ..DeleteTgz($$$FileTempDir _ ..#ModuleFolderV2) + do ..RestoreDefaultRegistry() + return $$$OK +} + +/// A module published for the current platform is both visible and installable. +Method TestCompatiblePlatformVisibleAndInstallable() +{ + do ..SetupOrasRegistry() + do ..LoadAndPackage() + do $$$AssertStatusOK(##class(%IPM.Main).Shell("publish " _ ..#ModuleName _ " -v -only"), "Published for current platform") + + do $$$AssertTrue(..ModuleVisibleInList(..#ModuleName), "Compatible module is visible in list-modules") + + // 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() +{ + do ..SetupOrasRegistry() + set tgzPath = ..LoadAndPackage() + do $$$AssertStatusOK(..PublishWithPlatform(tgzPath, ..#IncompatiblePlatform), "Published for incompatible platform") + + do $$$AssertNotTrue(..ModuleVisibleInList(..#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 = $system.Version.GetMajor() _ "." _ $system.Version.GetMinor() + + do ..SetupOrasRegistry() + 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") +} + +/// 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() + + // PublishBothVersions leaves platform-filter-test uninstalled. That matters: an + // installed 1.0.0 would already satisfy ">=1.0.0" and the registry would never be searched. + set consumerDir = ..GetModuleDir(..#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") +} + +/// 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 = ..GetModuleDir(..#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") +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Load a module fixture from _data, package it, and return the path to the .tgz without +/// the extension. The .tgz is named after the fixture folder so teardown can find it. +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 = ..GetModuleDir(moduleFolder) + do $$$AssertStatusOK(##class(%IPM.Main).Shell("load -v -nodev " _ folder), "Loaded module from " _ folder) + + set tgzPath = $$$FileTempDir _ 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. Leaves the module uninstalled so that later resolution has to go +/// to the registry, and leaves the oras registry configured. +Method PublishBothVersions() +{ + do ..SetupOrasRegistry() + + // 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") +} + +/// Point the current namespace at the local zot registry, ready for publishing. +Method SetupOrasRegistry() +{ + 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") +} + +/// 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 the named module appears in repo -list-modules for oras. +/// Queries the search criteria API directly rather than parsing terminal output. +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 +} + +/// 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") + } +} + +Method RestoreDefaultRegistry() +{ + do $$$AssertStatusOK(##class(%IPM.Main).Shell("repo -delete-all"), "Cleared repos") + do $$$AssertStatusOK(##class(%IPM.Main).Shell("repo -reset-defaults"), "Reset repos to defaults") +} + +} diff --git a/tests/integration_tests/Test/PM/Integration/_data/platform-filter-consumer-pinned/PlatformFilterConsumerPinned/App.cls b/tests/integration_tests/Test/PM/Integration/_data/platform-filter-consumer-pinned/PlatformFilterConsumerPinned/App.cls new file mode 100644 index 000000000..e0e11dae8 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/platform-filter-consumer-pinned/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-pinned/module.xml b/tests/integration_tests/Test/PM/Integration/_data/platform-filter-consumer-pinned/module.xml new file mode 100644 index 000000000..2b581f0e0 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/platform-filter-consumer-pinned/module.xml @@ -0,0 +1,19 @@ + + + + + platform-filter-consumer-pinned + 1.0.0 + module + + + + platform-filter-test + + ^2.0.0 + + + + + diff --git a/tests/integration_tests/Test/PM/Integration/_data/platform-filter-consumer/PlatformFilterConsumer/App.cls b/tests/integration_tests/Test/PM/Integration/_data/platform-filter-consumer/PlatformFilterConsumer/App.cls new file mode 100644 index 000000000..6d1b1b4b3 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/platform-filter-consumer/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-consumer/module.xml b/tests/integration_tests/Test/PM/Integration/_data/platform-filter-consumer/module.xml new file mode 100644 index 000000000..886651f48 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/platform-filter-consumer/module.xml @@ -0,0 +1,20 @@ + + + + + platform-filter-consumer + 1.0.0 + module + + + + platform-filter-test + + >=1.0.0 + + + + + diff --git a/tests/integration_tests/Test/PM/Integration/_data/platform-filter-test-v2/PlatformFilterTest/Main.cls b/tests/integration_tests/Test/PM/Integration/_data/platform-filter-test-v2/PlatformFilterTest/Main.cls new file mode 100644 index 000000000..aba02fdb2 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/platform-filter-test-v2/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/PlatformFilterTest/Public.cls b/tests/integration_tests/Test/PM/Integration/_data/platform-filter-test-v2/PlatformFilterTest/Public.cls new file mode 100644 index 000000000..9bea49869 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/platform-filter-test-v2/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/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..caf8408bc --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/platform-filter-test-v2/module.xml @@ -0,0 +1,12 @@ + + + + + platform-filter-test + 2.0.0 + module + + + + + diff --git a/tests/integration_tests/Test/PM/Integration/_data/platform-filter-test/PlatformFilterTest/Main.cls b/tests/integration_tests/Test/PM/Integration/_data/platform-filter-test/PlatformFilterTest/Main.cls new file mode 100644 index 000000000..aba02fdb2 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/platform-filter-test/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/PlatformFilterTest/Public.cls b/tests/integration_tests/Test/PM/Integration/_data/platform-filter-test/PlatformFilterTest/Public.cls new file mode 100644 index 000000000..79876f119 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/platform-filter-test/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/module.xml b/tests/integration_tests/Test/PM/Integration/_data/platform-filter-test/module.xml new file mode 100644 index 000000000..838097061 --- /dev/null +++ b/tests/integration_tests/Test/PM/Integration/_data/platform-filter-test/module.xml @@ -0,0 +1,12 @@ + + + + + platform-filter-test + 1.0.0 + module + + + + + 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..192bd7345 --- /dev/null +++ b/tests/unit_tests/Test/PM/Unit/DeployedModuleFilter.cls @@ -0,0 +1,79 @@ +/// Unit tests for %IPM.Storage.ModuleInfo.IsCompatibleWithPlatform. +/// Constructs ModuleInfo objects directly — no running IRIS 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") +} + +/// Deployed module with empty PlatformVersions (e.g. filesystem repos) always returns true. +Method TestDeployedNoPlatformVersionsAlwaysCompatible() +{ + set mod = ##class(%IPM.Storage.ModuleInfo).%New() + set mod.Name = "mymodule" + set mod.VersionString = "1.0.0" + set mod.Deployed = 1 + + do $$$AssertTrue(mod.IsCompatibleWithPlatform("9999.9"), "Deployed with no PlatformVersions 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") +} + +// --------------------------------------------------------------------------- +// 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 +} + +} From 7e0ce91a2c3740d2c4b835e3ee2b18f4019a8dfe Mon Sep 17 00:00:00 2001 From: isc-dchui Date: Mon, 24 Aug 2026 15:00:08 -0400 Subject: [PATCH 2/7] Improve tests and search command display --- src/cls/IPM/Repo/Oras/PackageService.cls | 34 +++++- .../OrasDeployedPlatformFilter.cls | 105 +++++++++--------- .../consumer/pinned}/module.xml | 3 +- .../src}/PlatformFilterConsumerPinned/App.cls | 0 .../consumer/range}/module.xml | 3 +- .../range/src}/PlatformFilterConsumer/App.cls | 0 .../test/v1}/module.xml | 3 +- .../test/v1/src}/PlatformFilterTest/Main.cls | 0 .../v1/src}/PlatformFilterTest/Public.cls | 0 .../test/v2}/module.xml | 3 +- .../test/v2/src}/PlatformFilterTest/Main.cls | 0 .../v2/src}/PlatformFilterTest/Public.cls | 0 12 files changed, 95 insertions(+), 56 deletions(-) rename tests/integration_tests/Test/PM/Integration/_data/{platform-filter-consumer-pinned => platform-filter/consumer/pinned}/module.xml (89%) rename tests/integration_tests/Test/PM/Integration/_data/{platform-filter-consumer-pinned => platform-filter/consumer/pinned/src}/PlatformFilterConsumerPinned/App.cls (100%) rename tests/integration_tests/Test/PM/Integration/_data/{platform-filter-consumer => platform-filter/consumer/range}/module.xml (90%) rename tests/integration_tests/Test/PM/Integration/_data/{platform-filter-consumer => platform-filter/consumer/range/src}/PlatformFilterConsumer/App.cls (100%) rename tests/integration_tests/Test/PM/Integration/_data/{platform-filter-test => platform-filter/test/v1}/module.xml (82%) rename tests/integration_tests/Test/PM/Integration/_data/{platform-filter-test-v2 => platform-filter/test/v1/src}/PlatformFilterTest/Main.cls (100%) rename tests/integration_tests/Test/PM/Integration/_data/{platform-filter-test => platform-filter/test/v1/src}/PlatformFilterTest/Public.cls (100%) rename tests/integration_tests/Test/PM/Integration/_data/{platform-filter-test-v2 => platform-filter/test/v2}/module.xml (82%) rename tests/integration_tests/Test/PM/Integration/_data/{platform-filter-test => platform-filter/test/v2/src}/PlatformFilterTest/Main.cls (100%) rename tests/integration_tests/Test/PM/Integration/_data/{platform-filter-test-v2 => platform-filter/test/v2/src}/PlatformFilterTest/Public.cls (100%) diff --git a/src/cls/IPM/Repo/Oras/PackageService.cls b/src/cls/IPM/Repo/Oras/PackageService.cls index 82b6a31a2..17c6ed3d0 100644 --- a/src/cls/IPM/Repo/Oras/PackageService.cls +++ b/src/cls/IPM/Repo/Oras/PackageService.cls @@ -230,6 +230,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, and falls 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( @@ -241,6 +264,13 @@ Method ListModulesFromTagString( { set allTagsString = ..GetAllTags(..Location, name, "", client) set allVersionsList = ..AggregatePlatformVersions($listfromstring(allTagsString, ", "), .aggregatedPlatformVersion) + // AllVersions is a tri-state; only the empty state reports every version, and it filters by + // platform later, so leave its order alone. The other two report a single version, which + // should be one this platform can actually install. + if (searchCriteria.AllVersions '= "") && 'searchCriteria.IgnorePlatformFilter { + set currentPlatform = $system.Version.GetMajor() _ "." _ $system.Version.GetMinor() + set allVersionsList = ..PreferCompatibleVersions(allVersionsList, .aggregatedPlatformVersion, currentPlatform) + } set pointer = 0 while $listnext(allVersionsList,pointer,moduleVersion) { // filter by version @@ -317,7 +347,9 @@ Method ListModulesFromTagString( set tModRef.Repository = artifactMetadata.ImageSource set tModRef.Description = artifactMetadata.ImageDescription set tModRef.Deployed = artifactMetadata.IPMDeployed - if (artifactMetadata.IPMDeployed '= "") { + // These platforms belong to this one version, so they would contradict AllVersions below, + // which lists every version's tag with its own platform suffix. + if (artifactMetadata.IPMDeployed '= "") && 'searchCriteria.AllVersions { set pvPtr = 0 while $listnext(platformVersions, pvPtr, pv) { $$$ThrowOnError(tModRef.PlatformVersions.Insert(pv)) diff --git a/tests/integration_tests/Test/PM/Integration/OrasDeployedPlatformFilter.cls b/tests/integration_tests/Test/PM/Integration/OrasDeployedPlatformFilter.cls index ec229de89..57e3bad66 100644 --- a/tests/integration_tests/Test/PM/Integration/OrasDeployedPlatformFilter.cls +++ b/tests/integration_tests/Test/PM/Integration/OrasDeployedPlatformFilter.cls @@ -1,33 +1,25 @@ -Include %IPM.Common - /// Integration tests for deployed module platform filtering against a real ORAS (zot) registry. -/// Requires the zot registry service to be running at the URL in the OrasURL parameter. -/// -/// Tests: -/// 1. A module published for the current platform is visible in list-modules and installs successfully. -/// 2. A module published only for an incompatible platform (9999.1) is hidden from list-modules. -/// 3. Installing an incompatible-platform-only module gives a helpful error naming the available platforms. -/// 4. With 1.0.0 published for the current platform and 2.0.0 for an incompatible one, an -/// unconstrained install resolves 1.0.0 rather than the newer 2.0.0. -/// 5. A dependent whose version range spans both published versions resolves the compatible one. -/// 6. A dependent pinned to the incompatible version fails to resolve. Class Test.PM.Integration.OrasDeployedPlatformFilter Extends Test.PM.Integration.Base { -Parameter ModuleFolder = "platform-filter-test"; +/// 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 = "platform-filter-test-v2"; +Parameter ModuleFolderV2 = "test/v2"; Parameter ModuleName = "platform-filter-test"; /// Depends on ModuleName at ">=1.0.0", which both published versions satisfy. -Parameter ConsumerFolder = "platform-filter-consumer"; +Parameter ConsumerFolder = "consumer/range"; Parameter ConsumerName = "platform-filter-consumer"; /// Depends on ModuleName at "^2.0.0", which only the incompatible version satisfies. -Parameter PinnedConsumerFolder = "platform-filter-consumer-pinned"; +Parameter PinnedConsumerFolder = "consumer/pinned"; Parameter PinnedConsumerName = "platform-filter-consumer-pinned"; @@ -37,26 +29,36 @@ Parameter IncompatiblePlatform = "9999.1"; /// Oras registry URL. Uses the docker-compose service name by default. Parameter OrasURL = "http://oras:5000"; -/// Teardown runs here rather than at the end of each test so that it still happens when a -/// test errors out partway through. Leftover published tags are the dangerous kind of -/// leakage: a stale current-platform tag would make the "hidden from list" tests fail. +/// 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) - // Must run before the registry is reset, while the oras repo still exists do ##class(%IPM.Main).Shell("unpublish oras/" _ ..#ModuleName _ " all -force") - do ..DeleteTgz($$$FileTempDir _ ..#ModuleFolder) - do ..DeleteTgz($$$FileTempDir _ ..#ModuleFolderV2) - do ..RestoreDefaultRegistry() + 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 ..SetupOrasRegistry() do ..LoadAndPackage() do $$$AssertStatusOK(##class(%IPM.Main).Shell("publish " _ ..#ModuleName _ " -v -only"), "Published for current platform") @@ -71,7 +73,6 @@ Method TestCompatiblePlatformVisibleAndInstallable() /// A module available only for an incompatible platform is filtered out of repository listings. Method TestIncompatiblePlatformHiddenFromList() { - do ..SetupOrasRegistry() set tgzPath = ..LoadAndPackage() do $$$AssertStatusOK(..PublishWithPlatform(tgzPath, ..#IncompatiblePlatform), "Published for incompatible platform") @@ -84,7 +85,6 @@ Method TestIncompatiblePlatformInstallError() { set currentPlatform = $system.Version.GetMajor() _ "." _ $system.Version.GetMinor() - do ..SetupOrasRegistry() set tgzPath = ..LoadAndPackage() do $$$AssertStatusOK(..PublishWithPlatform(tgzPath, ..#IncompatiblePlatform), "Published for incompatible platform") @@ -115,9 +115,7 @@ Method TestDependencyResolvesToCompatibleVersion() { do ..PublishBothVersions() - // PublishBothVersions leaves platform-filter-test uninstalled. That matters: an - // installed 1.0.0 would already satisfy ">=1.0.0" and the registry would never be searched. - set consumerDir = ..GetModuleDir(..#ConsumerFolder) + 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") } @@ -128,10 +126,10 @@ Method TestDependencyPinnedToIncompatibleVersionFails() { do ..PublishBothVersions() - set consumerDir = ..GetModuleDir(..#PinnedConsumerFolder) + 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 + // 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") @@ -143,29 +141,47 @@ Method TestDependencyPinnedToIncompatibleVersionFails() // 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. The .tgz is named after the fixture folder so teardown can find it. +/// 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 = ..GetModuleDir(moduleFolder) + set folder = ..FixtureDir(moduleFolder) do $$$AssertStatusOK(##class(%IPM.Main).Shell("load -v -nodev " _ folder), "Loaded module from " _ folder) - set tgzPath = $$$FileTempDir _ moduleFolder + 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. Leaves the module uninstalled so that later resolution has to go -/// to the registry, and leaves the oras registry configured. +/// incompatible one. Method PublishBothVersions() { - do ..SetupOrasRegistry() - // 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) @@ -179,13 +195,6 @@ Method PublishBothVersions() do $$$AssertStatusOK(##class(%IPM.Main).Shell("uninstall " _ ..#ModuleName), "Uninstalled 2.0.0 after publishing") } -/// Point the current namespace at the local zot registry, ready for publishing. -Method SetupOrasRegistry() -{ - 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") -} - /// 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. @@ -258,10 +267,4 @@ ClassMethod DeleteTgz(tgzPath As %String) } } -Method RestoreDefaultRegistry() -{ - do $$$AssertStatusOK(##class(%IPM.Main).Shell("repo -delete-all"), "Cleared repos") - do $$$AssertStatusOK(##class(%IPM.Main).Shell("repo -reset-defaults"), "Reset repos to defaults") -} - } 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 similarity index 89% rename from tests/integration_tests/Test/PM/Integration/_data/platform-filter-consumer-pinned/module.xml rename to tests/integration_tests/Test/PM/Integration/_data/platform-filter/consumer/pinned/module.xml index 2b581f0e0..f702e7c6c 100644 --- 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 @@ -1,10 +1,11 @@ - + platform-filter-consumer-pinned 1.0.0 module + src diff --git a/tests/integration_tests/Test/PM/Integration/_data/platform-filter-consumer-pinned/PlatformFilterConsumerPinned/App.cls b/tests/integration_tests/Test/PM/Integration/_data/platform-filter/consumer/pinned/src/PlatformFilterConsumerPinned/App.cls similarity index 100% rename from tests/integration_tests/Test/PM/Integration/_data/platform-filter-consumer-pinned/PlatformFilterConsumerPinned/App.cls rename to tests/integration_tests/Test/PM/Integration/_data/platform-filter/consumer/pinned/src/PlatformFilterConsumerPinned/App.cls diff --git a/tests/integration_tests/Test/PM/Integration/_data/platform-filter-consumer/module.xml b/tests/integration_tests/Test/PM/Integration/_data/platform-filter/consumer/range/module.xml similarity index 90% rename from tests/integration_tests/Test/PM/Integration/_data/platform-filter-consumer/module.xml rename to tests/integration_tests/Test/PM/Integration/_data/platform-filter/consumer/range/module.xml index 886651f48..dcd17669a 100644 --- a/tests/integration_tests/Test/PM/Integration/_data/platform-filter-consumer/module.xml +++ b/tests/integration_tests/Test/PM/Integration/_data/platform-filter/consumer/range/module.xml @@ -1,10 +1,11 @@ - + platform-filter-consumer 1.0.0 module + src diff --git a/tests/integration_tests/Test/PM/Integration/_data/platform-filter-consumer/PlatformFilterConsumer/App.cls b/tests/integration_tests/Test/PM/Integration/_data/platform-filter/consumer/range/src/PlatformFilterConsumer/App.cls similarity index 100% rename from tests/integration_tests/Test/PM/Integration/_data/platform-filter-consumer/PlatformFilterConsumer/App.cls rename to tests/integration_tests/Test/PM/Integration/_data/platform-filter/consumer/range/src/PlatformFilterConsumer/App.cls diff --git a/tests/integration_tests/Test/PM/Integration/_data/platform-filter-test/module.xml b/tests/integration_tests/Test/PM/Integration/_data/platform-filter/test/v1/module.xml similarity index 82% rename from tests/integration_tests/Test/PM/Integration/_data/platform-filter-test/module.xml rename to tests/integration_tests/Test/PM/Integration/_data/platform-filter/test/v1/module.xml index 838097061..0a088a265 100644 --- a/tests/integration_tests/Test/PM/Integration/_data/platform-filter-test/module.xml +++ b/tests/integration_tests/Test/PM/Integration/_data/platform-filter/test/v1/module.xml @@ -1,10 +1,11 @@ - + platform-filter-test 1.0.0 module + src diff --git a/tests/integration_tests/Test/PM/Integration/_data/platform-filter-test-v2/PlatformFilterTest/Main.cls b/tests/integration_tests/Test/PM/Integration/_data/platform-filter/test/v1/src/PlatformFilterTest/Main.cls similarity index 100% rename from tests/integration_tests/Test/PM/Integration/_data/platform-filter-test-v2/PlatformFilterTest/Main.cls rename to tests/integration_tests/Test/PM/Integration/_data/platform-filter/test/v1/src/PlatformFilterTest/Main.cls diff --git a/tests/integration_tests/Test/PM/Integration/_data/platform-filter-test/PlatformFilterTest/Public.cls b/tests/integration_tests/Test/PM/Integration/_data/platform-filter/test/v1/src/PlatformFilterTest/Public.cls similarity index 100% rename from tests/integration_tests/Test/PM/Integration/_data/platform-filter-test/PlatformFilterTest/Public.cls rename to tests/integration_tests/Test/PM/Integration/_data/platform-filter/test/v1/src/PlatformFilterTest/Public.cls 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 similarity index 82% rename from tests/integration_tests/Test/PM/Integration/_data/platform-filter-test-v2/module.xml rename to tests/integration_tests/Test/PM/Integration/_data/platform-filter/test/v2/module.xml index caf8408bc..56aecca18 100644 --- 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 @@ -1,10 +1,11 @@ - + platform-filter-test 2.0.0 module + src diff --git a/tests/integration_tests/Test/PM/Integration/_data/platform-filter-test/PlatformFilterTest/Main.cls b/tests/integration_tests/Test/PM/Integration/_data/platform-filter/test/v2/src/PlatformFilterTest/Main.cls similarity index 100% rename from tests/integration_tests/Test/PM/Integration/_data/platform-filter-test/PlatformFilterTest/Main.cls rename to tests/integration_tests/Test/PM/Integration/_data/platform-filter/test/v2/src/PlatformFilterTest/Main.cls diff --git a/tests/integration_tests/Test/PM/Integration/_data/platform-filter-test-v2/PlatformFilterTest/Public.cls b/tests/integration_tests/Test/PM/Integration/_data/platform-filter/test/v2/src/PlatformFilterTest/Public.cls similarity index 100% rename from tests/integration_tests/Test/PM/Integration/_data/platform-filter-test-v2/PlatformFilterTest/Public.cls rename to tests/integration_tests/Test/PM/Integration/_data/platform-filter/test/v2/src/PlatformFilterTest/Public.cls From 7cd72e0f64112d8248f6eef5b6e197e1ff577f6d Mon Sep 17 00:00:00 2001 From: isc-dchui Date: Tue, 25 Aug 2026 10:45:46 -0400 Subject: [PATCH 3/7] Fix minor issues and refactor --- src/cls/IPM/Lifecycle/Base.cls | 7 +-- src/cls/IPM/Main.cls | 29 ++++----- src/cls/IPM/Repo/Manager.cls | 9 ++- src/cls/IPM/Repo/Oras/PackageService.cls | 25 ++++---- src/cls/IPM/Repo/Remote/PackageService.cls | 2 +- .../ResourceProcessor/Default/Document.cls | 6 +- src/cls/IPM/Storage/ModuleInfo.cls | 26 ++++---- src/cls/IPM/Utils/Module.cls | 10 ++-- src/inc/IPM/Common.inc | 2 + .../OrasDeployedPlatformFilter.cls | 34 +++++++++-- .../Test/PM/Unit/DeployedModuleFilter.cls | 60 ++++++++++++++++++- 11 files changed, 150 insertions(+), 60 deletions(-) diff --git a/src/cls/IPM/Lifecycle/Base.cls b/src/cls/IPM/Lifecycle/Base.cls index f5847b62f..66414c39b 100644 --- a/src/cls/IPM/Lifecycle/Base.cls +++ b/src/cls/IPM/Lifecycle/Base.cls @@ -1713,8 +1713,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 +1867,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 afa805a42..ec8466718 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,Deployed,PlatformVersions 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,9 +2279,7 @@ ClassMethod ShowModulesForRepository( set list = list + 1 set list(list) = $listbuild(name, tRes.Version) set list(list, "Origin") = tRes.Origin - if tRes.Deployed && (tRes.PlatformVersions '= "") { - set list(list, "PlatformVersions") = tRes.PlatformVersions - } + set list(list, "PlatformVersions") = tRes.PlatformVersions if pShowDesc { set list(list, "Description") = tRes.Description } @@ -2598,18 +2596,17 @@ ClassMethod Install( // Zero results may mean the module was filtered out for being deployed for other // platforms only, which deserves a better message than "not found". Search again // without the filter to tell the two cases apart. - set diagCriteria = ##class(%IPM.Repo.SearchCriteria).%New() - set diagCriteria.Name = $$$lcase(tModuleName) - set diagCriteria.VersionExpression = tVersion - set diagCriteria.Registry = tRegistry - set diagCriteria.IgnorePlatformFilter = 1 - set diagSC = ##class(%IPM.Repo.Utils).SearchRepositoriesForModule(diagCriteria, .diagResults) - if $$$ISOK(diagSC) && $isobject(diagResults) && (diagResults.Count() > 0) { - set diagResult = diagResults.GetAt(1) - if diagResult.Deployed && (diagResult.PlatformVersions.Count() > 0) { - set platformVersion = $system.Version.GetMajor() _ "." _ $system.Version.GetMinor() - set msg = "'" _ tModuleName _ "' " _ diagResult.VersionString _ " is not available for platform " _ platformVersion _ ". Available platforms: " _ diagResult.FormatPlatformVersions() _ ". Run 'search " _ tModuleName _ " -versions' to see all versions." - $$$ThrowStatus($$$ERROR($$$GeneralError, msg)) + if (tModuleName '= "") { + set diagCriteria = tSearchCriteria.%ConstructClone(1) + set diagCriteria.IgnorePlatformFilter = 1 + set diagSC = ##class(%IPM.Repo.Utils).SearchRepositoriesForModule(diagCriteria, .diagResults) + if $$$ISOK(diagSC) && $isobject(diagResults) && (diagResults.Count() > 0) { + set diagResult = diagResults.GetAt(1) + set availablePlatforms = diagResult.FormatPlatformVersions() + if diagResult.Deployed && (availablePlatforms '= "") { + set msg = "'" _ tModuleName _ "' " _ diagResult.VersionString _ " is not available for platform " _ $$$CurrentPlatformVersion _ ". Available platforms: " _ availablePlatforms _ ". Run 'search " _ tModuleName _ " -versions' to see all versions." + $$$ThrowStatus($$$ERROR($$$GeneralError, msg)) + } } } set tPrefix = "" diff --git a/src/cls/IPM/Repo/Manager.cls b/src/cls/IPM/Repo/Manager.cls index 37876b5cd..625628d77 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 { @@ -66,7 +68,7 @@ Method SearchRepositoriesForModule( set tSC = $$$ERROR($$$SQLCode,tRes.%SQLCODE,tRes.%Message) quit } - set currentPlatform = $system.Version.GetMajor() _ "." _ $system.Version.GetMinor() + set currentPlatform = $$$CurrentPlatformVersion while (tRes.%Next(.tSC)) { if $$$ISERR(tSC) { quit @@ -106,8 +108,9 @@ Method SearchRepositoriesForModule( continue } #; Record which platform's artifact this result refers to; callers need it to build - #; the tag. Skipped when the list is empty, as for repositories without platforms. - if item.Deployed && (item.PlatformVersion = "") && (item.PlatformVersions.Find(currentPlatform) > 0) { + #; the tag. Left empty for repositories that don't report platforms, and for the + #; incompatible results that only survive the filter above when it is off. + if item.Deployed && (item.PlatformVersions.Find(currentPlatform) > 0) { set item.PlatformVersion = currentPlatform } #; Sorting versions diff --git a/src/cls/IPM/Repo/Oras/PackageService.cls b/src/cls/IPM/Repo/Oras/PackageService.cls index 17c6ed3d0..1b4ac7935 100644 --- a/src/cls/IPM/Repo/Oras/PackageService.cls +++ b/src/cls/IPM/Repo/Oras/PackageService.cls @@ -169,7 +169,7 @@ Method GetModuleManifest(pModuleReference As %IPM.Storage.ModuleInfo) As %Stream } set stream = ##class(%Stream.GlobalCharacter).%New() $$$ThrowOnError(stream.Write(moduleXML)) - do stream.Rewind() + $$$ThrowOnError(stream.Rewind()) return stream } @@ -180,6 +180,9 @@ ClassMethod TagForReference(moduleReference As %IPM.Storage.ModuleInfo) As %Stri { 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 @@ -230,10 +233,10 @@ 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, and falls back to the newest -/// version overall when the module has no artifact for this platform at all. +/// 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, @@ -264,12 +267,10 @@ Method ListModulesFromTagString( { set allTagsString = ..GetAllTags(..Location, name, "", client) set allVersionsList = ..AggregatePlatformVersions($listfromstring(allTagsString, ", "), .aggregatedPlatformVersion) - // AllVersions is a tri-state; only the empty state reports every version, and it filters by - // platform later, so leave its order alone. The other two report a single version, which - // should be one this platform can actually install. + // 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 '= "") && 'searchCriteria.IgnorePlatformFilter { - set currentPlatform = $system.Version.GetMajor() _ "." _ $system.Version.GetMinor() - set allVersionsList = ..PreferCompatibleVersions(allVersionsList, .aggregatedPlatformVersion, currentPlatform) + set allVersionsList = ..PreferCompatibleVersions(allVersionsList, .aggregatedPlatformVersion, $$$CurrentPlatformVersion) } set pointer = 0 while $listnext(allVersionsList,pointer,moduleVersion) { @@ -347,8 +348,8 @@ Method ListModulesFromTagString( set tModRef.Repository = artifactMetadata.ImageSource set tModRef.Description = artifactMetadata.ImageDescription set tModRef.Deployed = artifactMetadata.IPMDeployed - // These platforms belong to this one version, so they would contradict AllVersions below, - // which lists every version's tag with its own platform suffix. + // 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) { 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/ResourceProcessor/Default/Document.cls b/src/cls/IPM/ResourceProcessor/Default/Document.cls index 743714021..c8b535c0d 100644 --- a/src/cls/IPM/ResourceProcessor/Default/Document.cls +++ b/src/cls/IPM/ResourceProcessor/Default/Document.cls @@ -184,9 +184,9 @@ Method OnPhase( write !, "Loading deployed resource from Studio project: "_..ResourceReference.Name } set ..CompileFromProject = 1 - // Mark deployed so %Compile skips source compilation for - // Deploy=true resources. Persisted because the * phase reopens - // the module from the database before Compile runs. + // 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()) diff --git a/src/cls/IPM/Storage/ModuleInfo.cls b/src/cls/IPM/Storage/ModuleInfo.cls index b12bffe9f..ebd551adb 100644 --- a/src/cls/IPM/Storage/ModuleInfo.cls +++ b/src/cls/IPM/Storage/ModuleInfo.cls @@ -1,5 +1,5 @@ -/// Serializable representation of a module available in a repository, including platform compatibility information for deployed modules. -/// Can be embedded in other persistent objects. +/// 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 = "" ] { @@ -7,24 +7,30 @@ 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 and deployed modules with no platform list are always compatible. -/// Deployed modules with a platform list are compatible only if platformVersion appears in the list. +/// Returns true if this module is installable on the given platform. Only a deployed module +/// with a non-empty platform list restricts anything; an empty entry in that list means the +/// artifact is not platform-specific, so it is compatible with everything. Method IsCompatibleWithPlatform(platformVersion As %String) As %Boolean { - if '..Deployed || (..PlatformVersions.Count() = 0) { + set platformVersions = ..PlatformVersionList() + if '..Deployed || (platformVersions = "") { return 1 } - return ..PlatformVersions.Find(platformVersion) > 0 + return ($listfind(platformVersions, "") > 0) || ($listfind(platformVersions, platformVersion) > 0) } -/// Returns PlatformVersions as a comma-separated string, e.g. "2024.1, 2025.1". -/// Returns "" when the list is empty. +/// 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 _ $select(i > 1: ", ", 1: "") _ ..PlatformVersions.GetAt(i) + set result = result _ $listbuild(..PlatformVersions.GetAt(i)) } return result } diff --git a/src/cls/IPM/Utils/Module.cls b/src/cls/IPM/Utils/Module.cls index c49976b34..bd23278db 100644 --- a/src/cls/IPM/Utils/Module.cls +++ b/src/cls/IPM/Utils/Module.cls @@ -870,7 +870,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,Deployed:%Boolean,PlatformVersions:%String") [ SqlProc ] + pShowAllVersions As %Boolean) As %Query(ROWSPEC = "Name:%String,Version:%String,Repository:%String,Description:%String,Origin:%String,AllVersions:%String,PlatformVersions:%String") [ SqlProc ] { } @@ -903,13 +903,13 @@ ClassMethod GetModuleListExecute( write !, "Skipping repo '" _ pServer _ "': " _ $system.Status.GetErrorText(e.AsStatus()) } if $isobject(tList) { - set platformVersion = $system.Version.GetMajor() _ "." _ $system.Version.GetMinor() + set platformVersion = $$$CurrentPlatformVersion for i=1:1:tList.Count() { set tMod = tList.GetAt(i) 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.Deployed, tMod.FormatPlatformVersions()) + set qHandle($increment(qHandle)) = $listbuild(tMod.Name,tMod.Version.ToString(),tMod.Repository,tMod.Description, tMod.Origin, tMod.AllVersions, tMod.FormatPlatformVersions()) } } } @@ -1395,8 +1395,8 @@ ClassMethod ConstructInvertedDependencyGraph( quit } // Invert dependency graph, subscripts (, ) - #; Repository and version, then the deployed flag and platform version, which - #; the installer needs to locate the artifact built for this platform + #; 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 { 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 index 57e3bad66..267c2d3a7 100644 --- a/tests/integration_tests/Test/PM/Integration/OrasDeployedPlatformFilter.cls +++ b/tests/integration_tests/Test/PM/Integration/OrasDeployedPlatformFilter.cls @@ -1,3 +1,5 @@ +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 { @@ -62,7 +64,9 @@ 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 visible in list-modules") + 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") @@ -76,7 +80,8 @@ Method TestIncompatiblePlatformHiddenFromList() set tgzPath = ..LoadAndPackage() do $$$AssertStatusOK(..PublishWithPlatform(tgzPath, ..#IncompatiblePlatform), "Published for incompatible platform") - do $$$AssertNotTrue(..ModuleVisibleInList(..#ModuleName), "Incompatible module is hidden from list-modules") + 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 @@ -106,6 +111,9 @@ Method TestPlatformFilterPicksLowerCompatibleVersion() // 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 @@ -118,6 +126,7 @@ Method TestDependencyResolvesToCompatibleVersion() 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, @@ -237,8 +246,8 @@ Method PublishWithPlatform( return sc } -/// Returns true if the named module appears in repo -list-modules for oras. -/// Queries the search criteria API directly rather than parsing terminal output. +/// 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() @@ -250,6 +259,23 @@ ClassMethod ModuleVisibleInList(moduleName As %String) As %Boolean 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 { diff --git a/tests/unit_tests/Test/PM/Unit/DeployedModuleFilter.cls b/tests/unit_tests/Test/PM/Unit/DeployedModuleFilter.cls index 192bd7345..be4ab55b3 100644 --- a/tests/unit_tests/Test/PM/Unit/DeployedModuleFilter.cls +++ b/tests/unit_tests/Test/PM/Unit/DeployedModuleFilter.cls @@ -1,5 +1,6 @@ -/// Unit tests for %IPM.Storage.ModuleInfo.IsCompatibleWithPlatform. -/// Constructs ModuleInfo objects directly — no running IRIS registry or ORAS service needed. +/// 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 { @@ -59,6 +60,61 @@ Method TestFormatPlatformVersionsEmpty() do $$$AssertEquals(mod.FormatPlatformVersions(), "", "Empty list returns empty string") } +/// A deployed module tagged without a platform is not platform-specific, so it must be treated +/// as compatible with everything. Matches how PreferCompatibleVersions reads the same list. +Method TestEmptyPlatformEntryIsCompatible() +{ + set mod = ..BuildDeployedMod("") + do $$$AssertTrue(mod.IsCompatibleWithPlatform("9999.9"), "Empty platform entry is compatible") + do $$$AssertEquals(mod.FormatPlatformVersions(), "", "Empty platform entry formats as empty") +} + +/// 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 // --------------------------------------------------------------------------- From c1e9f105e2d0e66c9762f1ac00657ae74268a7f1 Mon Sep 17 00:00:00 2001 From: isc-dchui Date: Wed, 26 Aug 2026 09:30:17 -0400 Subject: [PATCH 4/7] Avoid second search and fix latent bug --- src/cls/IPM/Lifecycle/Base.cls | 14 ++++++++-- src/cls/IPM/Main.cls | 26 +++++++------------ src/cls/IPM/Repo/Manager.cls | 14 +++++++--- src/cls/IPM/Repo/Oras/PackageService.cls | 2 +- src/cls/IPM/Repo/SearchCriteria.cls | 3 --- src/cls/IPM/Repo/Utils.cls | 9 ++++--- .../OrasDeployedPlatformFilter.cls | 21 ++++++++++++++- 7 files changed, 59 insertions(+), 30 deletions(-) diff --git a/src/cls/IPM/Lifecycle/Base.cls b/src/cls/IPM/Lifecycle/Base.cls index 66414c39b..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 { diff --git a/src/cls/IPM/Main.cls b/src/cls/IPM/Main.cls index ec8466718..81e499e96 100644 --- a/src/cls/IPM/Main.cls +++ b/src/cls/IPM/Main.cls @@ -2553,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 = "" @@ -2593,21 +2593,15 @@ ClassMethod Install( $$$ThrowOnError(##class(%IPM.Utils.Module).LoadQualifiedReference(tResult, .tParams, , log)) } } else { - // Zero results may mean the module was filtered out for being deployed for other - // platforms only, which deserves a better message than "not found". Search again - // without the filter to tell the two cases apart. - if (tModuleName '= "") { - set diagCriteria = tSearchCriteria.%ConstructClone(1) - set diagCriteria.IgnorePlatformFilter = 1 - set diagSC = ##class(%IPM.Repo.Utils).SearchRepositoriesForModule(diagCriteria, .diagResults) - if $$$ISOK(diagSC) && $isobject(diagResults) && (diagResults.Count() > 0) { - set diagResult = diagResults.GetAt(1) - set availablePlatforms = diagResult.FormatPlatformVersions() - if diagResult.Deployed && (availablePlatforms '= "") { - set msg = "'" _ tModuleName _ "' " _ diagResult.VersionString _ " is not available for platform " _ $$$CurrentPlatformVersion _ ". Available platforms: " _ availablePlatforms _ ". Run 'search " _ tModuleName _ " -versions' to see all versions." - $$$ThrowStatus($$$ERROR($$$GeneralError, msg)) - } - } + // 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) { + #dim tFiltered As %IPM.Storage.ModuleInfo + set tFiltered = tPlatformFiltered.GetAt(1) + 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 '= "") { diff --git a/src/cls/IPM/Repo/Manager.cls b/src/cls/IPM/Repo/Manager.cls index 625628d77..32a72404f 100644 --- a/src/cls/IPM/Repo/Manager.cls +++ b/src/cls/IPM/Repo/Manager.cls @@ -49,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) @@ -104,12 +109,13 @@ Method SearchRepositoriesForModule( set tServerResults = tClient.ListModules(tClientCriteria) for i=1:1:tServerResults.Count() { set item = tServerResults.GetAt(i) - if 'pSearchCriteria.IgnorePlatformFilter && 'item.IsCompatibleWithPlatform(currentPlatform) { + 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 the - #; incompatible results that only survive the filter above when it is off. + #; 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 } diff --git a/src/cls/IPM/Repo/Oras/PackageService.cls b/src/cls/IPM/Repo/Oras/PackageService.cls index 1b4ac7935..0534d5f2c 100644 --- a/src/cls/IPM/Repo/Oras/PackageService.cls +++ b/src/cls/IPM/Repo/Oras/PackageService.cls @@ -269,7 +269,7 @@ Method ListModulesFromTagString( 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 '= "") && 'searchCriteria.IgnorePlatformFilter { + if (searchCriteria.AllVersions '= "") { set allVersionsList = ..PreferCompatibleVersions(allVersionsList, .aggregatedPlatformVersion, $$$CurrentPlatformVersion) } set pointer = 0 diff --git a/src/cls/IPM/Repo/SearchCriteria.cls b/src/cls/IPM/Repo/SearchCriteria.cls index 6e1955fed..487b95504 100644 --- a/src/cls/IPM/Repo/SearchCriteria.cls +++ b/src/cls/IPM/Repo/SearchCriteria.cls @@ -25,9 +25,6 @@ Property Keywords As list Of %String; /// empty: resolving dependencies Property AllVersions As %Boolean; -/// When true, do not filter deployed modules by the current IRIS platform version -Property IgnorePlatformFilter As %Boolean; - Method KeywordsSet(%value) As %Status { if $isobject(%value) { 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/tests/integration_tests/Test/PM/Integration/OrasDeployedPlatformFilter.cls b/tests/integration_tests/Test/PM/Integration/OrasDeployedPlatformFilter.cls index 267c2d3a7..7de190a37 100644 --- a/tests/integration_tests/Test/PM/Integration/OrasDeployedPlatformFilter.cls +++ b/tests/integration_tests/Test/PM/Integration/OrasDeployedPlatformFilter.cls @@ -88,7 +88,7 @@ Method TestIncompatiblePlatformHiddenFromList() /// is actually available for, rather than a bare "not found". Method TestIncompatiblePlatformInstallError() { - set currentPlatform = $system.Version.GetMajor() _ "." _ $system.Version.GetMinor() + set currentPlatform = $$$CurrentPlatformVersion set tgzPath = ..LoadAndPackage() do $$$AssertStatusOK(..PublishWithPlatform(tgzPath, ..#IncompatiblePlatform), "Published for incompatible platform") @@ -146,6 +146,25 @@ Method TestDependencyPinnedToIncompatibleVersionFails() 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 // --------------------------------------------------------------------------- From b92a9c3ebc842d13d2ab6dc782ad514975c981f1 Mon Sep 17 00:00:00 2001 From: isc-dchui Date: Wed, 26 Aug 2026 10:06:57 -0400 Subject: [PATCH 5/7] Improve search/install UX --- CHANGELOG.md | 1 + src/cls/IPM/Main.cls | 9 +++++++++ src/cls/IPM/Storage/ModuleInfo.cls | 11 +++++------ src/cls/IPM/Utils/Module.cls | 13 ++++++++++--- 4 files changed, 25 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7d4f7619..c54115702 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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. diff --git a/src/cls/IPM/Main.cls b/src/cls/IPM/Main.cls index 81e499e96..ddf9d2226 100644 --- a/src/cls/IPM/Main.cls +++ b/src/cls/IPM/Main.cls @@ -2598,8 +2598,17 @@ ClassMethod Install( // 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)) } diff --git a/src/cls/IPM/Storage/ModuleInfo.cls b/src/cls/IPM/Storage/ModuleInfo.cls index ebd551adb..6a63264a5 100644 --- a/src/cls/IPM/Storage/ModuleInfo.cls +++ b/src/cls/IPM/Storage/ModuleInfo.cls @@ -7,16 +7,15 @@ 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. Only a deployed module -/// with a non-empty platform list restricts anything; an empty entry in that list means the -/// artifact is not platform-specific, so it is compatible with everything. +/// 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 { - set platformVersions = ..PlatformVersionList() - if '..Deployed || (platformVersions = "") { + if '..Deployed { return 1 } - return ($listfind(platformVersions, "") > 0) || ($listfind(platformVersions, platformVersion) > 0) + return $listfind(..PlatformVersionList(), platformVersion) > 0 } /// Returns PlatformVersions as a comma-separated string, e.g. "2024.1, 2025.1", or "" if empty. diff --git a/src/cls/IPM/Utils/Module.cls b/src/cls/IPM/Utils/Module.cls index bd23278db..675808241 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) From b98344e8d81c15215c83b8c51da86830a668d3f4 Mon Sep 17 00:00:00 2001 From: isc-dchui Date: Wed, 26 Aug 2026 10:16:46 -0400 Subject: [PATCH 6/7] Standardize comment syntax --- src/cls/IPM/Repo/Manager.cls | 8 ++++---- src/cls/IPM/Repo/Oras/PackageService.cls | 2 +- src/cls/IPM/Utils/Module.cls | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/cls/IPM/Repo/Manager.cls b/src/cls/IPM/Repo/Manager.cls index 32a72404f..cda716cbc 100644 --- a/src/cls/IPM/Repo/Manager.cls +++ b/src/cls/IPM/Repo/Manager.cls @@ -113,13 +113,13 @@ Method SearchRepositoriesForModule( 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. + // 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 + // 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 0534d5f2c..6bd04150e 100644 --- a/src/cls/IPM/Repo/Oras/PackageService.cls +++ b/src/cls/IPM/Repo/Oras/PackageService.cls @@ -162,7 +162,7 @@ Method GetModule( Method GetModuleManifest(pModuleReference As %IPM.Storage.ModuleInfo) As %Stream.Object { set name = pModuleReference.Name _ ":" _ ..TagForReference(pModuleReference) - #; GetModuleXML returns a string, but callers expect a stream per %IPM.Repo.IPackageService + // 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)) diff --git a/src/cls/IPM/Utils/Module.cls b/src/cls/IPM/Utils/Module.cls index 675808241..edae48207 100644 --- a/src/cls/IPM/Utils/Module.cls +++ b/src/cls/IPM/Utils/Module.cls @@ -1402,8 +1402,8 @@ ClassMethod ConstructInvertedDependencyGraph( quit } // Invert dependency graph, subscripts (, ) - #; Repository, version, deployed flag and platform version. See BuildDependencyGraph - #; for the full node layout. + // 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 { From 779149a541e03790e8d249aabc5502c015e01aaf Mon Sep 17 00:00:00 2001 From: isc-dchui Date: Wed, 26 Aug 2026 10:57:50 -0400 Subject: [PATCH 7/7] Remove incorrect tests --- .../Test/PM/Unit/DeployedModuleFilter.cls | 21 ------------------- 1 file changed, 21 deletions(-) diff --git a/tests/unit_tests/Test/PM/Unit/DeployedModuleFilter.cls b/tests/unit_tests/Test/PM/Unit/DeployedModuleFilter.cls index be4ab55b3..3607ca4f4 100644 --- a/tests/unit_tests/Test/PM/Unit/DeployedModuleFilter.cls +++ b/tests/unit_tests/Test/PM/Unit/DeployedModuleFilter.cls @@ -32,18 +32,6 @@ Method TestNonDeployedAlwaysCompatible() do $$$AssertTrue(mod.IsCompatibleWithPlatform("9999.9"), "Non-deployed is always compatible") } -/// Deployed module with empty PlatformVersions (e.g. filesystem repos) always returns true. -Method TestDeployedNoPlatformVersionsAlwaysCompatible() -{ - set mod = ##class(%IPM.Storage.ModuleInfo).%New() - set mod.Name = "mymodule" - set mod.VersionString = "1.0.0" - set mod.Deployed = 1 - - do $$$AssertTrue(mod.IsCompatibleWithPlatform("9999.9"), "Deployed with no PlatformVersions is always compatible") -} - - /// FormatPlatformVersions returns comma-separated list. Method TestFormatPlatformVersions() { @@ -60,15 +48,6 @@ Method TestFormatPlatformVersionsEmpty() do $$$AssertEquals(mod.FormatPlatformVersions(), "", "Empty list returns empty string") } -/// A deployed module tagged without a platform is not platform-specific, so it must be treated -/// as compatible with everything. Matches how PreferCompatibleVersions reads the same list. -Method TestEmptyPlatformEntryIsCompatible() -{ - set mod = ..BuildDeployedMod("") - do $$$AssertTrue(mod.IsCompatibleWithPlatform("9999.9"), "Empty platform entry is compatible") - do $$$AssertEquals(mod.FormatPlatformVersions(), "", "Empty platform entry formats as empty") -} - /// 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()