diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6f72ed274..8a94a5f5f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,11 +5,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
## [0.10.7] - Unreleased
### Added
- #992: Implement automatic history purge logic
- #973: Enables CORS and JWT configuration for WebApplications in module.xml
+- #1031: Add support for user-configurable ModuleRoot for IPM module installation
### Fixed
- #1001: The `unmap` and `enable` commands will now only activate CPF merge once after all namespaces have been configured instead after every namespace
diff --git a/preload/cls/IPM/Installer.cls b/preload/cls/IPM/Installer.cls
index 26970544b..fe4e742a0 100644
--- a/preload/cls/IPM/Installer.cls
+++ b/preload/cls/IPM/Installer.cls
@@ -113,6 +113,7 @@ ClassMethod ZPMInit(
$$$QuitOnError(##class(%IPM.Repo.UniversalSettings).SetValue("UseStandalonePip", "", 0))
$$$QuitOnError(##class(%IPM.Repo.UniversalSettings).SetValue("SemVerPostRelease", 0, 0))
$$$QuitOnError(##class(%IPM.Repo.UniversalSettings).SetValue("DefaultLogEntryLimit",20, 0))
+ $$$QuitOnError(##class(%IPM.Repo.UniversalSettings).SetValue("ModuleRoot",##class(%SYSTEM.Util).DataDirectory() _ "ipm/", 0))
quit $$$OK
}
diff --git a/src/cls/IPM/Repo/UniversalSettings.cls b/src/cls/IPM/Repo/UniversalSettings.cls
index e1ff1f3ed..68485f17d 100644
--- a/src/cls/IPM/Repo/UniversalSettings.cls
+++ b/src/cls/IPM/Repo/UniversalSettings.cls
@@ -41,11 +41,9 @@ Parameter UseStandalonePip = "UseStandalonePip";
/// Default value is 0, where 1.0.0-anystring is considered a pre-release of 1.0.0, hence 1.0.0-anystring < 1.0.0
Parameter SemVerPostRelease = "SemVerPostRelease";
-/// Configuration setting name used to determine the number of days
-/// to retain IPM history records before they are eligible for cleanup.
-Parameter HistoryRetain = "history_retain";
+Parameter ModuleRoot = "ModuleRoot";
-Parameter CONFIGURABLE = "trackingId,analytics,ColorScheme,TerminalPrompt,PublishTimeout,PipCaller,UseStandalonePip,SemVerPostRelease,DefaultLogEntryLimit,HistoryRetain";
+Parameter CONFIGURABLE = "trackingId,analytics,ColorScheme,TerminalPrompt,PublishTimeout,PipCaller,UseStandalonePip,SemVerPostRelease,DefaultLogEntryLimit,ModuleRoot";
/// Returns configArray, that includes all configurable settings
ClassMethod GetAll(Output configArray) As %Status
@@ -104,6 +102,9 @@ ClassMethod UpdateOne(
write "Config key = """_key_""" not found",!
quit
}
+ if key="ModuleRoot" {
+ do ..CheckDirPermission(value)
+ }
set sc = ..SetValue($parameter(..%ClassName(1),key), value)
if $$$ISOK(sc) {
write "Key """_key_""" succesfully updated",!
@@ -185,9 +186,22 @@ ClassMethod SetAnalyticsAvailable(
return ..SetValue(..#analytics, +val, overwrite)
}
-ClassMethod GetHistoryRetain() As %Integer
+ClassMethod GetMouleRoot() As %String
+{
+ return ..GetValue(..#ModuleRoot)
+}
+
+ClassMethod CheckDirPermission(directory As %String = "")
{
- return ..GetValue(..#HistoryRetain)
+ if directory="" {
+ $$$ThrowOnError($$$ERROR($$$DirectoryNameRequired))
+ }
+ set directory = ##class(%File).NormalizeDirectory(directory)
+ if '##class(%File).DirectoryExists(directory) {
+ $$$ThrowOnError($$$ERROR($$$DirectoryNotFound))
+ } elseif '##class(%File).Writeable(directory) {
+ $$$ThrowOnError($$$ERROR($$$GeneralError,"No write permission on directory "_directory))
+ }
}
}
diff --git a/src/cls/IPM/ResourceProcessor/Abstract.cls b/src/cls/IPM/ResourceProcessor/Abstract.cls
index 5d56e2b9a..15892c05c 100644
--- a/src/cls/IPM/ResourceProcessor/Abstract.cls
+++ b/src/cls/IPM/ResourceProcessor/Abstract.cls
@@ -272,7 +272,11 @@ Method %Evaluate(pAttrValue As %String) As %String [ Internal ]
set tAttrValue = ##class(%IPM.Utils.Module).%EvaluateMacro(tAttrValue)
set attrValue = ##class(%IPM.Utils.Module).%EvaluateSystemExpression(tAttrValue)
set customParams("packagename") = ..ResourceReference.Module.Name
- set customParams("ipmDir") = ##class(%Library.File).NormalizeDirectory($system.Util.DataDirectory() _ "ipm/" _ ..ResourceReference.Module.Name _ "/" _ ..ResourceReference.Module.VersionString)
+ set ipmDir = ##class(%IPM.Repo.UniversalSettings).GetMouleRoot()
+ if ipmDir="" {
+ set ipmDir = $system.Util.DataDirectory() _ "ipm/"
+ }
+ set customParams("ipmDir") = ##class(%Library.File).NormalizeDirectory(ipmDir _ ..ResourceReference.Module.Name _ "/" _ ..ResourceReference.Module.VersionString)
set attrValue = ##class(%IPM.Storage.ModuleSetting.Default).EvaluateAttribute(attrValue,.customParams)
set root = ..ResourceReference.Module.Root
if (root '= "") {
diff --git a/src/cls/IPM/Utils/Module.cls b/src/cls/IPM/Utils/Module.cls
index b2903e36d..397f0cf93 100644
--- a/src/cls/IPM/Utils/Module.cls
+++ b/src/cls/IPM/Utils/Module.cls
@@ -226,8 +226,19 @@ ClassMethod LoadModuleFromArchive(
try {
set tVerbose = $get(pParams("Verbose"))
- // Modules have a well-defined location inside the archive
- set tTargetDirectory = ##class(%Library.File).NormalizeDirectory(##class(%SYSTEM.Util).DataDirectory() _ "ipm/" _ pModuleName _ "/" _ pModuleVersion)
+ // Determine the base installation directory for IPM modules.
+ // Precedence:
+ // 1. User-configured 'ModuleRoot' via UniversalSettings.
+ // 2. Default system location (IRIS data directory /ipm/).
+ set ipmRoot = ##class(%IPM.Repo.UniversalSettings).GetMouleRoot()
+ if ipmRoot'="" {
+ set impRoot = ##class(%File).NormalizeDirectory(ipmRoot)
+ }
+ else {
+ set ipmRoot = ##class(%SYSTEM.Util).DataDirectory() _ "ipm/"
+ }
+ set ipmModuleRootDir = ipmRoot_ pModuleName _ "/" _ pModuleVersion
+ set tTargetDirectory = ##class(%Library.File).NormalizeDirectory(ipmModuleRootDir)
if ##class(%File).DirectoryExists(tTargetDirectory) {
// Delete it.
set tSC = ##class(%IPM.Utils.File).RemoveDirectoryTree(tTargetDirectory)
diff --git a/tests/unit_tests/Test/PM/Unit/UniversalSettings.cls b/tests/unit_tests/Test/PM/Unit/UniversalSettings.cls
index 6279a47e3..9bb729d30 100644
--- a/tests/unit_tests/Test/PM/Unit/UniversalSettings.cls
+++ b/tests/unit_tests/Test/PM/Unit/UniversalSettings.cls
@@ -5,46 +5,123 @@ Parameter TestIndx As STRING = "TestIndx";
Method TestSetValueOverwrite()
{
- Do ##class(%IPM.Repo.UniversalSettings).SetValue(..#TestIndx, "new value a", )
- Set value = ##class(%IPM.Repo.UniversalSettings).GetValue(..#TestIndx)
- Do $$$AssertEquals(value, "new value a")
+ do ##class(%IPM.Repo.UniversalSettings).SetValue(..#TestIndx, "new value a", )
+ set value = ##class(%IPM.Repo.UniversalSettings).GetValue(..#TestIndx)
+ do $$$AssertEquals(value, "new value a")
- Do ##class(%IPM.Repo.UniversalSettings).SetValue(..#TestIndx, "new value b", 1)
- Set value = ##class(%IPM.Repo.UniversalSettings).GetValue(..#TestIndx)
- Do $$$AssertEquals(value, "new value b")
+ do ##class(%IPM.Repo.UniversalSettings).SetValue(..#TestIndx, "new value b", 1)
+ set value = ##class(%IPM.Repo.UniversalSettings).GetValue(..#TestIndx)
+ do $$$AssertEquals(value, "new value b")
}
Method TestSetValueNoOverwrite()
{
- Do ##class(%IPM.Repo.UniversalSettings).SetValue(..#TestIndx, "new value c", 0)
- Set value = ##class(%IPM.Repo.UniversalSettings).GetValue(..#TestIndx)
- Do $$$AssertEquals(value, "default value")
+ do ##class(%IPM.Repo.UniversalSettings).SetValue(..#TestIndx, "new value c", 0)
+ set value = ##class(%IPM.Repo.UniversalSettings).GetValue(..#TestIndx)
+ do $$$AssertEquals(value, "default value")
}
/// Run by RunTest immediately after each test method in the test class is run.
///