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
7 changes: 5 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- #935: Adding a generic JFrog Artifactory tarball resource processor for bundling artifact with a package and deploying it to a final location on install.
- #950: Added support for listing installed Python packages using `list -python`, `list -py` and `list-installed -python`
- #822: The CPF resource processor now supports system expressions and macros in CPF merge files
- #578 Added functionality to record and display IPM history of install, uninstall, load, and update
- #578: Added functionality to record and display IPM history of install, uninstall, load, and update
- #961: Adding creation of a lock file for a module by using the `-create-lockfile` flag on install.
- #959: In ORAS repos, external name can now be used interchangeably with (default) name for `install` and `update`, i.e. a module published with its (default) name can be installed using its external name.
- #951: The `unpublish` command will skip user confirmation prompt if the `-force` flag is provided.

### Changed
- #316: All parameters, except developer mode, included with a `load`, `install` or `update` command will be propagated to dependencies
Expand All @@ -27,7 +29,7 @@ lock contention by bypassing IRIS compiler.
- Have better caching of results for module searches by collapsing search expressions (reducing expressions that are intersections).

### Removed
- #938 Removed secret flag NewVersion handling in %Publish()
- #938: Removed secret flag NewVersion handling in %Publish()

### Fixed
- #943: The `load` command when used with a GitHub repository URL accepts a `branch` argument again
Expand All @@ -37,6 +39,7 @@ lock contention by bypassing IRIS compiler.
- #965: FileCopy on a directory with a Name without the leading slash now works
- #937: Publishing a module with a `<WebApplication>` containing a `Path` no longer errors out
- #957: Improved error messages for OS command execution. Now, when a command fails, the error message includes the full command and its return code. Also fixed argument separation for the Windows `attrib` command and removed misleading error handling for missing commands.
- #789: Fix error when listing modules for an ORAS repo with a specified namespace.

### Deprecated
- #828: The `CheckStatus` flag for `<Invoke>` action has been deprecated. Default behavior is now to always check the status of the method if and only if the method signature returns %Library.Status
Expand Down
38 changes: 22 additions & 16 deletions src/cls/IPM/Main.cls
Original file line number Diff line number Diff line change
Expand Up @@ -179,10 +179,10 @@ This command is an alias for `module-action module-name makedeployed`
Delete package from repository
</description>
<example description="Delete all versions of the package &quot;MyModuleName&quot; from the repository">unpublish MyModuleName all</example>
<example description="Delete version &quot;1.0.0&quot; of the package &quot;MyModuleName&quot; from the repository">unpublish MyModuleName 1.0.0</example>
<example description="Delete version &quot;1.0.0&quot; of the package &quot;MyModuleName&quot; from the repository named MyRepo">unpublish MyRepo/MyModuleName 1.0.0</example>
<parameter name="module" required="true" description="Name of module on which to perform unpublish actions" />
<parameter name="version" required="true" description="Version of module on which to perform unpublish actions. Use &quot;all&quot; to delete all versions of the package" />

<modifier name="force" aliases="f" value="false" description="Will delete module from the repository without prompting user for confirmation. Still requires authorization from the repository" />
<modifier name="quiet" aliases="q" dataAlias="Verbose" dataValue="0" description="Produces minimal output from the command." />
<modifier name="verbose" aliases="v" dataAlias="Verbose" dataValue="1" description="Produces verbose output from the command." />
</command>
Expand Down Expand Up @@ -2154,6 +2154,8 @@ ClassMethod ShowModulesForRepository(
set list(list, "AllVersions") = tRes.AllVersions
}
}
// Throw an error if there are no results and tSC is set
$$$ThrowOnError(tSC)
set list("width") = width
write !
do ..DisplayModules(.list)
Expand Down Expand Up @@ -2506,25 +2508,29 @@ ClassMethod Unpublish(ByRef pCommandInfo) [ Internal ]

if (isEnabled) {
set tResult = 0
if ($$$lcase(tVersion)="all") {
write $$$FormattedLine($$$Red, "Deleting a package and all its versions is an irreversible action")
set tHelp = "Enter ""Yes"" if you want to delete all package versions."
set tMsg = "Are you sure you want to delete all versions of the package """_tModuleName_""" from registry """_tServer.Name_""" ("_tServer.URL_")?"
} else {
write $$$FormattedLine($$$Red, "Deleting a package version is an irreversible action")
set tHelp = "Enter ""Yes"" if you want to delete selected package version."
set tMsg = "Are you sure you want to delete the package """_tModuleName_" "_tVersion_""" from registry """_tServer.Name_""" ("_tServer.URL_")?"
}
set force = $data(pCommandInfo("modifiers","force"))
if 'force {
if ($$$lcase(tVersion)="all") {
write $$$FormattedLine($$$Red, "Deleting a package and all its versions is an irreversible action!")
set tHelp = "Enter ""Yes"" if you want to delete all package versions."
set tMsg = "Are you sure you want to delete all versions of the package """_tModuleName_""" from registry """_tServer.Name_""" ("_tServer.URL_")?"
} else {
write $$$FormattedLine($$$Red, "Deleting a package version is an irreversible action!")
set tHelp = "Enter ""Yes"" if you want to delete selected package version."
set tMsg = "Are you sure you want to delete the package """_tModuleName_" version "_tVersion_""" from registry """_tServer.Name_""" ("_tServer.URL_")?"
}

set tResponse = ##class(%Library.Prompt).GetYesNo(tMsg,.tResult,.tHelp)
set tResponse = ##class(%Library.Prompt).GetYesNo(tMsg,.tResult,.tHelp)

if (tResponse '= $$$SuccessResponse) {
$$$ThrowStatus($$$ERROR($$$GeneralError,"Operation cancelled."))
if (tResponse '= $$$SuccessResponse) {
$$$ThrowStatus($$$ERROR($$$GeneralError,"Operation cancelled."))
}
}

if (tResult) {
if (tResult || force) {
$$$ThrowOnError(tManager.Unpublish(tServer.Name, tModuleName, tVersion))
write !!,"Package deleted"
set msg = "Package deleted" _ $case(force, 1:" forcefully without user confirmation", :"")
write !!, msg
}
} else {
write !,"The package could not be deleted (the registry denied the request)",!
Expand Down
132 changes: 87 additions & 45 deletions src/cls/IPM/Repo/Oras/PackageService.cls
Original file line number Diff line number Diff line change
Expand Up @@ -107,13 +107,13 @@ Method ListModulesFromTagString(
set allVersionsList = ..AggregatePlatformVersions($listfromstring(allTagsString, ", "), .aggregatedPlatformVersion)
set pointer = 0
while $listnext(allVersionsList,pointer,moduleVersion) {
#; filter by version
// filter by version
set tVersion = ##class(%IPM.General.SemanticVersion).FromString($$$Tag2Semver(moduleVersion))
if 'tVersion.Satisfies(semverExpr) {
continue
}

#; Special case: if we provided an explicit build number, require it.
// Special case: if we provided an explicit build number, require it.
if ##class(%IPM.General.SemanticVersion).IsValid(searchCriteria.VersionExpression,.specificVersion) && (specificVersion.Build '= "") && (specificVersion.Build '= tVersion.Build) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Sort of a side question since it's not in your review directly, but why don't we check whether the version expression in the search criteria is valid first thing in this method and throw an error if not?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Good question! IsValid() checks whether it is a fully formed expression, e.g. 1.0.0, but the search criteria may include wildcards or other characters, e.g. 1.0.x., so we only want to make sure it is valid if it is a specific version

continue
}
Expand All @@ -126,11 +126,53 @@ Method ListModulesFromTagString(
set tag = tag _ $$$OrasTagPlatformSeparator _ platformVersion
}

#; get metadata from annotations
// get metadata from annotations
set metadata = ..GetPackageMetadata(..Location, name, tag, "", client)
set artifactMetadata = ##class(%IPM.Repo.Oras.ArtifactMetadata).%New()
$$$ThrowOnError(artifactMetadata.%JSONImport(metadata))

// Filter by module name if requested - check both Name and ExternalName
if (searchCriteria.Name '= "") {
// Extract the package name without namespace prefix for comparison
// Package name from ORAS may be "namespace/modulename" or just "modulename"
set packageNameWithoutNamespace = $piece(name, "/", *)

// First check if package name already matches
set packageNameMatches = ($$$lcase(packageNameWithoutNamespace) = $$$lcase(searchCriteria.Name))

// If packageNameMatches is true, proceed without additional checks
// Otherwise fetch module.xml to check namespaced Name and ExternalName
if 'packageNameMatches {
// Note: pass empty namespace since 'name' already includes the full path
set moduleXMLText = ..GetModuleXML(..Location, name _ ":" _ tag, "", ..Username, ..Password, ..Token, ..TokenAuthMethod)

if (moduleXMLText '= "") {
// Parse module.xml
try {
set moduleObj = ##class(%IPM.Utils.Module).GetModuleObjectFromString(moduleXMLText, .found)
} catch ex {
// if a single module.xml fails to parse, move onto the next one instead of failing completely
continue
}
if 'found {
continue
}

// Check if search name matches either Name or ExternalName (case-insensitive)
set matchesName = ($$$lcase(moduleObj.Name) = $$$lcase(searchCriteria.Name))
set matchesExternalName = ((moduleObj.ExternalName '= "") && ($$$lcase(moduleObj.ExternalName) = $$$lcase(searchCriteria.Name)))

// Only include if at least one matches
if 'matchesName && 'matchesExternalName {
continue
}
} else {
// No module.xml available - skip this module
continue
}
}
}

set tModRef = ##class(%IPM.Storage.ModuleInfo).%New()
// `artifactMetadata.ImageTitle` can be different from `name`. E.g., when the module was simply "moved" from elsewhere under a different name.
set tModRef.Name = name
Expand Down Expand Up @@ -162,48 +204,32 @@ Method ListModulesFromTagString(
}
}

/// Lists the modules available in this repository that satisfy the search criteria
/// Note: for ORAS repositories only, the internal name and external name can be used interchangeably
Method ListModules(pSearchCriteria As %IPM.Repo.SearchCriteria) As %ListOfObjects(ELEMENTTYPE="%IPM.Storage.ModuleInfo")
{
#; Get ORAS client
// Get ORAS client
set client = ..GetClient(..Location, ..Username, ..Password, ..Token, ..TokenAuthMethod)

#; Parse search criteria
set name = $$$lcase(pSearchCriteria.Name)
// Parse search criteria
set tVersionExpression = pSearchCriteria.VersionExpression
set tSC = ##class(%IPM.General.SemanticVersionExpression).FromString(pSearchCriteria.VersionExpression, .tVersionExpression)
$$$ThrowOnError(tSC)

#; If namespace is defined, add it to the package URI being searched for
if (name'="") && (..Namespace'="") {
set name = ..AppendURIs(..Namespace, name)
}

#; Get all modules
// Collect list of modules
set tList = ##class(%Library.ListOfObjects).%New()

#; get all versions
// When no namespace is specified, we can only call "v2/_catalog" to get all the packages. In case of error, fail descriptively
if (..Namespace = "") {
set request = ..GetHttpRequest()
#; Make GET request
// response is a JSON structure like {"repositories":["package1", "package2", ...]}
set tSC=request.Get(..PathPrefix _ "/v2/_catalog")
$$$ThrowOnError(tSC)
set response=request.HttpResponse
if (response.StatusCode'=200) {
// TODO improve error processing
set msg = "Error: ORAS namespace is not set and the call to /v2/_catalog endpoint failed"
set msg = msg _ $char(10,13) _ "Either set an ORAS namespace or ensure the ORAS server supports the /v2/_catalog endpoint"
set msg = msg _ $char(10,13) _ "Response Code: "_response.StatusCode _ " - " _ response.Data.Read()
$$$ThrowStatus($$$ERROR($$$GeneralError, msg))
}

#; Handle results
set json = ""
while 'response.Data.AtEnd {
set json = json _ response.Data.Read()
}
set data = ##class(%DynamicAbstractObject).%FromJSON(json)
// Use /v2/_catalog to enumerate all packages, then filter appropriately
Copy link
Contributor

Choose a reason for hiding this comment

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

My memory is very rusty on this, but I think that not all ORAS registries support /v2/_catalog and the intent here was:

  • Support registries without /v2/_catalog if configured with a namespace
  • Support registries registries with /v2/_catalog with/without a namespace.

This would seem to break the first case. It would be good if you could dig on this a tiny bit to confirm.

Copy link
Collaborator Author

@isc-dchui isc-dchui Dec 17, 2025

Choose a reason for hiding this comment

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

@isc-tleavitt Ok after a bit of digging, you're correct that /v2/_catalog is an optional spec that not every registry supports. Unfortunately the OCI spec does not have a replacement. Individual registries may implement their own version, for example Docker Hub has https://hub.docker.com/v2/repositories/<namespace>/ but there is no standardization of what the endpoint is and what it returns. This does explain why searching in a namespace without a name has been broken: there is no registry-agnostic way of doing it without /v2/_catalog.

Not too sure what the correct approach is. I think we should use this endpoint if available, but then do we implement registry-specific handling if it is not?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I've added some special handling to cover the case where /v2/_catalog is unavailable but name is specified. External name and default name won't be interchangeable in this case though

Copy link
Contributor

@isc-tleavitt isc-tleavitt Dec 18, 2025

Choose a reason for hiding this comment

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

@isc-dchui I don't want to do anything registry-specific, but if there is a generic way to work with a namespace and name, and without /v2/_catalog, I'd like to support it (as it seems we did prior to this change).

Copy link
Contributor

Choose a reason for hiding this comment

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

Although I suppose as long as it works with zot and artifactory we're fine.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Both zot and artifactory support the /v2/_catalog endpoint so we're good there at least

// Note: not every registry supports this endpoint, but without it, searching becomes much harder without using registry-specific endpoints
set request = ..GetHttpRequest()
// Make GET request
// response is a JSON structure like {"repositories":["package1", "package2", ...]}
set tSC=request.Get(..PathPrefix _ "/v2/_catalog")
$$$ThrowOnError(tSC)
set response=request.HttpResponse
if (response.StatusCode=200) {
// Handle results
set data = ##class(%DynamicAbstractObject).%FromJSON(response.Data)
set iter = data.repositories.%GetIterator()
// Iterate through each package
// key is the index
Expand All @@ -213,22 +239,38 @@ Method ListModules(pSearchCriteria As %IPM.Repo.SearchCriteria) As %ListOfObject
if (package="") {
continue
}
#; filter by module name if requested
if (name'="") && (package'=name) {
continue

// If namespace is configured, only consider packages in that namespace
if (..Namespace '= "") {
set namespacePrefix = ..Namespace _ "/"
// Skip packages not in this namespace
if ($extract(package, 1, $length(namespacePrefix)) '= namespacePrefix) {
continue
}
}
#; collect all versions for this package in tList

// collect all versions for this package in tList
do ..ListModulesFromTagString(tVersionExpression, client, pSearchCriteria, package, .tList)
}
} else {
// TODO: Make this work properly for the case where name is empty (searching in a repo with a namespace)
do ..ListModulesFromTagString(tVersionExpression, client, pSearchCriteria, name, .tList)
// Endpoint failed, but if the name is specified, still try to find it
set name = pSearchCriteria.Name
if (name '= "") {
if ..Namespace '= "" {
set name = ..AppendURIs(..Namespace, name)
}
do ..ListModulesFromTagString(tVersionExpression, client, pSearchCriteria, name, .tList)
} else {
// otherwise error out
set msg = "Error: Call to /v2/_catalog endpoint failed. This registry may not support it."
set msg = msg _ $char(10,13) _ "Response Code: "_response.StatusCode _ " - " _ response.ReasonPhrase
$$$ThrowStatus($$$ERROR($$$GeneralError, msg))
}
}
return tList
}

// ** ORAS FUNCTIONS **

/// ** ORAS FUNCTIONS **
/// Returns an authenticated ORAS client
ClassMethod GetClient(
registry As %String,
Expand Down Expand Up @@ -391,8 +433,8 @@ ClassMethod GetModuleXML(
annotations = manifest.get('annotations', {})
module_xml = annotations.get('com.intersystems.ipm.module.v1+xml')
if module_xml:
# remove all whitespace
return "".join(module_xml.split())
# remove extraneous whitespace
return module_xml.strip()
except Exception as ex:
print("Exception: %s" % str(ex))

Expand Down
15 changes: 14 additions & 1 deletion src/cls/IPM/Utils/Module.cls
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,19 @@ ClassMethod GetModuleObjectFromStream(
return $get(moduleObj)
}

/// Given a string, tries to correlate it to an instance of <class>%IPM.Storage.Module</class>.
/// Throws errors.
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think it's great to comment explicitly on methods that throw errors. Would it be helpful for consistency to also comment on other methods on this PR that throw errors? For example ListModules() above throws errors but lacks this comment

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

So the "Throws Errors" comment is only on the GetModuleObjectFrom methods and I just copied it over. Not too sure how I feel about it on all methods as often the code is pretty clear about throwing errors.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I think if anything, its worth removing the throws errors comment. Only needed if its a specific error code we want to highlight

ClassMethod GetModuleObjectFromString(
moduleXML As %String,
Output found As %Boolean) As %IPM.Storage.Module
{
// Create stream from XML text
set stream = ##class(%Stream.GlobalCharacter).%New()
do stream.Write(moduleXML)

return ..GetModuleObjectFromStream(stream, .found)
}

/// Returns a semantic version expression capturing all version requirements for a given module name in the current namespace.
/// A list of modules to exclude may be provided (for example, if these modules would be updated at the same time).
ClassMethod GetRequiredVersionExpression(
Expand Down Expand Up @@ -1132,7 +1145,7 @@ ClassMethod LoadNewModule(

if foundNewModuleObj {
// Check that on an update command with -path flag, the module in the path matches the base module specified for update.
if (commandLineModuleName '= "") && (newModuleObj.Name '= commandLineModuleName) {
if (commandLineModuleName '= "") && (newModuleObj.Name '= commandLineModuleName) && (newModuleObj.ExternalName '= commandLineModuleName){
set msg = $$$FormatText("The specified path does not contain a module matching the specified module to update. Module in path = %1. Specified module = %2.",newModuleObj.Name,commandLineModuleName)
$$$ThrowStatus($$$ERROR($$$GeneralError, msg))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,27 +9,27 @@ Parameter ExternalName = "ext-name-mod-action";

Method TestExternalNameModuleAction()
{
Set dir = ..GetModuleDir(..#Folder)
Set sc = ##class(%IPM.Main).Shell("load "_dir)
Do $$$AssertStatusOK(sc, "Successfully loaded module")
set dir = ..GetModuleDir(..#Folder)
set sc = ##class(%IPM.Main).Shell("load "_dir)
do $$$AssertStatusOK(sc, "Successfully loaded module")

Set sc = ##class(%IPM.Main).Shell(..#DefaultName_" greeting")
Do $$$AssertStatusOK(sc, "Successfully executed action by default name")
set sc = ##class(%IPM.Main).Shell(..#DefaultName_" greeting")
do $$$AssertStatusOK(sc, "Successfully executed action by default name")

Set sc = ##class(%IPM.Main).Shell(..#ExternalName_" greeting")
Do $$$AssertStatusOK(sc, "Successfully executed action by external name")
set sc = ##class(%IPM.Main).Shell(..#ExternalName_" greeting")
do $$$AssertStatusOK(sc, "Successfully executed action by external name")

Set sc = ##class(%IPM.Main).Shell(..#DefaultName_" package")
Do $$$AssertStatusOK(sc, "Successfully package by default name")
set sc = ##class(%IPM.Main).Shell(..#DefaultName_" package")
do $$$AssertStatusOK(sc, "Successfully package by default name")

Set sc = ##class(%IPM.Main).Shell(..#ExternalName_" package")
Do $$$AssertStatusOK(sc, "Successfully package by external name")
set sc = ##class(%IPM.Main).Shell(..#ExternalName_" package")
do $$$AssertStatusOK(sc, "Successfully package by external name")

Set sc = ##class(%IPM.Main).Shell("package "_..#DefaultName)
Do $$$AssertStatusOK(sc, "Successfully package by default name")
set sc = ##class(%IPM.Main).Shell("package "_..#DefaultName)
do $$$AssertStatusOK(sc, "Successfully package by default name")

Set sc = ##class(%IPM.Main).Shell("package "_..#ExternalName)
Do $$$AssertStatusOK(sc, "Successfully package by external name")
set sc = ##class(%IPM.Main).Shell("package "_..#ExternalName)
do $$$AssertStatusOK(sc, "Successfully package by external name")
}

}
Loading