From 2f4f5b1ac9c86d62b577fcc29fbe550a092da1b4 Mon Sep 17 00:00:00 2001 From: Tom Sellman Date: Fri, 21 Mar 2025 16:21:29 +0000 Subject: [PATCH] Support plugin download from OCI registry using ORAS Signed-off-by: Tom Sellman --- modules/nf-commons/build.gradle | 1 + .../plugin/OrasPluginDownloader.groovy | 66 +++++++++++++++++++ .../main/nextflow/plugin/PluginUpdater.groovy | 23 +++++++ 3 files changed, 90 insertions(+) create mode 100644 modules/nf-commons/src/main/nextflow/plugin/OrasPluginDownloader.groovy diff --git a/modules/nf-commons/build.gradle b/modules/nf-commons/build.gradle index 45ef5ac464..6a6e0cbf28 100644 --- a/modules/nf-commons/build.gradle +++ b/modules/nf-commons/build.gradle @@ -35,6 +35,7 @@ dependencies { api 'dev.failsafe:failsafe:3.1.0' // patch gson dependency required by pf4j api 'com.google.code.gson:gson:2.10.1' + implementation 'land.oras:oras-java-sdk:0.2.4' /* testImplementation inherited from top gradle build file */ testImplementation(testFixtures(project(":nextflow"))) diff --git a/modules/nf-commons/src/main/nextflow/plugin/OrasPluginDownloader.groovy b/modules/nf-commons/src/main/nextflow/plugin/OrasPluginDownloader.groovy new file mode 100644 index 0000000000..d0a9675f1e --- /dev/null +++ b/modules/nf-commons/src/main/nextflow/plugin/OrasPluginDownloader.groovy @@ -0,0 +1,66 @@ +package nextflow.plugin + +import groovy.transform.CompileStatic +import land.oras.ContainerRef +import land.oras.Manifest +import land.oras.Registry +import org.pf4j.PluginRuntimeException + +import java.nio.file.Files +import java.nio.file.Path + +/** + * Support downloading plugins from an ORAS (OCI Registry As Storage) + * location. + * + * NOTE: logically this should implement pf4j's FileDownloader interface, + * but unfortunately that interface uses java.net.URL which doesn't support + * custom URI protocols. + * + * See https://oras.land/ + */ +@CompileStatic +class OrasPluginDownloader { + private static final String PROTOCOL = "oras://" + private static final String ARTIFACT_TYPE = "application/vnd.nextflow.plugin+zip" + + static boolean canDownload(String uri) { + return uri.startsWith(PROTOCOL) + } + + Path downloadFile(String uri) throws IOException { + if (!canDownload(uri)) { + throw new PluginRuntimeException("URI protocol not supported by ORAS downloader: {}", uri); + } + return downloadOras(uri) + } + + private static Path downloadOras(String uri) { + Path destination = Files.createTempDirectory("oras-update-downloader") + destination.toFile().deleteOnExit() + + // strip the oras:// prefix since it's just a marker + ContainerRef source = ContainerRef.parse(uri.replaceFirst("oras://", "")) + Registry registry = Registry.Builder.builder() + // use http on localhost, require https everywhere else + .withInsecure(source.registry.startsWith("localhost:")) + .build() + + // grab oras metadata about the url + Manifest manifest = registry.getManifest(source) + String type = manifest.getArtifactType() + if (type != ARTIFACT_TYPE) { + throw new PluginRuntimeException("Not a nextflow plugin: {}", uri) + } + // get the filename of the plugin artifact, which should be in layer 0 + def layers = manifest.getLayers() + if (layers.size() < 1) { + throw new PluginRuntimeException("Unable to find nextflow plugin at: {}", uri) + } + String filename = layers[0].annotations['org.opencontainers.image.title'] + + // download the plugin artifact + registry.pullArtifact(source, destination, true) + return destination.resolve(filename) + } +} diff --git a/modules/nf-commons/src/main/nextflow/plugin/PluginUpdater.groovy b/modules/nf-commons/src/main/nextflow/plugin/PluginUpdater.groovy index 19f765affc..b97f4a9180 100644 --- a/modules/nf-commons/src/main/nextflow/plugin/PluginUpdater.groovy +++ b/modules/nf-commons/src/main/nextflow/plugin/PluginUpdater.groovy @@ -17,6 +17,8 @@ package nextflow.plugin +import org.pf4j.update.FileVerifier + import static java.nio.file.StandardCopyOption.* import java.nio.file.Files @@ -244,6 +246,27 @@ class PluginUpdater extends UpdateManager { return Failsafe.with(policy).get(supplier) } + // override this method from the parent class to support oras:// downloads + @Override + protected Path downloadPlugin(String id, String version) { + try { + def release = findReleaseForPlugin(id, version) + + // pf4j's FileDownloader interface uses java.net.URL, which + // doesn't support custom protocols. This means we can't use + // it for oras:// URIs and instead have to call the ORAS downloader + // directly in such cases + Path downloaded = OrasPluginDownloader.canDownload(release.url) + ? new OrasPluginDownloader().downloadFile(release.url) + : getFileDownloader(id).downloadFile(new URL(release.url)) + + getFileVerifier(id).verify(new FileVerifier.Context(id, release), downloaded); + return downloaded; + } catch (IOException e) { + throw new PluginRuntimeException(e, "Error during download of plugin {}", id); + } + } + protected RetryPolicy retryPolicy(String id, String version) { final listener = new dev.failsafe.event.EventListener>() { @Override