Artifactory Pro: Clean up Branch-Specific Artifacts on Pull Request in Bitbucket

By Gerald Mücke | September 16, 2018

Artifactory Pro: Clean up Branch-Specific Artifacts on Pull Request in Bitbucket

Haben sie auch einen automatischen Branch Build, d.h. einen Build der selbstständig erkennt, ob ein neuer Branch angelegt wurde und darauf hin die Build Pipeline für diesen neuen Branch dupliziert? Dann haben sie vermutlich auch Branch-spezifische Artefakte, oder zumindest Snapshots davon.

Ohne Branch-speizifische Versionen lassen sich Artefakte aus Multi-Branch Builds im Repository nicht mehr auseinanderhalten, schliesslich heissen alle “SNAPSHOT”. Wir haben uns dazu entschieden, den aktuellen Issue-Key der Versionsnummer anzufügen, also aus my-artefact-1.2.0-SNAPSHOT.jar wurde so my-artefact-1.2.0-PRJ-1234-SNAPSHOT.jar, bei Pull-Requests dann einheitlich my-artefact-1.2.0-PR-123-SNAPSHOT.jar. Mit diesen Artefakten kann dann weiter aus dem Artifactory gearbeitet werden, z.B. Bauen von Docker images oder Deployment.

Mit Artifactory ergab das allerdings eine neue Problemstellung, nämlich dass die Zahl der unterschiedlichen Snapshot-Artefakte explosionsartig anstieg. Zwar bietet Artifactory die Möglichkeit über Retention-Policies maximale Anzahl und Alter von Artefakten festzulegen, jedoch nur pro Version, oder anders: uns lief die Festplatte voll.

Wir suchten nun also nach einer Lösung, nicht mehr benötigte Artefakte automatisch zu löschen. Wenn man mit Branches arbeitet ist der Punkt “nicht mehr benötigt” dann erreicht, wenn ein Pull-Request gemergt und der Branch geschlossen wurde.

Lösungsskizze

Die Lösung sollte wenn möglich keine weiteren Komponenten ausser Artifactory und Bitbucket (dem Git SCM) benötigen. Folgende Lösungsskizze umreisst kurz unseren Ansatz, bevor ich weiter unten ins Detail gehe:

  • Während des Builds bzw. zum Upload der Artefakte ins Artifactory wird der Branch-Name als Property am Artefakt hinterlegt
  • Entwicklung eines Artifactory Plugin erstellen, das via REST Aufruf angesprochen werden kann und als WebHook für Bitbucket dient
    • Im WebHook Aufruf ist Branch- und PR Nummer als payload enthalten
    • Das Plugin markiert Artefakte mit “toDelete” flag
  • Ein regelmässiger Cleanup Job (auch Plugin) löscht Artefakte mit gesetztem toDelete-flag (z.B. 1x pro Nacht)
  • Falls ein WebHook verpasst wurde kann ein Job auch alle (SNAPSHOT) Artefakte löschen, die länger als x Tage/Wochen/Monate nicht heruntergeladen wurden

Artefakte mit Property markieren

Als CI Server verwendeten wir Jenkins mit einer Coded Pipeline. Für Jenkins gibt es ein Artifactory Plugin, was das Deployment der Artefakte im Repository erleichter

Der aktuelle Branch steht innerhalb der Pipeline als BRANCH_NAME zur Verfügung.

Mit dem folgenden Skript wird das Projekt gebaut und die Artefakte in Artifactory hochgeladen. Jedes Artefakt bekommt im “branch” property den Namen des aktuellen Branches gesetzt.

def artifactory = Artifactory.server 'my-artifactory-id'

def rtMaven = Artifactory.newMavenBuild()
rtMaven.tool = 'maven-v3-tool'
rtMaven.resolver server: artifactory, releaseRepo: 'local-releases', snapshotRepo: 'local-snapshots'
rtMaven.deployer server: artifactory, releaseRepo: 'local-releases', snapshotRepo: 'local-snapshots'

def buildInfo = rtMaven.run pom: 'pom.xml', goals: 'clean install'

//set the branch-name property on the artifact
rtMaven.deployer.addProperty("branch", "${BRANCH_NAME}")

rtMaven.deployer.deployArtifacts buildInfo

//retention policy
buildInfo.retention maxBuilds: 10, maxDays: 10, deleteBuildArtifacts: true
artifactory.publishBuildInfo buildInfo

Custom Artifactory Plugin

Plugins sind nur in der kommerziellen Version Artifactory Pro verfügbar. Sie erlauben die Funktionalität von Artifactory an verschiedenen Punkten zu erweitern. Detailierte Informationen können der Dokumentation zu Custom User Plugins entnommen werden. Beispiel Plugins sind auf GitHub zu finden.

Für unsere Lösung sind zwei Extenstion Points interessant: “execution” und “jobs”.

Die execution erlaubt einen HTTP Endpunkt zu definieren, der über einen Web-Request angesprochen werden kann. Dieser soll als WebHook dienen und direkt den payload des WebHooks verarbeiten, die betroffenen Artefakte auffinden und zum löschen markieren.

Der job Extension Point erlaubt eine zeitgesteuerte Ausführung der Löschoperation der vorher markierten Artefakte. So kann der aufwändigere Löschvorgang an einer Tageszeit ausgeführt werden, an der weniger Last anliegt, z.B. Nachts. Der Zeitpunkt der Jobausführung kann über einen cron-Ausdruck angegeben werden.

Das plugin soll weiterhin über eine externe Property Datei konfiguriert werden können, so dass wir das Plugin einfach konfigurieren können. Die Properties Datei heisst wie das Plugin selbst und hat die Endung .properties.

import org.artifactory.resource.ResourceStreamHandle

import groovy.transform.Field

@Field final String PROPERTIES_FILE_PATH = "plugins/${this.class.name}.properties"
@Field def config = new ConfigSlurper().parse(new File(ctx.artifactoryHome.haAwareEtcDir, PROPERTIES_FILE_PATH).toURL())

executions {
    webhook(params:[:]) {params, ResourceStreamHandle inputBody ->
        //process request here
    }
}
jobs {
    deleteMarkedArtifacts(cron: "* * * * * ?") {
        //cleanup code here
    }
}

Auslesen des WebHook

Beim Aufruf des WebHooks durch Bitbucket werden detailierte Informationen über den Vorgang mitgeliefert. Für unsere Zwecke sind vor allem folgende Informationen relevant:

  • ID des Pull Requests (PR)
  • Status des PRs
  • Source Branch Name
  • Wurde der Source Branch Name beim Mergen geschlossen
  • der Ziel-Branch Name ist dagegen rein informativ

Im Nachfolgenden Code Beispiel wird der payload des WebHooks zunächst geparst und dann die relevanten Informationen ausgelesen

def bodyJson = new JsonSlurper().parse(inputBody.inputStream)

def pr = bodyJson.pullrequest
if(pr == null){
    log.warn "Request contained no pullrequest, ignoring"
    return
}

def state = pr.state
//Name des "PR-Branches"
def prBranchName = "PR-${pr.id}"
def sourceBranchName = pr.source.branch.name
def closeSourceBranch = pr.close_source_branch
def destinationBranchName = pr.destination.branch.name

Mit diesen Informationen können nun die Artefakte markiert werden, die durch einen Pull-Request-Build generiert wurden, allerdings nur, wenn der Pull Request auch gemerged wurde. Wenn der Source-Branch zudem geschlossen wurde, können auch alle Artefakte die durch den Source-Branch Build gebaut wurden entfernt werden.

String repository = config.repository as String

if("MERGED".equals(state)){
    markForDeletion(repository, prBranchName)
    if(closeSourceBranch){
        markForDeletion(repository, sourceBranchName)
    } else {
        log.info "source branch is not merged"
    }
} else {
    log.info "PR is not merged"
}

Suchen und Markieren der Artefakte

Das Auffinden der Artefakte erfolgt durch Suche mittels der Artifactory eigenen Abfrage Sprache AQL (die sich auf den ersten Blick sehr ähnlich zu MongoDBs Syntax gibt).

Wir suchen also alle Artefakte innerhalb eines Repositories, die eine Property namens “branch” haben, das den Wert des Branchnamens aus dem WebHook Payload hat. Über das Suchergebnis iterrieren wir, und setzen für jedes Artefakt das “toDelete” flag.

def markForDeletion(repo, branchName) {

    def aql = """items.find({
                        "repo":"${repo}",
                        "property.key":{
                            "\$eq" : "branch"
                            },
                        "property.value":{
                            "\$eq" : "${branchName}"
                            }
                        })
                """

    searches.aql(aql) { AqlResult result ->
        result.each { Map item ->

            String itemPath = item.path + "/" + item.name

            log.info "Found: $itemPath"
            log.info "Marking: $itemPath for deletion"

            RepoPath repoPath = RepoPathFactory.create(repo, itemPath)
            repositories.setProperty(repoPath, "toDelete", "true")
        }
    }
}

Löschen der Artefakte

Das Löschen der Artefakte erfolgt nachgelagert durch einen Cleanup-Job. Hier suchen wir wieder mittels AQL nach allen Artefakten innerhalb des Repositories mit gesetztem “toDelete” flag und löschen diese.

Der verwendete Cron-Ausdruck startet den Cleanup Job jede Nacht um 3.

jobs {
    deleteMarkedArtifacts(cron: "0 0 3 * * ?") {
        String repository = config.repository as String
        deleteMarkedArtifacts(repository)
    }
}

void deleteMarkedArtifacts(repo) {
    def count = 0
    def aql = """items.find({
                        "repo":"${repo}",
                        "property.key":{
                            "\$eq" : "toDelete"
                            },
                        "property.value":{
                            "\$eq" : "true"
                            }
                        })
                """
    searches.aql(aql) { AqlResult result ->
        result.each { Map item ->

            String itemPath = item.path + "/" + item.name
            log.info "Found: $itemPath"

            repositories.delete RepoPathFactory.create(repo, itemPath)
            count++
        }
    }
    if (count > 0) {
        log.info("Succesfully deleted  " + count + " files")
    } else {
        log.info("No files found that are marked for deletion")
    }
}

comments powered by Disqus