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