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
158 changes: 158 additions & 0 deletions download/downloadSelectedArtifactsAsZip/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
# Artifactory downloadSelectedArtifactsAsZip User Plugin

Bundle artifacts scattered across repos/versions into a single ZIP. Clients POST a JSON body with a `files` array; the plugin resolves every pattern, streams the archive back (or returns JSON errors), and records misses in headers. The API is intentionally designed to integrate smoothly with **IBM DevOps Deploy (UrbanCode Deploy)** so automated deployment processes can fetch curated bundles as a single step.

## Features

1. **Cross-repo bundling** – Mix and match repository keys, folders, and versions in one request.
2. **Path normalization** – ZIP entries start at `MVS/` or `HFS/`, mirroring mainframe datasets while hiding repo/version prefixes.
3. **Partial success reporting** – `206 Partial Content` + `X-Bundle-Missing` header lists unresolved patterns.
4. **In-memory ZIP delivery** – Builds the archive in-memory and returns it via the plugin bindings.

## Installation

### Plugin paths

| Artifactory version | Plugins directory |
| --- | --- |
| 7.x (default layout) | `$JFROG_HOME/artifactory/var/etc/artifactory/plugins/` |
| 6.x | `$ARTIFACTORY_HOME/etc/plugins/` |

Place **only** `downloadSelectedArtifactsAsZip.groovy` in the plugins directory (no sub-folder required at runtime).

---

### 1 – Bare-metal / VM (Linux)

```bash
cp downloadSelectedArtifactsAsZip.groovy \
$JFROG_HOME/artifactory/var/etc/artifactory/plugins/

curl -u admin:password -X POST \
"https://<host>/artifactory/api/plugins/reload"
```

---

### 2 – Docker (standalone container)

```bash
docker cp downloadSelectedArtifactsAsZip.groovy \
<container_name_or_id>:/opt/jfrog/artifactory/var/etc/artifactory/plugins/

docker exec <container_name_or_id> \
curl -s -u admin:password -X POST \
"http://localhost:8082/artifactory/api/plugins/reload"
```

---

### 3 – Docker Compose

```bash
docker compose cp downloadSelectedArtifactsAsZip.groovy \
artifactory:/opt/jfrog/artifactory/var/etc/artifactory/plugins/

docker compose exec artifactory \
curl -s -u admin:password -X POST \
"http://localhost:8082/artifactory/api/plugins/reload"
```

---

### 4 – Kubernetes (Helm chart deployment)

Replace `jfrog-platform` with your namespace and `jfrog-platform-artifactory-0` with your pod name as needed.

```bash
kubectl cp downloadSelectedArtifactsAsZip.groovy \
jfrog-platform/jfrog-platform-artifactory-0:/opt/jfrog/artifactory/var/etc/artifactory/plugins/ \
-c artifactory

kubectl exec -n jfrog-platform jfrog-platform-artifactory-0 -c artifactory -- \
curl -s -u admin:password -X POST \
"http://localhost:8082/artifactory/api/plugins/reload"
```

To find your pod name and namespace:
```bash
kubectl get pods -A | grep artifactory
```

---

### 5 – Artifactory as a Windows service

```powershell
Copy-Item downloadSelectedArtifactsAsZip.groovy `
-Destination "$env:JFROG_HOME\artifactory\var\etc\artifactory\plugins\"

Invoke-RestMethod -Method POST `
-Uri "https://<host>/artifactory/api/plugins/reload" `
-Credential (Get-Credential)
```

---

### Verify the plugin loaded

```bash
curl -u admin:password \
"https://<host>/artifactory/api/plugins/execute/downloadSelectedArtifactsAsZipInfo"
```

Expected response:
```json
{
"plugin": "downloadSelectedArtifactsAsZip",
"version": "1.0.0",
"endpoint": "POST /artifactory/api/plugins/execute/downloadSelectedArtifactsAsZip",
"info": "GET /artifactory/api/plugins/execute/downloadSelectedArtifactsAsZipInfo",
"body": "{ \"files\": [ { \"pattern\": \"<repoKey>/<path>\" }, ... ] }"
}
```

## Usage

- **POST** `/artifactory/api/plugins/execute/downloadSelectedArtifactsAsZip`
- **Body**
```json
{
"files": [
{ "pattern": "zos-repo/lv1-release-1/MVS/JCL/JMON" },
{ "pattern": "zos-repo/lv1-release-1/HFS/USS/bharat.text" }
]
}
```
- **Response codes**
| Scenario | Status | Body |
| --- | --- | --- |
| All artifacts found | `200 OK` | ZIP binary |
| Some missing | `206 Partial Content` | ZIP binary (`X-Bundle-Missing` header) |
| None found | `404 Not Found` | JSON `{ error, missing }` |
| Malformed body | `400 Bad Request` | JSON `{ error }` |

### Example curl
```bash
curl -u admin:password \
-X POST \
-H "Content-Type: application/json" \
-d '{
"files": [
{ "pattern": "zos-repo/lv1-release-1/MVS/JCL/JMON" },
{ "pattern": "zos-repo/lv1-release-1/HFS/USS/bharat.text" }
]
}' \
--output bundle.zip \
"https://<host>/artifactory/api/plugins/execute/downloadSelectedArtifactsAsZip"
```

### Example PowerShell
```powershell
Invoke-BundleDownload.ps1 `
-BaseUrl "https://<host>/artifactory" `
-Username "admin" `
-Password "password" `
-Patterns "zos-repo/lv1-release-1/MVS/JCL/JMON","zos-repo/lv1-release-1/HFS/USS/bharat.text" `
-OutputFile "bundle.zip"
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import groovy.json.JsonOutput
import groovy.json.JsonSlurper
import org.artifactory.repo.RepoPathFactory
import org.artifactory.resource.ResourceStreamHandle

import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream

/**
* Artifactory User Plugin: downloadSelectedArtifactsAsZip
*
* Endpoint : POST /artifactory/api/plugins/execute/downloadSelectedArtifactsAsZip
* Info : GET /artifactory/api/plugins/execute/downloadSelectedArtifactsAsZipInfo
* Auth : Standard Artifactory credentials (Basic / API-key header)
*
* Request body:
* {
* "files": [
* { "pattern": "zos-repo/lv1-release-1/MVS/JCL/JMON" },
* { "pattern": "zos-repo/lv1-release-1/HFS/USS/bharat.text" }
* ]
* }
*
* Pattern format : <repoKey>/<repoRelativePath>
* ZIP entry paths : stripped to MVS/... or HFS/...; full path kept as fallback.
*
* 200 – all files packed | 206 – partial (X-Bundle-Missing header set)
* 400 – bad request | 404 – nothing found | 500 – server error
*/

executions {

downloadSelectedArtifactsAsZip(httpMethod: 'POST', groups: ['readers']) { params, ResourceStreamHandle body ->

def (files, parseError) = parseRequest(body, log)
if (parseError) { badRequest(parseError); return }

def (entries, missing) = resolveEntries(files, repositories, log)
if (entries.isEmpty()) {
status = 404
message = toJson([error: 'None of the requested artifacts were found.', missing: missing])
return
}

int httpStatus = missing ? 206 : 200

message = buildZip(entries, repositories, log)
responseContentType = 'application/zip'
status = httpStatus

}

downloadSelectedArtifactsAsZipInfo(httpMethod: 'GET') { params ->
status = 200
responseContentType = 'application/json'
message = toJson([
plugin : 'downloadSelectedArtifactsAsZip',
version : '1.0.0',
endpoint: 'POST /artifactory/api/plugins/execute/downloadSelectedArtifactsAsZip',
info : 'GET /artifactory/api/plugins/execute/downloadSelectedArtifactsAsZipInfo',
body : '{ "files": [ { "pattern": "<repoKey>/<path>" }, ... ] }'
])
}
}

// ── Request parsing ─────────────────────────────────────────────────────────

private List parseRequest(ResourceStreamHandle body, log) {
if (!body) return [null, 'Request body is empty.']

String text
try {
text = new InputStreamReader(body.inputStream, 'UTF-8').text?.trim()
if (!text) return [null, 'Request body is empty.']
def json = new JsonSlurper().parseText(text)
if (!(json?.files instanceof List) || json.files.isEmpty())
return [null, '"files" array is missing or empty.']
return [json.files, null]
} catch (Exception e) {
log.error("parseRequest failed: ${e.message}")
return [null, "Could not parse request body: ${e.message}"]
}
}

// ── Artifact resolution ─────────────────────────────────────────────────────

private List resolveEntries(List files, repositories, log) {
def entries = []
def missing = []

files.each { f ->
String pattern = f?.pattern?.trim()
if (!pattern) return

int sep = pattern.indexOf('/')
if (sep < 0) { missing << pattern; return }

String repoKey = pattern[0..<sep]
String filePath = pattern[(sep + 1)..-1]
def repoPath = RepoPathFactory.create(repoKey, filePath)

if (!repositories.exists(repoPath)) {
log.warn("Not found: $pattern")
missing << pattern
return
}

entries << [pattern: pattern, repoPath: repoPath, entry: zipEntryName(filePath)]
}

return [entries, missing]
}

// ── ZIP building ────────────────────────────────────────────────────────────

private byte[] buildZip(List entries, repositories, log) {
def baos = new ByteArrayOutputStream()
def zos = new ZipOutputStream(baos)
try {
writeEntries(zos, entries, repositories, log)
zos.finish()
} finally {
try { zos.close() } catch (ignored) {}
}
log.info("Bundle: ${entries.size()} file(s), ${baos.size()} bytes")
return baos.toByteArray()
}

private void writeEntries(ZipOutputStream zos, List entries, repositories, log) {
byte[] buf = new byte[16384]
entries.each { e ->
def handle = repositories.getContent(e.repoPath)
try {
zos.putNextEntry(new ZipEntry(e.entry))
def is = handle.inputStream
int n
while ((n = is.read(buf)) != -1) zos.write(buf, 0, n)
zos.closeEntry()
log.info("Packed '${e.pattern}' → '${e.entry}'")
} finally {
handle.close()
}
}
}

// ── ZIP entry naming ────────────────────────────────────────────────────────

private String zipEntryName(String filePath) {
def m = filePath =~ /(?:.*?)\/(MVS|HFS)\/(.*)/
if (m) return sanitize("${m[0][1]}/${m[0][2]}")
if (filePath =~ /^(MVS|HFS)\//) return sanitize(filePath)
return sanitize(filePath)
}

private String sanitize(String name) {
name?.replace('\\', '/')
?.replaceAll(/^\/+/, '')
?.replace('../', '')
?.replace('..', '') ?: 'unknown'
}

// ── Utilities ───────────────────────────────────────────────────────────────

private String toJson(Object obj) { JsonOutput.toJson(obj) }

private void badRequest(String msg) {
status = 400
message = toJson([error: msg])
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import spock.lang.Specification

class DownloadSelectedArtifactsAsZipTest extends Specification {
def 'not implemented plugin test'() {
when:
throw new Exception('Not implemented.')
then:
false
}
}
Loading