Skip to content

Commit 019389e

Browse files
authored
Enhance Helm strategy and support of Statefullset/Cronjob (#1253)
1 parent 7e9f49c commit 019389e

13 files changed

+772
-328
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Added
66

77
### Changed
8+
* Enhance Helm strategy and support of Statefullset/Cronjob ([#1253](https://github.com/opendevstack/ods-jenkins-shared-library/pull/1253))
89
* Add component information in automatic release close notes ([#1254](https://github.com/opendevstack/ods-jenkins-shared-library/pull/1254))
910

1011
### Fixed

docs/modules/jenkins-shared-library/partials/odsComponentStageRolloutOpenShiftDeployment.adoc

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,14 @@ _String_
144144
referenced by `chartDir` exists.
145145

146146

147+
| *helmReleasesHistoryLimit* +
148+
_Integer_
149+
|Number of historical Helm release secrets to keep (defaults to 5).
150+
Helm stores release state in secrets. This limit controls how many
151+
previous releases are retained before old ones are deleted. Only relevant
152+
if the directory referenced by `chartDir` exists.
153+
154+
147155
| *helmValues* +
148156
_Map<String,&nbsp;String>_
149157
|Key/value pairs to pass as values (by default, the key `imageTag` is set

src/org/ods/component/AbstractDeploymentStrategy.groovy

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ abstract class AbstractDeploymentStrategy implements IDeploymentStrategy {
99

1010
protected final List<String> DEPLOYMENT_KINDS = [
1111
OpenShiftService.DEPLOYMENT_KIND, OpenShiftService.DEPLOYMENTCONFIG_KIND,
12+
OpenShiftService.STATEFULSET_KIND, OpenShiftService.CRONJOB_KIND,
1213
]
1314

1415
@Override
Lines changed: 78 additions & 134 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package org.ods.component
22

3-
import com.cloudbees.groovy.cps.NonCPS
43
import groovy.transform.TypeChecked
54
import groovy.transform.TypeCheckingMode
65
import org.ods.services.JenkinsService
@@ -58,9 +57,8 @@ class HelmDeploymentStrategy extends AbstractDeploymentStrategy {
5857
if (!config.containsKey('helmEnvBasedValuesFiles')) {
5958
config.helmEnvBasedValuesFiles = []
6059
}
61-
if (!config.containsKey('helmDefaultFlags')) {
62-
config.helmDefaultFlags = ['--install', '--atomic']
63-
}
60+
// helmDefaultFlags are always set and cannot be overridden
61+
config.helmDefaultFlags = ['--install', '--atomic']
6462
if (!config.containsKey('helmAdditionalFlags')) {
6563
config.helmAdditionalFlags = []
6664
}
@@ -70,6 +68,9 @@ class HelmDeploymentStrategy extends AbstractDeploymentStrategy {
7068
if (!config.helmPrivateKeyCredentialsId) {
7169
config.helmPrivateKeyCredentialsId = "${context.cdProject}-helm-private-key"
7270
}
71+
if (!config.containsKey('helmReleasesHistoryLimit')) {
72+
config.helmReleasesHistoryLimit = 5
73+
}
7374
this.context = context
7475
this.logger = logger
7576
this.steps = steps
@@ -142,13 +143,17 @@ class HelmDeploymentStrategy extends AbstractDeploymentStrategy {
142143
mergedHelmValuesFiles.addAll(options.helmValuesFiles)
143144
mergedHelmValuesFiles.addAll(envConfigFiles)
144145

146+
// Add history-max flag to limit stored release revisions
147+
def mergedAdditionalFlags = options.helmAdditionalFlags.collect { it }
148+
mergedAdditionalFlags << "--history-max ${options.helmReleasesHistoryLimit}".toString()
149+
145150
openShift.helmUpgrade(
146151
targetProject,
147152
options.helmReleaseName,
148153
mergedHelmValuesFiles,
149154
mergedHelmValues,
150155
options.helmDefaultFlags,
151-
options.helmAdditionalFlags,
156+
mergedAdditionalFlags,
152157
options.helmDiff
153158
)
154159
}
@@ -157,8 +162,8 @@ class HelmDeploymentStrategy extends AbstractDeploymentStrategy {
157162

158163
// rollout returns a map like this:
159164
// [
160-
// 'DeploymentConfig/foo': [[podName: 'foo-a', ...], [podName: 'foo-b', ...]],
161-
// 'Deployment/bar': [[podName: 'bar-a', ...]]
165+
// 'DeploymentConfig/foo': [deploymentId: 'foo', containers: [...], ...],
166+
// 'Deployment/bar': [deploymentId: 'bar', containers: [...], ...]
162167
// ]
163168
@TypeChecked(TypeCheckingMode.SKIP)
164169
private Map<String, List<PodData>> getRolloutData(
@@ -169,143 +174,82 @@ class HelmDeploymentStrategy extends AbstractDeploymentStrategy {
169174
Map<String, List<String>> deploymentKinds = helmStatus.resourcesByKind
170175
.findAll { kind, res -> kind in DEPLOYMENT_KINDS }
171176

177+
// Collect containers organized by resource (kind/name) to preserve all images
178+
Map<String, Map<String, String>> containersByResource = [:]
179+
Map<String, List<String>> resourcesByKind = [:]
180+
172181
deploymentKinds.each { kind, names ->
182+
resourcesByKind[kind] = names
173183
names.each { name ->
174-
context.addDeploymentToArtifactURIs("${name}-deploymentMean",
175-
[
176-
type: 'helm',
177-
selector: options.selector,
178-
namespace: context.targetProject,
179-
chartDir: options.chartDir,
180-
helmReleaseName: options.helmReleaseName,
181-
helmEnvBasedValuesFiles: options.helmEnvBasedValuesFiles,
182-
helmValuesFiles: options.helmValuesFiles,
183-
helmValues: options.helmValues,
184-
helmDefaultFlags: options.helmDefaultFlags,
185-
helmAdditionalFlags: options.helmAdditionalFlags,
186-
helmStatus: helmStatus.toMap(),
187-
]
184+
// Get container images directly from the resource spec (deployment/statefulset/cronjob)
185+
def containers = openShift.getContainerImagesWithNameFromPodSpec(
186+
context.targetProject, kind, name
188187
)
189-
def podDataContext = [
190-
"targetProject=${context.targetProject}",
191-
"selector=${options.selector}",
192-
"name=${name}",
193-
]
194-
def msgPodsNotFound = "Could not find 'running' pod(s) for '${podDataContext.join(', ')}'"
195-
List<PodData> podData = null
196-
for (def i = 0; i < options.deployTimeoutRetries; i++) {
197-
podData = openShift.checkForPodData(context.targetProject, options.selector, name)
198-
if (podData) {
199-
break
200-
}
201-
steps.echo("${msgPodsNotFound} - waiting")
202-
steps.sleep(12)
203-
}
204-
if (!podData) {
205-
throw new RuntimeException(msgPodsNotFound)
206-
}
207-
logger.debug("Helm podData for ${podDataContext.join(', ')}: ${podData}")
188+
logger.debug("Helm container images for ${kind}/${name}: ${containers}")
208189

209-
rolloutData["${kind}/${name}"] = podData
190+
containersByResource[name] = containers
210191

211-
// We need to find the pod that was created as a result of the deployment.
212-
// The previous pod may still be alive when we use a rollout strategy.
213-
// We can tell one from the other using their creation timestamp,
214-
// being the most recent the one we are interested in.
215-
def latestPods = getLatestPods(podData)
216-
// While very unlikely, it may happen that there is more than one pod with the same timestamp.
217-
// Note that timestamp resolution is seconds.
218-
// If that happens, we are unable to know which is the correct pod.
219-
// However, it doesn't matter which pod is the right one, if they all have the same images.
220-
def sameImages = haveSameImages(latestPods)
221-
if (!sameImages) {
222-
throw new RuntimeException(
223-
"Unable to determine the most recent Pod. " +
224-
"Multiple pods running with the same latest creation timestamp " +
225-
"and different images found for ${name}"
226-
)
227-
}
228-
// Deployment and DeploymentConfig resource.
229-
context.addDeploymentToArtifactURIs(name, latestPods[0]?.toMap())
192+
def podData = new PodData(
193+
deploymentId: name,
194+
containers: containers,
195+
)
196+
rolloutData[name] = [podData]
230197
}
231198
}
232-
return rolloutData
233-
}
234-
235-
/**
236-
* Returns the pods with the latest creation timestamp.
237-
* Note that the resolution of this timestamp is seconds and there may be more than one pod with the same
238-
* latest timestamp.
239-
*
240-
* @param pods the pods over which to find the latest ones.
241-
* @return a list with all the pods sharing the same, latest timestamp.
242-
*/
243-
@NonCPS
244-
private static List getLatestPods(Iterable pods) {
245-
return maxElements(pods) { it.podMetaDataCreationTimestamp }
246-
}
247-
248-
/**
249-
* Checks whether all the given pods contain the same images, ignoring order and multiplicity.
250-
*
251-
* @param pods the pods to check for image equality.
252-
* @return true if all the pods have the same images or false otherwise.
253-
*/
254-
@NonCPS
255-
private static boolean haveSameImages(Iterable pods) {
256-
return areEqual(pods) { a, b ->
257-
def imagesA = a.containers.values() as Set
258-
def imagesB = b.containers.values() as Set
259-
return imagesA == imagesB
260-
}
261-
}
262-
263-
/**
264-
* Selects the items in the iterable which when passed as a parameter to the supplied closure
265-
* return the maximum value. A null return value represents the least possible return value,
266-
* so any item for which the supplied closure returns null, won't be selected (unless all items return null).
267-
* The return list contains all the elements that returned the maximum value.
268-
*
269-
* @param iterable the iterable over which to search for maximum values.
270-
* @param getValue a closure returning the value that corresponds to each element.
271-
* @return the list of all the elements for which the closure returns the maximum value.
272-
*/
273-
@NonCPS
274-
private static List maxElements(Iterable iterable, Closure getValue) {
275-
if (!iterable) {
276-
return [] // Return an empty list if the iterable is null or empty
277-
}
278-
279-
// Find the maximum value using the closure
280-
def maxValue = iterable.collect(getValue).max()
281199

282-
// Find all elements with the maximum value
283-
return iterable.findAll { getValue(it) == maxValue }
284-
}
285-
286-
/**
287-
* Checks whether all the elements in the given iterable are deemed as equal by the given closure.
288-
*
289-
* @param iterable the iterable over which to check for element equality.
290-
* @param equals a closure that checks two elements for equality.
291-
* @return true if all the elements are equal or false otherwise.
292-
*/
293-
@NonCPS
294-
private static boolean areEqual(Iterable iterable, Closure equals) {
295-
def equal = true
296-
if (iterable) {
297-
def first = true
298-
def base = null
299-
iterable.each {
300-
if (first) {
301-
base = it
302-
first = false
303-
} else if (!equals(base, it)) {
304-
equal = false
200+
// Create a single deploymentMean entry for the entire helm release
201+
def deploymentMean = [
202+
type: 'helm',
203+
selector: options.selector,
204+
namespace: context.targetProject,
205+
chartDir: options.chartDir,
206+
helmReleaseName: options.helmReleaseName,
207+
helmEnvBasedValuesFiles: options.helmEnvBasedValuesFiles,
208+
helmValuesFiles: options.helmValuesFiles,
209+
helmValues: options.helmValues,
210+
helmDefaultFlags: options.helmDefaultFlags,
211+
helmAdditionalFlags: options.helmAdditionalFlags,
212+
helmStatus: helmStatus.toMap(),
213+
resources: resourcesByKind,
214+
]
215+
216+
// Store deployment mean once per helm release (not per resource)
217+
context.addDeploymentToArtifactURIs("${options.helmReleaseName}-deploymentMean", deploymentMean)
218+
219+
// Store consolidated deployment data with all unique containers flattened
220+
// Ensure all container images are plain strings (handle deserialization artifacts)
221+
// Flatten containers from all resources into a single map to support multiple containers per resource
222+
def flattenedContainers = [:]
223+
containersByResource.each { resourceName, containerMap ->
224+
containerMap.each { containerName, image ->
225+
// Extract string value - image might be String, or wrapped in Map/List after deserialization
226+
def imageStr = image
227+
while (Map.isInstance(imageStr) && imageStr.size() > 0) {
228+
imageStr = imageStr.values()[0]
229+
}
230+
while (List.isInstance(imageStr) && imageStr.size() > 0) {
231+
imageStr = imageStr[0]
232+
}
233+
def stringValue = imageStr.toString()
234+
// Ensure no trailing bracket or other artifacts remain from serialization
235+
// Use double-escaped bracket to ensure proper regex matching in Groovy
236+
stringValue = stringValue.replaceAll('\\]\\s*$', '')
237+
// Create a unique key for each container
238+
// If resource has only one container, use resource name; otherwise use resource::container format
239+
def containerKey = containerMap.size() == 1 ? resourceName : "${resourceName}::${containerName}"
240+
// Only add if this image isn't already present (avoid duplicates of same image)
241+
if (!flattenedContainers.values().contains(stringValue)) {
242+
flattenedContainers[containerKey] = stringValue
305243
}
306244
}
307245
}
308-
return equal
246+
def consolidatedPodData = new PodData(
247+
deploymentId: options.helmReleaseName,
248+
containers: flattenedContainers as Map<String, String>,
249+
)
250+
context.addDeploymentToArtifactURIs(options.helmReleaseName, consolidatedPodData.toMap())
251+
252+
return rolloutData
309253
}
310254

311-
}
255+
}

src/org/ods/component/RolloutOpenShiftDeploymentOptions.groovy

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,13 @@ class RolloutOpenShiftDeploymentOptions extends Options {
8686
* by `chartDir` exists. */
8787
String helmPrivateKeyCredentialsId
8888

89+
/**
90+
* Number of historical Helm release secrets to keep (defaults to 5).
91+
* Helm stores release state in secrets. This limit controls how many
92+
* previous releases are retained before old ones are deleted. Only relevant
93+
* if the directory referenced by `chartDir` exists. */
94+
Integer helmReleasesHistoryLimit
95+
8996
/**
9097
* Directory with OpenShift templates (defaults to `openshift`). */
9198
String openshiftDir

src/org/ods/orchestration/phases/DeployOdsComponent.groovy

Lines changed: 1 addition & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import org.ods.services.GitService
1111
import org.ods.orchestration.util.DeploymentDescriptor
1212
import org.ods.orchestration.util.MROPipelineUtil
1313
import org.ods.orchestration.util.Project
14-
import org.ods.util.PodData
1514

1615
// Deploy ODS comnponent (code or service) to 'qa' or 'prod'.
1716
@TypeChecked
@@ -60,35 +59,7 @@ class DeployOdsComponent {
6059

6160
applyTemplates(openShiftDir, deploymentMean)
6261

63-
def retries = project.environmentConfig?.openshiftRolloutTimeoutRetries ?: 10
64-
def podDataContext = [
65-
"targetProject=${project.targetProject}",
66-
"selector=${deploymentMean.selector}",
67-
"name=${deploymentName}",
68-
]
69-
def msgPodsNotFound = "Could not find 'running' pod(s) for '${podDataContext.join(', ')}'"
70-
List<PodData> podData = null
71-
for (def i = 0; i < retries; i++) {
72-
podData = os.checkForPodData(project.targetProject, deploymentMean.selector, deploymentName)
73-
if (podData) {
74-
break
75-
}
76-
steps.echo("${msgPodsNotFound} - waiting")
77-
steps.sleep(12)
78-
}
79-
80-
if (!podData) {
81-
throw new RuntimeException(msgPodsNotFound)
82-
}
83-
logger.debug("Helm podData for '${podDataContext.join(', ')}': ${podData}")
84-
85-
// TODO: Once the orchestration pipeline can deal with multiple replicas,
86-
// update this to deal with multiple pods.
87-
def pod = podData[0].toMap()
88-
89-
// TODO verifyImageShas doesn't work properly
90-
//verifyImageShas(deployment, pod.containers)
91-
repo.data.openshift.deployments << [(deploymentName): pod]
62+
// Store deployment metadata for Helm release
9263
def deploymentMeanKey = deploymentName + '-deploymentMean'
9364
repo.data.openshift.deployments << [(deploymentMeanKey): deploymentMean]
9465
}

0 commit comments

Comments
 (0)