Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f6feeef
Enhance deployment strategy to support additional Kubernetes resource…
BraisVQ Jan 3, 2026
641d073
Enhance Helm deployment handling by organizing resources and updating…
BraisVQ Jan 3, 2026
bb74c58
Enhance ODS component verification to support all Kubernetes workload…
BraisVQ Jan 3, 2026
ab0b6e5
Refine deployment strategy to remove Job kind references and update c…
BraisVQ Jan 3, 2026
8792652
Add helmReleasesHistoryLimit option to manage Helm release history re…
BraisVQ Jan 3, 2026
60a719c
Normalize container handling in FinalizeOdsComponent to support both …
BraisVQ Jan 3, 2026
72589df
Refactor container handling in HelmDeploymentStrategy and FinalizeOds…
BraisVQ Jan 3, 2026
1c3702f
test
BraisVQ Jan 3, 2026
59d4474
Ensure consistent image handling by converting container images to st…
BraisVQ Jan 3, 2026
413f739
Fix trailing artifacts in container image string conversion in HelmDe…
BraisVQ Jan 3, 2026
1a32152
Refactor container validation in FinalizeOdsComponent to unify handli…
BraisVQ Jan 3, 2026
c1ce133
Fix regex for trailing artifacts in container image string conversion…
BraisVQ Jan 4, 2026
e30a910
Flatten container data structure to ensure unique entries in HelmDepl…
BraisVQ Jan 4, 2026
08cdb7a
Remove pod data retrieval logic and store deployment metadata for Hel…
BraisVQ Jan 4, 2026
73bcf08
clean comments
BraisVQ Jan 4, 2026
3fb9d1b
Refactor deployment handling and improve test coverage
BraisVQ Jan 4, 2026
1d4b964
changelog and docs
BraisVQ Jan 5, 2026
995f7dd
Refactor deploy method signatures to use List<PodData> for improved t…
BraisVQ Feb 16, 2026
df27cda
Refactor expected rollout data structure to use List<PodData> for imp…
BraisVQ Feb 16, 2026
e53d689
Fix deploymentInfo access to handle array structure in rollout test
BraisVQ Feb 16, 2026
f1a2107
Merge branch 'master' into helm-changes
BraisVQ Feb 16, 2026
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Added

### Changed
* Enhance Helm strategy and support of Statefullset/Cronjob ([#1253](https://github.com/opendevstack/ods-jenkins-shared-library/pull/1253))

### Fixed
* Log correct error message for wrong preview-branch value ([#1249](https://github.com/opendevstack/ods-jenkins-shared-library/pull/1249))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,14 @@ _String_
referenced by `chartDir` exists.


| *helmReleasesHistoryLimit* +
_Integer_
|Number of historical Helm release secrets to keep (defaults to 5).
Helm stores release state in secrets. This limit controls how many
previous releases are retained before old ones are deleted. Only relevant
if the directory referenced by `chartDir` exists.


| *helmValues* +
_Map<String,&nbsp;String>_
|Key/value pairs to pass as values (by default, the key `imageTag` is set
Expand Down
4 changes: 2 additions & 2 deletions src/org/ods/component/AbstractDeploymentStrategy.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,16 @@ package org.ods.component
import groovy.transform.TypeChecked
import groovy.transform.TypeCheckingMode
import org.ods.services.OpenShiftService
import org.ods.util.PodData

abstract class AbstractDeploymentStrategy implements IDeploymentStrategy {

protected final List<String> DEPLOYMENT_KINDS = [
OpenShiftService.DEPLOYMENT_KIND, OpenShiftService.DEPLOYMENTCONFIG_KIND,
OpenShiftService.STATEFULSET_KIND, OpenShiftService.CRONJOB_KIND,
]

@Override
abstract Map<String, List<PodData>> deploy()
abstract Map<String, ?> deploy()

// Fetches original kubernetes revisions of deployment resources.
//
Expand Down
219 changes: 81 additions & 138 deletions src/org/ods/component/HelmDeploymentStrategy.groovy
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
package org.ods.component

import com.cloudbees.groovy.cps.NonCPS
import groovy.transform.TypeChecked
import groovy.transform.TypeCheckingMode
import org.ods.services.JenkinsService
import org.ods.services.OpenShiftService
import org.ods.util.HelmStatus
import org.ods.util.ILogger
import org.ods.util.IPipelineSteps
import org.ods.util.PodData

class HelmDeploymentStrategy extends AbstractDeploymentStrategy {

Expand Down Expand Up @@ -58,9 +56,8 @@ class HelmDeploymentStrategy extends AbstractDeploymentStrategy {
if (!config.containsKey('helmEnvBasedValuesFiles')) {
config.helmEnvBasedValuesFiles = []
}
if (!config.containsKey('helmDefaultFlags')) {
config.helmDefaultFlags = ['--install', '--atomic']
}
// helmDefaultFlags are always set and cannot be overridden
config.helmDefaultFlags = ['--install', '--atomic']
if (!config.containsKey('helmAdditionalFlags')) {
config.helmAdditionalFlags = []
}
Expand All @@ -70,6 +67,9 @@ class HelmDeploymentStrategy extends AbstractDeploymentStrategy {
if (!config.helmPrivateKeyCredentialsId) {
config.helmPrivateKeyCredentialsId = "${context.cdProject}-helm-private-key"
}
if (!config.containsKey('helmReleasesHistoryLimit')) {
config.helmReleasesHistoryLimit = 5
}
this.context = context
this.logger = logger
this.steps = steps
Expand All @@ -80,7 +80,7 @@ class HelmDeploymentStrategy extends AbstractDeploymentStrategy {
}

@Override
Map<String, List<PodData>> deploy() {
Map<String, Map<String, Object>> deploy() {
if (!context.environment) {
logger.warn 'Skipping because of empty (target) environment ...'
return [:]
Expand Down Expand Up @@ -142,13 +142,17 @@ class HelmDeploymentStrategy extends AbstractDeploymentStrategy {
mergedHelmValuesFiles.addAll(options.helmValuesFiles)
mergedHelmValuesFiles.addAll(envConfigFiles)

// Add history-max flag to limit stored release revisions
def mergedAdditionalFlags = options.helmAdditionalFlags.collect { it }
mergedAdditionalFlags << "--history-max ${options.helmReleasesHistoryLimit}".toString()

openShift.helmUpgrade(
targetProject,
options.helmReleaseName,
mergedHelmValuesFiles,
mergedHelmValues,
options.helmDefaultFlags,
options.helmAdditionalFlags,
mergedAdditionalFlags,
options.helmDiff
)
}
Expand All @@ -157,155 +161,94 @@ class HelmDeploymentStrategy extends AbstractDeploymentStrategy {

// rollout returns a map like this:
// [
// 'DeploymentConfig/foo': [[podName: 'foo-a', ...], [podName: 'foo-b', ...]],
// 'Deployment/bar': [[podName: 'bar-a', ...]]
// 'DeploymentConfig/foo': [deploymentId: 'foo', containers: [...], ...],
// 'Deployment/bar': [deploymentId: 'bar', containers: [...], ...]
// ]
@TypeChecked(TypeCheckingMode.SKIP)
private Map<String, List<PodData>> getRolloutData(
private Map<String, Map<String, Object>> getRolloutData(
HelmStatus helmStatus
) {
Map<String, List<PodData>> rolloutData = [:]
Map<String, Map<String, Object>> rolloutData = [:]

Map<String, List<String>> deploymentKinds = helmStatus.resourcesByKind
.findAll { kind, res -> kind in DEPLOYMENT_KINDS }

// Collect containers organized by resource (kind/name) to preserve all images
Map<String, Map<String, String>> containersByResource = [:]
Map<String, List<String>> resourcesByKind = [:]

deploymentKinds.each { kind, names ->
resourcesByKind[kind] = names
names.each { name ->
context.addDeploymentToArtifactURIs("${name}-deploymentMean",
[
type: 'helm',
selector: options.selector,
namespace: context.targetProject,
chartDir: options.chartDir,
helmReleaseName: options.helmReleaseName,
helmEnvBasedValuesFiles: options.helmEnvBasedValuesFiles,
helmValuesFiles: options.helmValuesFiles,
helmValues: options.helmValues,
helmDefaultFlags: options.helmDefaultFlags,
helmAdditionalFlags: options.helmAdditionalFlags,
helmStatus: helmStatus.toMap(),
]
// Get container images directly from the resource spec (deployment/statefulset/cronjob)
def containers = openShift.getContainerImagesWithNameFromPodSpec(
context.targetProject, kind, name
)
def podDataContext = [
"targetProject=${context.targetProject}",
"selector=${options.selector}",
"name=${name}",
]
def msgPodsNotFound = "Could not find 'running' pod(s) for '${podDataContext.join(', ')}'"
List<PodData> podData = null
for (def i = 0; i < options.deployTimeoutRetries; i++) {
podData = openShift.checkForPodData(context.targetProject, options.selector, name)
if (podData) {
break
}
steps.echo("${msgPodsNotFound} - waiting")
steps.sleep(12)
}
if (!podData) {
throw new RuntimeException(msgPodsNotFound)
}
logger.debug("Helm podData for ${podDataContext.join(', ')}: ${podData}")
logger.debug("Helm container images for ${kind}/${name}: ${containers}")

rolloutData["${kind}/${name}"] = podData
containersByResource[name] = containers

// We need to find the pod that was created as a result of the deployment.
// The previous pod may still be alive when we use a rollout strategy.
// We can tell one from the other using their creation timestamp,
// being the most recent the one we are interested in.
def latestPods = getLatestPods(podData)
// While very unlikely, it may happen that there is more than one pod with the same timestamp.
// Note that timestamp resolution is seconds.
// If that happens, we are unable to know which is the correct pod.
// However, it doesn't matter which pod is the right one, if they all have the same images.
def sameImages = haveSameImages(latestPods)
if (!sameImages) {
throw new RuntimeException(
"Unable to determine the most recent Pod. " +
"Multiple pods running with the same latest creation timestamp " +
"and different images found for ${name}"
)
}
// Deployment and DeploymentConfig resource.
context.addDeploymentToArtifactURIs(name, latestPods[0]?.toMap())
def resourceData = [
deploymentId: name,
containers: containers,
]
rolloutData[name] = resourceData
}
}
return rolloutData
}

/**
* Returns the pods with the latest creation timestamp.
* Note that the resolution of this timestamp is seconds and there may be more than one pod with the same
* latest timestamp.
*
* @param pods the pods over which to find the latest ones.
* @return a list with all the pods sharing the same, latest timestamp.
*/
@NonCPS
private static List getLatestPods(Iterable pods) {
return maxElements(pods) { it.podMetaDataCreationTimestamp }
}

/**
* Checks whether all the given pods contain the same images, ignoring order and multiplicity.
*
* @param pods the pods to check for image equality.
* @return true if all the pods have the same images or false otherwise.
*/
@NonCPS
private static boolean haveSameImages(Iterable pods) {
return areEqual(pods) { a, b ->
def imagesA = a.containers.values() as Set
def imagesB = b.containers.values() as Set
return imagesA == imagesB
}
}

/**
* Selects the items in the iterable which when passed as a parameter to the supplied closure
* return the maximum value. A null return value represents the least possible return value,
* so any item for which the supplied closure returns null, won't be selected (unless all items return null).
* The return list contains all the elements that returned the maximum value.
*
* @param iterable the iterable over which to search for maximum values.
* @param getValue a closure returning the value that corresponds to each element.
* @return the list of all the elements for which the closure returns the maximum value.
*/
@NonCPS
private static List maxElements(Iterable iterable, Closure getValue) {
if (!iterable) {
return [] // Return an empty list if the iterable is null or empty
}

// Find the maximum value using the closure
def maxValue = iterable.collect(getValue).max()

// Find all elements with the maximum value
return iterable.findAll { getValue(it) == maxValue }
}

/**
* Checks whether all the elements in the given iterable are deemed as equal by the given closure.
*
* @param iterable the iterable over which to check for element equality.
* @param equals a closure that checks two elements for equality.
* @return true if all the elements are equal or false otherwise.
*/
@NonCPS
private static boolean areEqual(Iterable iterable, Closure equals) {
def equal = true
if (iterable) {
def first = true
def base = null
iterable.each {
if (first) {
base = it
first = false
} else if (!equals(base, it)) {
equal = false
// Create a single deploymentMean entry for the entire helm release
def deploymentMean = [
type: 'helm',
selector: options.selector,
namespace: context.targetProject,
chartDir: options.chartDir,
helmReleaseName: options.helmReleaseName,
helmEnvBasedValuesFiles: options.helmEnvBasedValuesFiles,
helmValuesFiles: options.helmValuesFiles,
helmValues: options.helmValues,
helmDefaultFlags: options.helmDefaultFlags,
helmAdditionalFlags: options.helmAdditionalFlags,
helmStatus: helmStatus.toMap(),
resources: resourcesByKind,
]

// Store deployment mean once per helm release (not per resource)
context.addDeploymentToArtifactURIs("${options.helmReleaseName}-deploymentMean", deploymentMean)

// Store consolidated deployment data with all unique containers flattened
// Ensure all container images are plain strings (handle deserialization artifacts)
// Flatten containers from all resources into a single map to support multiple containers per resource
def flattenedContainers = [:]
containersByResource.each { resourceName, containerMap ->
containerMap.each { containerName, image ->
// Extract string value - image might be String, or wrapped in Map/List after deserialization
def imageStr = image
while (Map.isInstance(imageStr) && imageStr.size() > 0) {
imageStr = imageStr.values()[0]
}
while (List.isInstance(imageStr) && imageStr.size() > 0) {
imageStr = imageStr[0]
}
def stringValue = imageStr.toString()
// Ensure no trailing bracket or other artifacts remain from serialization
// Use double-escaped bracket to ensure proper regex matching in Groovy
stringValue = stringValue.replaceAll('\\]\\s*$', '')
// Create a unique key for each container
// If resource has only one container, use resource name; otherwise use resource::container format
def containerKey = containerMap.size() == 1 ? resourceName : "${resourceName}::${containerName}"
// Only add if this image isn't already present (avoid duplicates of same image)
if (!flattenedContainers.values().contains(stringValue)) {
flattenedContainers[containerKey] = stringValue
}
}
}
return equal
def consolidatedDeploymentData = [
deploymentId: options.helmReleaseName,
containers: flattenedContainers,
]
context.addDeploymentToArtifactURIs(options.helmReleaseName, consolidatedDeploymentData)

return rolloutData
}

}
}
4 changes: 1 addition & 3 deletions src/org/ods/component/IDeploymentStrategy.groovy
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
package org.ods.component

import org.ods.util.PodData

@SuppressWarnings('MethodCount')
interface IDeploymentStrategy {

Map<String, List<PodData>> deploy()
Map<String, ?> deploy()

}
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,13 @@ class RolloutOpenShiftDeploymentOptions extends Options {
* by `chartDir` exists. */
String helmPrivateKeyCredentialsId

/**
* Number of historical Helm release secrets to keep (defaults to 5).
* Helm stores release state in secrets. This limit controls how many
* previous releases are retained before old ones are deleted. Only relevant
* if the directory referenced by `chartDir` exists. */
Integer helmReleasesHistoryLimit

/**
* Directory with OpenShift templates (defaults to `openshift`). */
String openshiftDir
Expand Down
Loading