Skip to content

Commit

Permalink
[TEAMCITY-QA-T] Enhance automation for the verification of Docker Ima…
Browse files Browse the repository at this point in the history
…ge's size (#100)

[TEAMCITY-QA-T] Enhance automation for the verification of Docker Image's size (#100)
  • Loading branch information
AndreyKoltsov1997 authored Aug 23, 2023
1 parent 7b66c47 commit 85bcf12
Show file tree
Hide file tree
Showing 3 changed files with 87 additions and 69 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ package com.jetbrains.teamcity.common.constants
object ValidationConstants {
const val ALLOWED_IMAGE_SIZE_INCREASE_THRESHOLD_PERCENT = 5.0f
const val PRE_PRODUCTION_IMAGE_PREFIX = "EAP"
const val LATEST = "latest"
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,7 @@ import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import java.lang.Exception
import java.lang.IllegalArgumentException
import java.lang.IllegalStateException
import java.net.http.HttpResponse
import java.time.Instant

/**
* Provides access to Docker registry.
Expand Down Expand Up @@ -93,43 +89,39 @@ class DockerRegistryAccessor(private val uri: String, credentials: DockerhubCred
print("Registry information for given image was not found: $currentImage")
return null
}

// get the TAG of previous image. It might have multiple corresponding images (same tag, but different target OS)
val previousImageRepository = registryInfo.results
// Remove current & EAP (non-production) tags
.asSequence()
.filter {
return@filter ((it.name != currentImage.tag)
&& (!it.name.contains(ValidationConstants.PRE_PRODUCTION_IMAGE_PREFIX)))
}
// Remove year from tag, making it comparable
.filter {
try {
return@filter it.name.contains(currentImage.tag.split("-", limit = 2)[1])
} catch (e: Exception) {
println("Image name does not match the expected pattern, thus would be filtered out: ${it.name}")
return@filter false
}
// EAP & latest
&& (!it.name.contains(ValidationConstants.PRE_PRODUCTION_IMAGE_PREFIX))
&& (!it.name.contains(ValidationConstants.LATEST)))
}
.maxByOrNull { result -> Instant.parse(result.tagLastPushed) }
if (previousImageRepository == null) {
return null
}
// Remove releases that were after the image under test
.filter { return@filter isPrevRelease(it.name, currentImage.tag) }
// lookup previous image for specific distribution, e.g. 2023.05-linux, 2023.05-windowsservercore, etc.
.filter { return@filter isSameDistribution(currentImage.tag, it.name) }
// Sort based on tag
.sortedWith { lhs, rhs -> imageTagComparator(lhs.name, rhs.name) }
.last()

// Apply filtering to the found Docker images.

// -- 1. Filter by OS type
previousImageRepository.images = previousImageRepository.images.filter { it.os == targetOs }
if (previousImageRepository.images.isNotEmpty() && !osVersion.isNullOrEmpty()) {

// --- 2. Filter by OS version (e.g. specific version of Windows, Linux)
val imagesFilteredByTarget = previousImageRepository.images.filter { it.osVersion.equals(osVersion) }
if (imagesFilteredByTarget.isEmpty()) {
// Found images that matches OS type, but doesn't match OS version, e.g. ...
// ... - Previous: teamcity-agent:2022.10.1--windowsservercore-2004 (Windows 10.0.17763.3650)
// ... - Current : teamcity-agent:2022.10.2-windowsservercore-2004 (Windows 10.0.17763.3887)
println("$currentImage - found previous image - ${previousImageRepository.name}, but OS version is "
+ "different - $osVersion and ${previousImageRepository.images.first().osVersion} \n"
+ "Images with mismatching OS versions, but matching tags will be compared.")
println(
"$currentImage - found previous image - ${previousImageRepository.name}, but OS version is "
+ "different - $osVersion and ${previousImageRepository.images.first().osVersion} \n"
+ "Images with mismatching OS versions, but matching tags will be compared."
)
return previousImageRepository
}

Expand All @@ -139,6 +131,51 @@ class DockerRegistryAccessor(private val uri: String, credentials: DockerhubCred
return previousImageRepository
}

/**
* Compares image tags, e.g. (2023.05.1 > 2023.05)
*/
private fun imageTagComparator(lhsImageTag: String, rhsImageTag: String): Int {
val lhsTagComponents = lhsImageTag.split("-")[0].split(".").map { it.toIntOrNull() }
val rhsTagComponents = rhsImageTag.split("-")[0].split(".").map { it.toIntOrNull() }

for (i in 0 until maxOf(lhsTagComponents.size, rhsTagComponents.size)) {
// e.g. 2023.05 transforms into 2023.05.0 for comparison purposes
val lhsTagComponent: Int = lhsTagComponents.getOrNull(i) ?: 0
val rhsTagComponent = rhsTagComponents.getOrNull(i) ?: 0
if (lhsTagComponent != rhsTagComponent) {
return lhsTagComponent.compareTo(rhsTagComponent)
}
}
return lhsTagComponents.size.compareTo(rhsTagComponents.size)
}

/**
* returns true if lhs was earlier release than rhs
*/
private fun isPrevRelease(lhsTag: String, rhsTag: String): Boolean {
return imageTagComparator(lhsTag, rhsTag) < 0
}

/**
* Returns true if both images below to the same distribution, e.g. lhs="2023.05.1-windowsservercore", rhs= ...
* ...="2023.05-windowsservercore" will return true.
*/
private fun isSameDistribution(lhsTag: String, rhsTag: String): Boolean {
val nameComponents = lhsTag.split("-")
if (nameComponents.size == 1) {
// e.g. "2023.05"
return true
}
return try {
// 2023.05-linux-amd64 => linux-amd64
val something = lhsTag.split("-", limit = 2)[1]
rhsTag.contains(something)
} catch (e: Exception) {
println("Image name does not match the expected pattern, thus would be filtered out: $rhsTag")
false
}
}

/**
* Creates a session-based Personal Access Token (PAT) for DockerHub REST API access to private repositories.
* See: https://docs.docker.com/docker-hub/api/latest/#tag/authentication/operation/PostUsersLogin
Expand Down Expand Up @@ -173,7 +210,7 @@ class DockerRegistryAccessor(private val uri: String, credentials: DockerhubCred
throw RuntimeException("Failed to obtain JSON Web Token - response body is empty. \n $response \n ${this.uri}")
}

val authResponseJson= jsonSerializer.decodeFromString<DockerhubPersonalAccessToken>(webTokenJsonString)
val authResponseJson = jsonSerializer.decodeFromString<DockerhubPersonalAccessToken>(webTokenJsonString)
return authResponseJson.token
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,12 @@ class DockerImageValidationUtilities {
* @param registryUri URI of Docker Registry where image is placed
* @returns list of associated images that didn't pass the validation.
*/
fun validateImageSize(originalImageFqdn: String, registryUri: String, threshold: Float, credentials: DockerhubCredentials?): ArrayList<DockerhubImage> {
fun validateImageSize(
originalImageFqdn: String,
registryUri: String,
threshold: Float,
credentials: DockerhubCredentials?
): ArrayList<DockerhubImage> {
val registryAccessor = DockerRegistryAccessor(registryUri, credentials)

val currentImage = DockerImage(originalImageFqdn)
Expand All @@ -56,24 +61,33 @@ class DockerImageValidationUtilities {
val originalImageRegistryInfo = registryAccessor.getRepositoryInfo(currentImage)
originalImageRegistryInfo.images.forEach { associatedImage ->
// -- report size for each image
TeamCityUtils.reportTeamCityStatistics("SIZE-${getImageStatisticsId(currentImage.toString())}", associatedImage.size)
TeamCityUtils.reportTeamCityStatistics(
"SIZE-${getImageStatisticsId(currentImage.toString())}",
associatedImage.size
)

// -- compare image
val dockerHubInfoOfPreviousRelease: DockerRepositoryInfo? = registryAccessor.getPreviousImages(currentImage,
associatedImage.os,
associatedImage.osVersion) ?: null
val dockerHubInfoOfPreviousRelease: DockerRepositoryInfo? = registryAccessor.getPreviousImages(
currentImage,
associatedImage.os,
associatedImage.osVersion
)
if (dockerHubInfoOfPreviousRelease == null || dockerHubInfoOfPreviousRelease.images.size != 1) {
println("Unable to determine previous image for $originalImageFqdn-${associatedImage.os}")
return@forEach
}

// -- we will always have only 1 corresponding image, due to extensive criteria
val previousImage = dockerHubInfoOfPreviousRelease.images.first()
val percentageChange = MathUtils.getPercentageIncrease(associatedImage.size.toLong(), previousImage.size.toLong())
println("$originalImageFqdn-${associatedImage.os}-${associatedImage.osVersion}-${associatedImage.architecture}: "
+ "\n\t - Original size: ${associatedImage.size} ($originalImageFqdn)"
+ "\n\t - Previous size: ${previousImage.size} (${dockerHubInfoOfPreviousRelease.name})"
+ "\n\t - Percentage change: ${MathUtils.roundOffDecimal(percentageChange)}% (max allowable - $threshold%)\n")
val percentageChange =
MathUtils.getPercentageIncrease(associatedImage.size.toLong(), previousImage.size.toLong())
val osVersion = if (associatedImage.osVersion.isNullOrBlank()) "" else associatedImage.osVersion
println(
"$originalImageFqdn-${associatedImage.os}${osVersion}-${associatedImage.architecture}: "
+ "\n\t - Original size: ${associatedImage.size} ($originalImageFqdn)"
+ "\n\t - Previous size: ${previousImage.size} (${dockerHubInfoOfPreviousRelease.name})"
+ "\n\t - Percentage change: ${MathUtils.roundOffDecimal(percentageChange)}% (max allowable - $threshold%)\n"
)
if (percentageChange > threshold) {
imagesFailedValidation.add(associatedImage)
} else {
Expand All @@ -83,40 +97,6 @@ class DockerImageValidationUtilities {
return imagesFailedValidation
}

/**
* Generates ID of previous TeamCity Docker image assuming the pattern didn't change.
* WARNING: the function depends on the assumption that tag pattern ...
* ... is "<year>.<month number>-<OS>".
*/
fun getPrevDockerImageId(curImage: DockerImage): DockerImage? {
val curImageTagElems = curImage.tag.split(".")
if (curImageTagElems.size < 2) {
// image is highly likely doesn't correspond to pattern
System.err.println("Unable to auto-determine previous image tag - it doesn't correspond to pattern: $curImage")
return null
}

// handling 2 types: 2022.04-OS and 2022.04.2-OS
val isMinorRelease = curImageTagElems.size > 2

if (!isMinorRelease) {
System.err.println("Automatic determination of previous release is supported only for minor version of TeamCity.")
return null
}

val imageBuildNum = curImageTagElems[2].split("-")[0]

// -- construct old image tag based on retrieved information from the current one
// -- -- adding "0" since build number has at least 2 digits
val oldBuildNumber = Integer.parseInt(imageBuildNum) - 1

val originalImageTagPart = (curImageTagElems[0] + "." + curImageTagElems[1] + "." + imageBuildNum + "-")
val determinedOldImageTagPart = (curImageTagElems[0] + "." + curImageTagElems[1] + "." + oldBuildNumber + "-")

// Replace current image's numeric part of tag with determined "old" value, e.g. "2022.04.2-" -> "2022.04.1-"
return DockerImage(curImage.repo, curImage.tag.replace(originalImageTagPart, determinedOldImageTagPart))
}

/**
* Returns image ID for statistics within TeamCity. ID consists of image name with removed repository and release.
* Example: "some-registry.example.io/teamcity-agent:2022.10-windowsservercore-1809" -> "teamcity-agent-windowsservercore-1809"
Expand Down

0 comments on commit 85bcf12

Please sign in to comment.