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 +} + }