diff --git a/CHANGELOG.md b/CHANGELOG.md
index 483e5619..11091351 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- #938 Added flag -export-python-deps to package command
- #462: The `repo` command for repository configuration now supports secret input terminal mode for passwords with the `-password-stdin` flag
- #935: Adding a generic JFrog Artifactory tarball resource processor for bundling artifact with a package and deploying it to a final location on install.
+- #967: Added package pinning commands (`pin` and `unpin`) to lock module versions and prevent accidental modification. The `list` command now displays the `-Pinned` status, and the new `list -p` modifier shows only pinned modules.
### Changed
- #316: All parameters, except developer mode, included with a `load`, `install` or `update` command will be propagated to dependencies
diff --git a/src/cls/IPM/Main.cls b/src/cls/IPM/Main.cls
index a28b2ad2..7a4d744b 100644
--- a/src/cls/IPM/Main.cls
+++ b/src/cls/IPM/Main.cls
@@ -528,7 +528,7 @@ reinstall -env /path/to/env1.json;/path/to/env2.json example-package
-
+
@@ -758,6 +758,22 @@ generate /my/path -export 00000,PacketName2,IgnorePacket2^00000,PacketName3,Igno
+
+ Marks specific module as pinned to prevent future automatic ZPM operations.
+
+ pin HS.JSON
+
+
+
+
+
+ Removes the pinned status from an installed module, allowing future ZPM operations.
+
+ unpin HS.JSON
+
+
+
+
}
@@ -992,6 +1008,10 @@ ClassMethod ShellInternal(
do ..ModuleVersion(.tCommandInfo)
} elseif (tCommandInfo = "information") {
do ..Information(.tCommandInfo)
+ } elseif (tCommandInfo = "pin") {
+ do ..Pin(.tCommandInfo)
+ } elseif (tCommandInfo = "unpin") {
+ do ..UnPin(.tCommandInfo)
}
} catch pException {
if (pException.Code = $$$ERCTRLC) {
@@ -1200,7 +1220,9 @@ ClassMethod GetListModules(
$$$ThrowOnError(tSC)
set name = tRes.Name
set list = list + 1
- set list(list) = $listbuild(name, tRes.VersionString, tRes.ExternalName, tRes.DeveloperMode, tRes.Root)
+ set IsModulePinned=$select(##class(%IPM.Storage.PinnedModule).IsModulePinned(name):"-Pinned",1:"")
+
+ set list(list) = $listbuild(name, tRes.VersionString_IsModulePinned, tRes.ExternalName, tRes.DeveloperMode, tRes.Root)
if hasLastUpdated {
set list(list) = list(list) _ $listbuild(tRes.LastUpdated)
}
@@ -2248,6 +2270,10 @@ ClassMethod Install(ByRef pCommandInfo) [ Internal ]
$$$ThrowStatus($$$ERROR($$$GeneralError, "No repositories are configured and enabled in this namespace."))
}
+ //verify the module is pinned
+ if ##class(%IPM.Storage.PinnedModule).IsModulePinned(tModuleName) {
+ $$$ThrowStatus($$$ERROR($$$GeneralError, "The module "_tModuleName_" is pinned unable to install"))
+ }
set tVersion = $get(pCommandInfo("parameters","version"))
set tKeywords = $$$GetModifier(pCommandInfo,"keywords")
set tSynchronous = $$$HasModifier(pCommandInfo,"synchronous")
@@ -2340,6 +2366,10 @@ ClassMethod Reinstall(ByRef pCommandInfo) [ Internal ]
$$$ThrowOnError(..CheckModuleNamespace())
}
+ //verify the module is pinned
+ if ##class(%IPM.Storage.PinnedModule).IsModulePinned(tModuleName) {
+ $$$ThrowStatus($$$ERROR($$$GeneralError, "The module "_tModuleName_" is pinned unable to reinstall"))
+ }
set tVersionString = tModule.Version.ToString()
write !,"Reinstalling ",tModuleName," ",tVersionString
set pCommandInfo("parameters","version") = tVersionString
@@ -2354,11 +2384,16 @@ ClassMethod Uninstall(ByRef pCommandInfo) [ Internal ]
$$$ThrowOnError(##class(%IPM.Utils.Module).UninstallAll(tForce,.tParams))
return
} else {
- set tModuleName = pCommandInfo("parameters","module")
+ set tModuleName = $get(pCommandInfo("parameters","module"))
if (tModuleName = $$$IPMModuleName) {
$$$ThrowOnError(..CheckModuleNamespace())
}
set tRecurse = $$$HasModifier(pCommandInfo,"recurse") // Recursively uninstall unneeded dependencies
+
+ //verify the module is pinned
+ if ##class(%IPM.Storage.PinnedModule).IsModulePinned(tModuleName) {
+ $$$ThrowStatus($$$ERROR($$$GeneralError, "The module "_tModuleName_" is pinned unable to uninstall"))
+ }
$$$ThrowOnError(##class(%IPM.Storage.Module).Uninstall(tModuleName,tForce,tRecurse,.tParams))
}
}
@@ -2552,6 +2587,13 @@ ClassMethod ActiveNamespacesClose(qHandle As %Binary) As %Status [ PlaceAfter =
ClassMethod ListInstalled(ByRef pCommandInfo) [ Private ]
{
set tSearchString = $get(pCommandInfo("parameters","searchString"),"")
+ // list pinned modules
+ if $data(pCommandInfo("modifiers","pinned")) {
+ merge tModifiers = pCommandInfo("modifiers")
+ do ##class(%IPM.Storage.PinnedModule).GetAllPinnedModules(.list)
+ do ..DisplayModules(.list,,,, .tModifiers)
+ quit $$$OK
+ }
if (''$data(pCommandInfo("modifiers","tree"))) {
// Show tree of dependencies as well.
// Modules that are dependencies for no other are shown at the top level.
@@ -3858,6 +3900,10 @@ ClassMethod Update(ByRef pCommandInfo)
if '##class(%IPM.Storage.Module).NameExists(moduleName) && '##class(%IPM.Storage.Module).ExternalNameExists(moduleName) {
$$$ThrowStatus($$$ERROR($$$GeneralError,"Module "_moduleName_" has not been installed. Please use the install or load commands to install the module before calling update."))
}
+ //verify the module is pinned
+ if ##class(%IPM.Storage.PinnedModule).IsModulePinned(moduleName) {
+ $$$ThrowStatus($$$ERROR($$$GeneralError, "The module "_moduleName_" is pinned unable to update."))
+ }
if verbose {
if path '= "" {
write !,"Resolving version of module specified by -path flag: ",path," to update",!
@@ -3881,4 +3927,20 @@ ClassMethod Update(ByRef pCommandInfo)
}
}
+ClassMethod Pin(ByRef pCommandInfo) As %Status
+{
+ set sc = ##class(%IPM.Storage.PinnedModule).PinModule(pCommandInfo("parameters","module"))
+ $$$ThrowOnError(sc)
+ write "Module "_pCommandInfo("parameters","module")_" is now pinned"
+ return 1
+}
+
+ClassMethod UnPin(ByRef pCommandInfo) As %Status
+{
+ set sc = ##class(%IPM.Storage.PinnedModule).UnPinModule(pCommandInfo("parameters","module"))
+ $$$ThrowOnError(sc)
+ write "Module "_pCommandInfo("parameters","module")_" is now unpinned"
+ return 1
+}
+
}
diff --git a/src/cls/IPM/Storage/PinnedModule.cls b/src/cls/IPM/Storage/PinnedModule.cls
new file mode 100644
index 00000000..da153e8b
--- /dev/null
+++ b/src/cls/IPM/Storage/PinnedModule.cls
@@ -0,0 +1,96 @@
+Class %IPM.Storage.PinnedModule Extends (%Persistent, %JSON.Adaptor, %XML.Adaptor)
+{
+
+Parameter DEFAULTGLOBAL = "^IPM.Storage.PinnedModule";
+
+Parameter DOMAIN = "ZPM";
+
+Property Name As %String(MAXLEN = 128) [ Required ];
+
+Property PinnedVersion As %String(MAXLEN = 32) [ Required ];
+
+Property PinnedAt As %TimeStamp [ Required ];
+
+Property PinnedBy As %String;
+
+Index PinnedModuleIdx On Name [ IdKey, Unique ];
+
+ClassMethod IsModulePinned(pModule As %String = "") As %Boolean [ CodeMode = expression ]
+{
+$select($$$lcase(pModule)'="": ..%ExistsId($$$lcase(pModule)),1: 0)
+}
+
+ClassMethod GetAllPinnedModules(Output list)
+{
+ set tResult = ##class(%SQL.Statement).%ExecDirect(,"select Name, PinnedVersion from %IPM_Storage.PinnedModule")
+ $$$ThrowSQLIfError(tResult.%SQLCODE, tResult.%Message)
+ kill list
+ set width=0
+ while tResult.%Next(.tSC) {
+ $$$ThrowOnError(tSC)
+ set name=$zconvert(tResult.%Get("Name"),"l")
+ set list($increment(list))=$listbuild(name,$zconvert(tResult.%Get("PinnedVersion"),"l")_"-pinned")
+ if $length(name)>width set width=$length(name)
+ }
+ set list("width") = width
+}
+
+/// Pin Only Installed Modules
+ClassMethod PinModule(pModule As %String = "") As %Status
+{
+ set pModule=$$$lcase(pModule)
+ if ..IsModulePinned(pModule) {
+ return $$$ERROR($$$GeneralError,"Module "_pModule_" already pinned")
+ }
+ &SQL(SELECT ID INTO :id FROM %IPM_Storage.ModuleItem where name=:pModule)
+ if id = ""{
+ return $$$ERROR($$$GeneralError,"Module "_pModule_" not found")
+ }
+ set moduleObj = ##class(%IPM.Storage.Module).%OpenId(id)
+ set pinModule = ..%New()
+ set pinModule.Name= moduleObj.Name
+ set pinModule.PinnedVersion = moduleObj.VersionString
+ set pinModule.PinnedAt = $zdatetime($ztimestamp,3)
+ set pinModule.PinnedBy = $username
+ set sc = pinModule.%Save()
+ if $$$ISERR(sc) {
+ return sc
+ }
+ return $$$OK
+}
+
+/// UnPin Only Installed Modules
+ClassMethod UnPinModule(pModule As %String = "") As %Status
+{
+ set pModule = $$$lcase(pModule)
+ if '..IsModulePinned(pModule) {
+ return $$$ERROR($$$GeneralError,"Module "_pModule_" not currently pinned.")
+ }
+ return ..%DeleteId(pModule)
+}
+
+Storage Default
+{
+
+
+%%CLASSNAME
+
+
+PinnedVersion
+
+
+PinnedAt
+
+
+PinnedBy
+
+
+^IPM.Storage.PinnedModuleD
+PinnedModuleDefaultData
+^IPM.Storage.PinnedModuleD
+^IPM.Storage.PinnedModuleI
+^IPM.Storage.PinnedModuleS
+%Storage.Persistent
+}
+
+}
diff --git a/src/cls/IPM/Utils/Module.cls b/src/cls/IPM/Utils/Module.cls
index 8b87047d..a054f227 100644
--- a/src/cls/IPM/Utils/Module.cls
+++ b/src/cls/IPM/Utils/Module.cls
@@ -826,6 +826,8 @@ ClassMethod UninstallAll(
do ##class(%IPM.Main).GetListModules(,,.modList)
for i = 1:1:modList {
set name = $listget(modList(i),1)
+ ;prevent from uninstall the pinned module
+ continue:##class(%IPM.Storage.PinnedModule).IsModulePinned(name)
set simpleModList(name) = ""
// list of modules must be from here because modules without dependencies won't appear in %IPM_Storage.ModuleItem_Dependencies
set tModuleNames(name) = ""
diff --git a/tests/integration_tests/Test/PM/Integration/TestCLIPinCommand.cls b/tests/integration_tests/Test/PM/Integration/TestCLIPinCommand.cls
new file mode 100644
index 00000000..562e765d
--- /dev/null
+++ b/tests/integration_tests/Test/PM/Integration/TestCLIPinCommand.cls
@@ -0,0 +1,110 @@
+Class Test.PM.Integration.TestCLIPinCommand Extends %UnitTest.TestCase
+{
+
+/// Test.PM.Integration.Base
+Parameter ModuleFolder = "pin";
+
+/// lightweight library used for python pip installation and uninstallation.
+Parameter TestZPMModule = "web-fslog";
+
+// test pin command
+
+Method TestCLIPinCommand()
+{
+ set sc = $$$OK
+ set sc = ..InstallZPMModule()
+ do $$$AssertStatusOK(sc, "installing the module "_..#TestZPMModule)
+ set tCommand = "pin "_..#TestZPMModule
+ set sc = ..RunCommand(tCommand)
+ do $$$AssertStatusOK(sc, "pinned the module "_..#TestZPMModule)
+ do ..RePinThePinnedMoudle()
+ do ..PreventFromInstall()
+ do ..PreventFromReInstall()
+ do ..PreventFromUnInstall()
+ do ..PreventFromUpdate()
+ do ..CLIUnPinCommand()
+ quit sc
+}
+
+/// test unpin command
+Method CLIUnPinCommand()
+{
+ set sc = $$$OK
+ set tCommand ="unpin "_..#TestZPMModule
+ set sc = ..RunCommand(tCommand)
+ do $$$AssertStatusOK(sc, "unpin the module "_..#TestZPMModule)
+ do ..UnInstallZPMModule()
+ do $$$AssertStatusOK(sc, "uninstalled the module "_..#TestZPMModule)
+ do ..UnPinTheUnpinnedModule()
+ quit sc
+}
+
+Method UnPinTheUnpinnedModule()
+{
+ set tCommand ="unpin x-textpacakge"
+ set sc = ..RunCommand(tCommand)
+ do $$$AssertStatusNotOK(sc, "Unpin the unpinned module x-textpacakge")
+}
+
+Method RePinThePinnedMoudle()
+{
+ set tCommand ="pin "_..#TestZPMModule
+ set sc = ..RunCommand(tCommand)
+ do $$$AssertStatusNotOK(sc, "pin the already pinned module "_..#TestZPMModule)
+}
+
+/// list -p : display all pinned modules
+Method ListPinnedModules()
+{
+ set sc = ..RunCommand("list -p")
+ do $$$AssertStatusOK(sc, "list all the pinned "_..#TestZPMModule)
+ return sc
+}
+
+Method PreventFromInstall()
+{
+ set sc = ..RunCommand("install "_..#TestZPMModule)
+ do $$$AssertStatusNotOK(sc, "prevent from install the module"_..#TestZPMModule)
+ return sc
+}
+
+Method PreventFromReInstall()
+{
+ set sc = ..RunCommand("reinstall "_..#TestZPMModule)
+ do $$$AssertStatusNotOK(sc, "prevent from reinstall the module"_..#TestZPMModule)
+ return sc
+}
+
+Method PreventFromUnInstall()
+{
+ set sc = ..RunCommand("uninstall "_..#TestZPMModule)
+ do $$$AssertStatusNotOK(sc, "prevent from uninstall the module"_..#TestZPMModule)
+ return sc
+}
+
+Method PreventFromUpdate()
+{
+ set sc = ..RunCommand("update "_..#TestZPMModule)
+ do $$$AssertStatusNotOK(sc, "prevent from update the module"_..#TestZPMModule)
+}
+
+Method InstallZPMModule(pModule As %String = {..#TestZPMModule})
+{
+ set tCommand="install "_pModule
+ return ..RunCommand(tCommand)
+}
+
+Method UnInstallZPMModule(pModule As %String = "")
+{
+ set tCommand="uninstall "_pModule
+ return ..RunCommand(tCommand)
+}
+
+Method RunCommand(pCommand As %String)
+{
+ set sc= ##class(%IPM.Main).Shell(pCommand)
+ do $$$LogMessage("Run command: "_pCommand)
+ return sc
+}
+
+}
diff --git a/tests/unit_tests/Test/PM/Unit/CLI.cls b/tests/unit_tests/Test/PM/Unit/CLI.cls
index 2ea8e973..524428c1 100644
--- a/tests/unit_tests/Test/PM/Unit/CLI.cls
+++ b/tests/unit_tests/Test/PM/Unit/CLI.cls
@@ -2,6 +2,9 @@
Class Test.PM.Unit.CLI Extends %UnitTest.TestCase
{
+/// lightweight library used for python pip installation and uninstallation.
+Parameter TestZPMModule = "web-fslog";
+
Method TestParser()
{
@@ -217,10 +220,11 @@ Method CompareModifiers(
}
}
-Method RunCommand(pCommand As %String)
+Method RunCommand(pCommand As %String) As %Status
{
- do ##class(%IPM.Main).Shell(pCommand)
+ set sc = ##class(%IPM.Main).Shell(pCommand)
do $$$LogMessage("Run command: "_pCommand)
+ return sc
}
Method AssertNoException(pCommand As %String)
@@ -285,4 +289,28 @@ Method CompareArrays(
quit tEqual
}
+Method TestCLIPinnedCommand()
+{
+ set sc = $$$OK
+ set tCommandsList = $listbuild("list -p", "list -pinned")
+ set ptr=0
+ while $listnext(tCommandsList,ptr,tCommand) {
+ do ..RunCommand(tCommand)
+ }
+ set i = $increment(tCommands)
+ set tCommands(i) = "list-installed -p"
+ set tResults(tCommands)="list-installed"
+ set tResults(tCommands,"modifiers","pinned")=""
+ // Verify output matches
+ kill tParsedCommandInfo,tExpectedCommandInfo
+ do $$$AssertStatusOK(##class(%IPM.Main).%ParseCommandInput(tCommands(i),.tParsedCommandInfo))
+ merge tExpectedCommandInfo = tResults(i)
+ if $$$AssertTrue(..CompareArrays(.tParsedCommandInfo,.tExpectedCommandInfo,.tMessage),"Parsed correctly: "_tCommands(i)) {
+ do $$$LogMessage(tMessage)
+ write !,"Expected:",! zwrite tExpectedCommandInfo
+ write !,"Actual:",! zwrite tParsedCommandInfo
+ }
+ quit sc
+}
+
}