Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- #973: Enables CORS and JWT configuration for WebApplications in module.xml
- #1110: Add `iriscli` and `ipm` container utility scripts that are auto-installed to `~/.local/bin/` and `~/bin/` so they work both inside and outside of containers (Unix/Linux only)
- #971: Adds structured test output formats (JSON, YAML, Toon). Use `-f <format>` for a one-shot override or `config set TestReportFormat <format>` for a persistent default. Without either, legacy output is shown. Also adds `-output-file` for writing results to a file (including JUnit XML via `.xml` extension) and improves `-quiet` to suppress build noise.
- #1029: Add support for user-configurable ModuleRoot for IPM module installation

### Fixed
- #964: Fix poor error handling on some install failures due to incorrect error message variable in embedded SQL
Expand Down
11 changes: 6 additions & 5 deletions preload/cls/IPM/Installer.cls
Original file line number Diff line number Diff line change
Expand Up @@ -112,11 +112,12 @@ ClassMethod ZPMInit(
$$$QuitOnError(##class(%IPM.Repo.UniversalSettings).SetDefaultRegistry(pRegistry, 0))
$$$QuitOnError(##class(%IPM.Repo.UniversalSettings).SetAnalyticsAvailable(1, 0))
$$$QuitOnError(##class(%IPM.Repo.UniversalSettings).SetAnalyticsTrackingId(pAnalyticsTrackingID, 0))
$$$QuitOnError(##class(%IPM.Repo.UniversalSettings).SetValue("ColorScheme","", 0))
$$$QuitOnError(##class(%IPM.Repo.UniversalSettings).SetValue("PipCaller", "", 0))
$$$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(##class(%IPM.Repo.UniversalSettings).#ColorScheme, "", 0))
$$$QuitOnError(##class(%IPM.Repo.UniversalSettings).SetValue(##class(%IPM.Repo.UniversalSettings).#PipCaller, "", 0))
$$$QuitOnError(##class(%IPM.Repo.UniversalSettings).SetValue(##class(%IPM.Repo.UniversalSettings).#UseStandalonePip, "", 0))
$$$QuitOnError(##class(%IPM.Repo.UniversalSettings).SetValue(##class(%IPM.Repo.UniversalSettings).#SemVerPostRelease, 0, 0))
$$$QuitOnError(##class(%IPM.Repo.UniversalSettings).SetValue(##class(%IPM.Repo.UniversalSettings).#DefaultLogEntryLimit, 20, 0))
$$$QuitOnError(##class(%IPM.Repo.UniversalSettings).SetValue(##class(%IPM.Repo.UniversalSettings).#ModuleRoot, ##class(%File).NormalizeDirectory(##class(%SYSTEM.Util).DataDirectory() _ "ipm/"), 0))
quit $$$OK
}

Expand Down
74 changes: 61 additions & 13 deletions src/cls/IPM/Repo/UniversalSettings.cls
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ Parameter HistoryRetain = "history_retain";
/// Specifies the serialization format (JSON, TOON, YAML) for unit and integration test results in the shell.
Parameter TestReportFormat = "TestReportFormat";

Parameter CONFIGURABLE = "trackingId,analytics,ColorScheme,TerminalPrompt,PublishTimeout,PipCaller,UseStandalonePip,SemVerPostRelease,DefaultLogEntryLimit,HistoryRetain,TestReportFormat";
Parameter ModuleRoot = "ModuleRoot";

Parameter CONFIGURABLE = "trackingId,analytics,ColorScheme,TerminalPrompt,PublishTimeout,PipCaller,UseStandalonePip,SemVerPostRelease,DefaultLogEntryLimit,HistoryRetain,TestReportFormat,ModuleRoot";

/// Returns configArray, that includes all configurable settings
ClassMethod GetAll(Output configArray) As %Status
Expand Down Expand Up @@ -89,8 +91,15 @@ ClassMethod ResetToDefault(key As %String) As %Status
write "Config key = """_key_""" not found",!
quit
}
// TestReportFormat has no factory default; empty means "use legacy output"
set defaultValue = $select(key = "TestReportFormat": "", 1: ..GetDefaultValue($parameter(..%ClassName(1),key)))
if key = ..#TestReportFormat {
// No factory default; empty string means "use legacy output"
set defaultValue = ""
} elseif key = ..#ModuleRoot {
// Computed directly rather than read from storage — older IPM versions did not seed this default node
set defaultValue = ##class(%File).NormalizeDirectory(##class(%SYSTEM.Util).DataDirectory() _ "ipm/")
} else {
set defaultValue = ..GetDefaultValue($parameter(..%ClassName(1),key))
}
set sc = ..SetValue($parameter(..%ClassName(1),key), defaultValue)
if $$$ISOK(sc) {
write !,"Value for """_key_""" succesfully reset to default",!
Expand All @@ -104,20 +113,33 @@ ClassMethod UpdateOne(
key As %String,
value As %String) As %Status
{
set sc = $$$OK
set SettingsList = $listfromstring(..#CONFIGURABLE)
if ('$listfind(SettingsList,key)) {
write "Config key = """_key_""" not found",!
quit
quit sc
}
if key = "TestReportFormat" {
// Validate format value before saving it
set sc = ..SetTestReportFormat(value)
} else {
set sc = ..SetValue($parameter(..%ClassName(1),key), value)
}
if $$$ISOK(sc) {
write !,"Key """_key_""" succesfully updated",!
} else {
try {
if key = ..#TestReportFormat {
// Validate format value before saving it; SetTestReportFormat also calls SetValue internally
set sc = ..SetTestReportFormat(value)
} else {
if key = ..#ModuleRoot {
if value = "" {
$$$ThrowOnError($$$ERROR($$$GeneralError, "ModuleRoot cannot be empty"))
}
set value = ##class(%File).NormalizeDirectory(value)
do ..EnsureWritableDirectory(value)
}
set sc = ..SetValue($parameter(..%ClassName(1),key), value)
}
if $$$ISOK(sc) {
write !,"Key """_key_""" succesfully updated",!
} else {
write !,$system.Status.GetErrorText(sc),!
}
} catch ex {
set sc = ex.AsStatus()
write !,$system.Status.GetErrorText(sc),!
}
return sc
Expand Down Expand Up @@ -215,4 +237,30 @@ ClassMethod GetTestReportFormat() As %String
return ..GetValue(..#TestReportFormat)
}

/// Returns the configured ModuleRoot. Falls back to an ipm/ subdirectory under the IRIS data directory if not set.
/// Note: "config list" shows a blank ModuleRoot on installs upgraded from versions that predate this setting,
/// because ZPMInit (which seeds the default) only runs during bootstraps, not upgrades through IPM itself.
/// Fixing this cleanly would require either splitting seeding logic across ZPMInit and an <Invoke>,
/// or adding a special-case to GetAll/PrintOne — both add more complexity than the gap warrants.
ClassMethod GetModuleRoot() As %String
{
set value = ..GetValue(..#ModuleRoot)
if value = "" {
return ##class(%File).NormalizeDirectory(##class(%SYSTEM.Util).DataDirectory() _ "ipm/")
}
return value
}

/// Expects a normalized directory path (caller must call NormalizeDirectory first).
/// Creates the directory chain if it does not already exist, then verifies write access.
ClassMethod EnsureWritableDirectory(directory As %String)
{
if '##class(%File).DirectoryExists(directory) {
$$$ThrowOnError(##class(%IPM.Utils.File).CreateDirectoryChain(directory))
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a side effect and should be documented and conveyed in the method name. The name sounds like its just checking. Not creating

}
if '##class(%File).Writeable(directory) {
$$$ThrowOnError($$$ERROR($$$GeneralError,"No write permission on directory "_directory))
}
}

}
3 changes: 2 additions & 1 deletion src/cls/IPM/ResourceProcessor/Abstract.cls
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,8 @@ 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).GetModuleRoot()
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 '= "") {
Expand Down
2 changes: 1 addition & 1 deletion src/cls/IPM/Storage/Module.cls
Original file line number Diff line number Diff line change
Expand Up @@ -1813,7 +1813,7 @@ Method %Evaluate(
set customParams("packagename") = ..Name
set customParams("version") = ..VersionString
set customParams("verbose") = +$get(pParams("Verbose"))
set customParams("ipmDir") = ##class(%Library.File).NormalizeDirectory($system.Util.DataDirectory() _ "ipm/" _ ..Name _ "/" _ ..VersionString)
set customParams("ipmDir") = ##class(%Library.File).NormalizeDirectory(##class(%IPM.Repo.UniversalSettings).GetModuleRoot() _ ..Name _ "/" _ ..VersionString)
set tAttrValue = ##class(%IPM.Utils.Module).%EvaluateMacro(tAttrValue)
set tAttrValue = ##class(%IPM.Storage.ModuleSetting.Default).EvaluateAttribute(tAttrValue,.customParams)
set attrValue = ##class(%IPM.Utils.Module).%EvaluateSystemExpression(tAttrValue)
Expand Down
2 changes: 1 addition & 1 deletion src/cls/IPM/Utils/File.cls
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ ClassMethod CreateDirectoryChain(pName As %String) As %Status
{
set tSC = $$$OK
if '##class(%Library.File).CreateDirectoryChain(pName,.tReturn) {
set tSC = $$$ERROR($$$GeneralError,$$$FormatText("Error creating directory chain %1: %2",pName,$zutil(209,tReturn)))
set tSC = $$$ERROR($$$GeneralError,$$$FormatText("Error creating directory chain %1: %2",pName,$zutil(209,$select(tReturn<0:-tReturn,1:tReturn))))
}
quit tSC
}
Expand Down
3 changes: 1 addition & 2 deletions src/cls/IPM/Utils/Module.cls
Original file line number Diff line number Diff line change
Expand Up @@ -226,8 +226,7 @@ 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)
set tTargetDirectory = ##class(%Library.File).NormalizeDirectory(##class(%IPM.Repo.UniversalSettings).GetModuleRoot() _ pModuleName _ "/" _ pModuleVersion)
if ##class(%File).DirectoryExists(tTargetDirectory) {
// Delete it.
set tSC = ##class(%IPM.Utils.File).RemoveDirectoryTree(tTargetDirectory)
Expand Down
113 changes: 113 additions & 0 deletions tests/integration_tests/Test/PM/Integration/ModuleRoot.cls
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
Class Test.PM.Integration.ModuleRoot Extends Test.PM.Integration.Base
{

Method OnBeforeAllTests() As %Status
{
set sc = ##class(%IPM.Main).Shell("repo -remote -n registry -url https://pm.community.intersystems.com/ -user """" -pass """"")
do $$$AssertStatusOK(sc, "Community registry configured.")
return sc
}

Method OnAfterAllTests() As %Status
{
set sc = ##class(%IPM.Main).Shell("uninstall objectscript-math -force")
set sc = ##class(%IPM.Main).Shell("repo -delete -n registry")
do $$$AssertStatusOK(sc, "Community registry removed.")
return sc
}

Method OnAfterOneTest(testname As %String) As %Status
{
set sc = ##class(%IPM.Repo.UniversalSettings).ResetToDefault(##class(%IPM.Repo.UniversalSettings).#ModuleRoot)
do $$$AssertStatusOK(sc, "ModuleRoot reset to default after test.")
for dir = "ipm-test-root-exists/","ipm-test-root-new/" {
set fullPath = ##class(%File).NormalizeDirectory(##class(%File).ManagerDirectory() _ dir)
if ##class(%File).DirectoryExists(fullPath) {
do ##class(%File).RemoveDirectoryTree(fullPath)
}
}
return sc
}

Method TestModuleRootDefault()
{
set sc = $$$OK
try {
set defaultRoot = ##class(%File).NormalizeDirectory(##class(%SYSTEM.Util).DataDirectory() _ "ipm/")

set moduleObj = ##class(%IPM.Storage.Module).NameOpen("objectscript-math")
if $isobject(moduleObj) {
do $$$AssertStatusOK(##class(%IPM.Main).Shell("uninstall objectscript-math"), "Uninstalled objectscript-math before test.")
}

do $$$AssertStatusOK(##class(%IPM.Main).Shell("install objectscript-math"), "Installed objectscript-math using default ModuleRoot.")

set moduleObj = ##class(%IPM.Storage.Module).NameOpen("objectscript-math")
set moduleRoot = ##class(%File).NormalizeDirectory($piece(moduleObj.Root, "objectscript-math"))
do $$$AssertEquals(moduleRoot, defaultRoot, "Module installed into default root.")
} catch ex {
do $$$AssertStatusOK(ex.AsStatus(), "Unexpected exception in TestModuleRootDefault.")
}
}

Method TestModuleRootCustomPathExists()
{
set sc = $$$OK
try {
set defaultRoot = ##class(%File).NormalizeDirectory(##class(%SYSTEM.Util).DataDirectory() _ "ipm/")
set customPath = ##class(%File).NormalizeDirectory(##class(%File).ManagerDirectory() _ "ipm-test-root-exists/")

do $$$AssertTrue(##class(%File).CreateDirectoryChain(customPath), "Created custom ModuleRoot directory.")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be done in the test or do you want to test this dir gets created by IPM? Perhaps testing both cases where the dir already exists vs doesn't exist would be nice. Also I'm not sure how you would test this but it would be interesting to test a case where the instance doesn't have permission to the directory

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Split the test into the two cases.

For the permissions testing, I did some manual testing where I did chmod 555 /usr/irissys/ and then tried to set the module root to a non-existent directory:

zpm:USER>config set ModuleRoot /usr/irissys/ipm_temp

ERROR! Error creating directory chain /usr/irissys/ipm_temp/: <13> Permission denied

If ipm_temp already exists, then this error results:

zpm:USER>config set ModuleRoot /usr/irissys/ipm_temp

ERROR! No write permission on directory /usr/irissys/ipm_temp/


do $$$AssertStatusOK(##class(%IPM.Repo.UniversalSettings).UpdateOne("ModuleRoot", customPath), "Set ModuleRoot to existing custom path.")
do $$$AssertEquals(##class(%IPM.Repo.UniversalSettings).GetModuleRoot(), customPath, "ModuleRoot stored as normalized path.")

set moduleObj = ##class(%IPM.Storage.Module).NameOpen("objectscript-math")
if $isobject(moduleObj) {
do $$$AssertStatusOK(##class(%IPM.Main).Shell("uninstall objectscript-math"), "Uninstalled objectscript-math before relocation test.")
}

do $$$AssertStatusOK(##class(%IPM.Main).Shell("install objectscript-math"), "Installed objectscript-math using existing custom ModuleRoot.")

set moduleObj = ##class(%IPM.Storage.Module).NameOpen("objectscript-math")
set moduleRoot = ##class(%File).NormalizeDirectory($piece(moduleObj.Root, "objectscript-math"))
do $$$AssertTrue(moduleRoot '= defaultRoot, "Module is no longer in default root.")
do $$$AssertTrue(moduleRoot [ customPath, "Module is installed in custom root: " _ moduleRoot)
} catch ex {
do $$$AssertStatusOK(ex.AsStatus(), "Unexpected exception in TestModuleRootCustomPathExists.")
}
}

Method TestModuleRootCustomPathCreated()
{
set sc = $$$OK
try {
set defaultRoot = ##class(%File).NormalizeDirectory(##class(%SYSTEM.Util).DataDirectory() _ "ipm/")
set customPath = ##class(%File).NormalizeDirectory(##class(%File).ManagerDirectory() _ "ipm-test-root-new/")

// Remove the directory if it exists from a prior run
if ##class(%File).DirectoryExists(customPath) {
do ##class(%File).RemoveDirectoryTree(customPath)
}
do $$$AssertTrue('##class(%File).DirectoryExists(customPath), "Custom path does not exist before config set.")

do $$$AssertStatusOK(##class(%IPM.Repo.UniversalSettings).UpdateOne("ModuleRoot", customPath), "Set ModuleRoot to nonexistent path — should create it.")
do $$$AssertTrue(##class(%File).DirectoryExists(customPath), "Directory was created by config set.")

set moduleObj = ##class(%IPM.Storage.Module).NameOpen("objectscript-math")
if $isobject(moduleObj) {
do $$$AssertStatusOK(##class(%IPM.Main).Shell("uninstall objectscript-math"), "Uninstalled objectscript-math before relocation test.")
}

do $$$AssertStatusOK(##class(%IPM.Main).Shell("install objectscript-math"), "Installed objectscript-math into auto-created custom ModuleRoot.")

set moduleObj = ##class(%IPM.Storage.Module).NameOpen("objectscript-math")
set moduleRoot = ##class(%File).NormalizeDirectory($piece(moduleObj.Root, "objectscript-math"))
do $$$AssertTrue(moduleRoot '= defaultRoot, "Module is no longer in default root.")
do $$$AssertTrue(moduleRoot [ customPath, "Module is installed in auto-created root: " _ moduleRoot)
} catch ex {
do $$$AssertStatusOK(ex.AsStatus(), "Unexpected exception in TestModuleRootCustomPathCreated.")
}
}

}
66 changes: 45 additions & 21 deletions tests/unit_tests/Test/PM/Unit/UniversalSettings.cls
Original file line number Diff line number Diff line change
Expand Up @@ -5,46 +5,70 @@ 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 <B>RunTest</B> immediately after each test method in the test class is run.<br>
/// <dl>
/// <dt><i>testname</i>
/// <dd>Name of the test to be run. Required.
/// </dl>
/// <dd>Name of the test to be run. Required.
/// </dl>
Method OnAfterOneTest(testname As %String) As %Status
{
New $NAMESPACE
Set $NAMESPACE = "%SYS"
Kill ^IPM.settings(..#TestIndx)
Quit $$$OK
new $namespace
set $namespace = "%SYS"
kill ^IPM.settings(..#TestIndx)
quit $$$OK
}

/// Run by <B>RunTest</B> immediately before each test method in the test class is run.<br>
/// <dl>
/// <dt><i>testname</i>
/// <dd>Name of the test to be run. Required.
/// </dl>
/// <dd>Name of the test to be run. Required.
/// </dl>
Method OnBeforeOneTest(testname As %String) As %Status
{
New $NAMESPACE
Set $NAMESPACE = "%SYS"
Set ^IPM.settings(..#TestIndx) = "default value"
Quit $$$OK
new $namespace
set $namespace = "%SYS"
set ^IPM.settings(..#TestIndx) = "default value"
quit $$$OK
}

Method TestModuleRootValidationRejectsEmptyPath()
{
set sc = ##class(%IPM.Repo.UniversalSettings).UpdateOne(##class(%IPM.Repo.UniversalSettings).#ModuleRoot, "")
do $$$AssertStatusNotOK(sc, "Setting ModuleRoot to an empty string returns an error status.")
}

Method TestModuleRootResetToDefault()
{
set sc = $$$OK
try {
set expectedDefault = ##class(%File).NormalizeDirectory(##class(%SYSTEM.Util).DataDirectory() _ "ipm/")
set customPath = ##class(%File).NormalizeDirectory(##class(%File).ManagerDirectory() _ "ipm-test-root-" _ $job _ "/")
do $$$AssertTrue(##class(%File).CreateDirectoryChain(customPath), "Created custom ModuleRoot directory.")

do $$$AssertStatusOK(##class(%IPM.Repo.UniversalSettings).UpdateOne(##class(%IPM.Repo.UniversalSettings).#ModuleRoot, customPath), "Set ModuleRoot to custom path.")

do $$$AssertStatusOK(##class(%IPM.Repo.UniversalSettings).ResetToDefault(##class(%IPM.Repo.UniversalSettings).#ModuleRoot), "Reset ModuleRoot to default.")
set resetValue = ##class(%IPM.Repo.UniversalSettings).GetModuleRoot()
do $$$AssertEquals(resetValue, expectedDefault, "ModuleRoot reset value matches expected default.")
} catch ex {
do $$$AssertStatusOK(ex.AsStatus(), "Unexpected exception in TestModuleRootResetToDefault.")
}
}

}
Loading