Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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,7 +13,9 @@ 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
- #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 @@ -22,7 +24,7 @@ to load using multicompile instead of trying to do own multi-threading of item l
lock contention by bypassing IRIS compiler.

### 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 @@ -32,6 +34,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
33 changes: 18 additions & 15 deletions src/cls/IPM/Main.cls
Original file line number Diff line number Diff line change
Expand Up @@ -178,10 +178,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 @@ -2503,23 +2503,26 @@ 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_" "_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"
}
Expand Down
155 changes: 106 additions & 49 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) {
continue
}
Expand All @@ -126,11 +126,73 @@ 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 = name
if (name [ "/") {
#; Extract everything after the last "/"
for i=$length(name):-1:1 {
if ($extract(name, i) = "/") {
set packageNameWithoutNamespace = $extract(name, i+1, *)
quit
}
}
}

// First check if package name already matches
if ($$$lcase(packageNameWithoutNamespace) = $$$lcase(searchCriteria.Name)) {
set packageNameMatches = 1
} else {
set packageNameMatches = 0
}

if 'packageNameMatches {
// Package name doesn't match - fetch module.xml to check namespaced Name and ExternalName
// 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 = 0
set matchesExternalName = 0

if ($$$lcase(moduleObj.Name) = $$$lcase(searchCriteria.Name)) {
set matchesName = 1
}
if (moduleObj.ExternalName '= "") && ($$$lcase(moduleObj.ExternalName) = $$$lcase(searchCriteria.Name)) {
set matchesExternalName = 1
}

// Only include if at least one matches
if 'matchesName && 'matchesExternalName {
continue
}
} else {
// No module.xml available - skip this module
continue
}
}
// If packageNameMatches is true, proceed without additional checks
}

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,73 +224,68 @@ 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
set client = ..GetClient(..Location, ..Username, ..Password, ..Token, ..TokenAuthMethod)

#; Parse search criteria
set name = $$$lcase(pSearchCriteria.Name)
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
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))
}
// 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

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: Call to /v2/_catalog endpoint failed"
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()
#; Handle results
set json = ""
while 'response.Data.AtEnd {
set json = json _ response.Data.Read()
}
set data = ##class(%DynamicAbstractObject).%FromJSON(json)
set iter = data.repositories.%GetIterator()
// Iterate through each package
// key is the index
// package is the package name
// type is always "string"
while iter.%GetNext(.key, .package, .type) {
if (package="") {
continue
}
set data = ##class(%DynamicAbstractObject).%FromJSON(json)
set iter = data.repositories.%GetIterator()
// Iterate through each package
// key is the index
// package is the package name
// type is always "string"
while iter.%GetNext(.key, .package, .type) {
if (package="") {
continue
}
#; filter by module name if requested
if (name'="") && (package'=name) {

// 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
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)

// collect all versions for this package in tList
do ..ListModulesFromTagString(tVersionExpression, client, pSearchCriteria, package, .tList)
}
return tList
}

// ** ORAS FUNCTIONS **

/// ** ORAS FUNCTIONS **
/// Returns an authenticated ORAS client
ClassMethod GetClient(
registry As %String,
Expand Down Expand Up @@ -391,8 +448,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
22 changes: 11 additions & 11 deletions src/cls/IPM/Storage/Module.cls
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Property VersionString As %String(MAXLEN = 100, XMLNAME = "Version") [ InitialEx
/// Does not need comparison method to be code generated because that comparing <property>VersionString</property> is good enough.
Property Version As %IPM.General.SemanticVersion(ForceCodeGenerate = 0, XMLPROJECTION = "NONE") [ Required ];

Property ExternalName As %String(MAXLEN = 255);
Property ExternalName As %IPM.DataType.ModuleName;

Index ExternalName On ExternalName [ Unique ];

Expand Down Expand Up @@ -159,12 +159,12 @@ Method HandleAllUpdateSteps(
}

/// For a given version, seed or execute update steps listed in the module's update package version class
///
///
/// If seeding: For only steps that don't have a TimeStampEnd value or have an errored status, set it's TimeStampEnd to the current time
///
///
/// If executing: Only start running steps at the first one found not to have a TimeStampEnd value or if an errored step is found
/// After finding this step, run it and all subsequent steps, regardless of if they have been run or not.
///
///
/// newStepToApply - Gets marked as true to tell us to run all remaining steps in the list, whether or not they have been run/seeded before
/// Can carry this over to subsequent calls by passing the newStepToApply variable to those HandleUpdateStepsFromList() calls
Method HandleUpdateStepsFromList(
Expand Down Expand Up @@ -1336,7 +1336,7 @@ Method OverrideLifecycleClassSet(pValue As %Dictionary.Classname) As %Status

/// This callback method is invoked by the <METHOD>%New</METHOD> method to
/// provide notification that a new instance of an object is being created.
///
///
/// <P>If this method returns an error then the object will not be created.
/// <p>It is passed the arguments provided in the %New call.
/// When customizing this method, override the arguments with whatever variables and types you expect to receive from %New().
Expand All @@ -1352,7 +1352,7 @@ Method %OnNew() As %Status [ Private, ServerOnly = 1 ]

/// This callback method is invoked by the <METHOD>%Open</METHOD> method to
/// provide notification that the object specified by <VAR>oid</VAR> is being opened.
///
///
/// <P>If this method returns an error then the object will not be opened.
Method %OnOpen() As %Status [ Private, ServerOnly = 1 ]
{
Expand Down Expand Up @@ -1382,7 +1382,7 @@ Method %OnOpen() As %Status [ Private, ServerOnly = 1 ]

/// This callback method is invoked by the <METHOD>%ValidateObject</METHOD> method to
/// provide notification that the current object is being validated.
///
///
/// <P>If this method returns an error then <METHOD>%ValidateObject</METHOD> will fail.
Method %OnValidateObject() As %Status [ Private, ServerOnly = 1 ]
{
Expand Down Expand Up @@ -1471,7 +1471,7 @@ Method %OnValidateObject() As %Status [ Private, ServerOnly = 1 ]
/// either because %Save() was invoked on this object or on an object that references this object.
/// %OnAddToSaveSet can modify the current object. It can also add other objects to the current
/// SaveSet by invoking %AddToSaveSet or remove objects by calling %RemoveFromSaveSet.
///
///
/// <P>If this method returns an error status then %Save() will fail and the transaction
/// will be rolled back.
Method %OnAddToSaveSet(
Expand Down Expand Up @@ -1506,7 +1506,7 @@ Method %OnAddToSaveSet(
}

/// Get an instance of an XML enabled class.<br><br>
///
///
/// You may override this method to do custom processing (such as initializing
/// the object instance) before returning an instance of this class.
/// However, this method should not be called directly from user code.<br>
Expand Down Expand Up @@ -1736,9 +1736,9 @@ Method %Evaluate(
/// This callback method is invoked by the <METHOD>%Save</METHOD> method to
/// provide notification that the object is being saved. It is called before
/// any data is written to disk.
///
///
/// <P><VAR>insert</VAR> will be set to 1 if this object is being saved for the first time.
///
///
/// <P>If this method returns an error then the call to <METHOD>%Save</METHOD> will fail.
Method %OnBeforeSave(insert As %Boolean) As %Status [ Private, ServerOnly = 1 ]
{
Expand Down
Loading