Skip to content

Commit 33097a3

Browse files
feat(execution): Exclude execution retrieval for Disabled Front50 pipelines (#4819)
* feat(execution): Exclude execution retrieval for Disabled Front50 pipelines * feat(orca-front50): Adding scheduling agent to Disable unused Pipelines on Front50 --------- Co-authored-by: Jason <[email protected]>
1 parent b2399f8 commit 33097a3

File tree

13 files changed

+531
-4
lines changed

13 files changed

+531
-4
lines changed

orca-core/src/main/java/com/netflix/spinnaker/orca/pipeline/persistence/DualExecutionRepository.kt

+10
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,16 @@ class DualExecutionRepository(
230230
).distinctBy { it.id }
231231
}
232232

233+
override fun retrievePipelineConfigIdsForApplicationWithCriteria(
234+
@Nonnull application: String,
235+
@Nonnull criteria: ExecutionCriteria
236+
): List<String> {
237+
return (
238+
primary.retrievePipelineConfigIdsForApplicationWithCriteria(application, criteria) +
239+
previous.retrievePipelineConfigIdsForApplicationWithCriteria(application, criteria)
240+
).distinct()
241+
}
242+
233243
override fun retrievePipelinesForPipelineConfigIdsBetweenBuildTimeBoundary(
234244
pipelineConfigIds: MutableList<String>,
235245
buildTimeStartBoundary: Long,

orca-core/src/main/java/com/netflix/spinnaker/orca/pipeline/persistence/ExecutionRepository.java

+3
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,9 @@ Collection<PipelineExecution> retrievePipelineExecutionDetailsForApplication(
107107
@Nonnull List<String> pipelineConfigIds,
108108
int queryTimeoutSeconds);
109109

110+
List<String> retrievePipelineConfigIdsForApplicationWithCriteria(
111+
@Nonnull String application, @Nonnull ExecutionCriteria criteria);
112+
110113
/**
111114
* Returns executions in the time boundary. Redis impl does not respect pageSize or offset params,
112115
* and returns all executions. Sql impl respects these params.

orca-core/src/main/java/com/netflix/spinnaker/orca/pipeline/persistence/InMemoryExecutionRepository.kt

+10
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,16 @@ class InMemoryExecutionRepository : ExecutionRepository {
305305
.distinctBy { it.id }
306306
}
307307

308+
override fun retrievePipelineConfigIdsForApplicationWithCriteria(
309+
@Nonnull application: String,
310+
@Nonnull criteria: ExecutionCriteria
311+
): List<String> {
312+
return pipelines.values
313+
.filter { it.application == application }
314+
.applyCriteria(criteria)
315+
.map { it.id }
316+
}
317+
308318
override fun retrieveOrchestrationForCorrelationId(correlationId: String): PipelineExecution {
309319
return retrieveByCorrelationId(ORCHESTRATION, correlationId)
310320
}

orca-front50/orca-front50.gradle

+3
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ dependencies {
4040
testImplementation("com.github.ben-manes.caffeine:guava")
4141
testImplementation("org.apache.groovy:groovy-json")
4242
testRuntimeOnly("net.bytebuddy:byte-buddy")
43+
testImplementation("org.junit.jupiter:junit-jupiter-api")
44+
testImplementation("org.assertj:assertj-core")
45+
testImplementation("org.mockito:mockito-junit-jupiter")
4346
}
4447

4548
sourceSets {

orca-front50/src/main/groovy/com/netflix/spinnaker/orca/front50/Front50Service.groovy

+3
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ interface Front50Service {
6868
@GET("/pipelines/{applicationName}")
6969
List<Map<String, Object>> getPipelines(@Path("applicationName") String applicationName, @Query("refresh") boolean refresh)
7070

71+
@GET("/pipelines/{applicationName}")
72+
List<Map<String, Object>> getPipelines(@Path("applicationName") String applicationName, @Query("refresh") boolean refresh, @Query("enabledPipelines") Boolean enabledPipelines)
73+
7174
@GET("/pipelines/{pipelineId}/get")
7275
Map<String, Object> getPipeline(@Path("pipelineId") String pipelineId)
7376

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
/*
2+
* Copyright 2024 Harness, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.netflix.spinnaker.orca.front50.scheduling;
17+
18+
import static com.netflix.spinnaker.orca.api.pipeline.models.ExecutionType.PIPELINE;
19+
20+
import com.netflix.spectator.api.Id;
21+
import com.netflix.spectator.api.LongTaskTimer;
22+
import com.netflix.spectator.api.Registry;
23+
import com.netflix.spinnaker.kork.retrofit.exceptions.SpinnakerHttpException;
24+
import com.netflix.spinnaker.orca.api.pipeline.models.ExecutionStatus;
25+
import com.netflix.spinnaker.orca.front50.Front50Service;
26+
import com.netflix.spinnaker.orca.notifications.AbstractPollingNotificationAgent;
27+
import com.netflix.spinnaker.orca.notifications.NotificationClusterLock;
28+
import com.netflix.spinnaker.orca.pipeline.persistence.ExecutionRepository;
29+
import java.time.Clock;
30+
import java.time.ZoneOffset;
31+
import java.util.Arrays;
32+
import java.util.List;
33+
import java.util.Map;
34+
import java.util.stream.Collectors;
35+
import org.slf4j.Logger;
36+
import org.slf4j.LoggerFactory;
37+
import org.springframework.beans.factory.annotation.Autowired;
38+
import org.springframework.beans.factory.annotation.Value;
39+
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
40+
import org.springframework.stereotype.Component;
41+
42+
/**
43+
* This class is responsible for polling and disabling unused pipelines in Spinnaker. It extends the
44+
* AbstractPollingNotificationAgent and uses a scheduled polling mechanism to check for unused
45+
* pipelines and sends a request to Front50 to disable them if they have not been executed within a
46+
* specified threshold.
47+
*/
48+
@Component
49+
@ConditionalOnExpression(
50+
"${pollers.unused-pipelines-disable.enabled:false} && ${execution-repository.sql.enabled:false}")
51+
public class UnusedPipelineDisablePollingNotificationAgent
52+
extends AbstractPollingNotificationAgent {
53+
54+
/** Service to interact with Front50 for pipeline operations. */
55+
Front50Service front50service;
56+
57+
/** List of completed execution statuses. */
58+
private static final List<String> COMPLETED_STATUSES =
59+
ExecutionStatus.COMPLETED.stream().map(Enum::toString).collect(Collectors.toList());
60+
61+
/** Logger instance for logging events. */
62+
private final Logger log =
63+
LoggerFactory.getLogger(UnusedPipelineDisablePollingNotificationAgent.class);
64+
65+
/** Clock instance for time-based operations. */
66+
private final Clock clock;
67+
68+
/** Repository for execution data. */
69+
private final ExecutionRepository executionRepository;
70+
71+
/** Registry for metrics and monitoring. */
72+
private final Registry registry;
73+
74+
/** Polling interval in seconds. */
75+
private final long pollingIntervalSec;
76+
77+
/** Threshold in days to consider a pipeline as unused. */
78+
private final int thresholdDays;
79+
80+
/**
81+
* Flag to indicate if the operation is a dry run. In dryRun mode the intention to disable is
82+
* logged but not executed.
83+
*/
84+
private final boolean dryRun;
85+
86+
/** Timer ID for long task timer. */
87+
private final Id timerId;
88+
89+
/**
90+
* Constructor to initialize the agent with required dependencies.
91+
*
92+
* @param clusterLock the cluster lock for notification
93+
* @param executionRepository the repository for execution data
94+
* @param front50Service the service to interact with Front50
95+
* @param clock the clock instance for time-based operations
96+
* @param registry the registry for metrics and monitoring
97+
* @param pollingIntervalSec the polling interval in seconds
98+
* @param thresholdDays the threshold in days since the last execution to consider a pipeline as
99+
* unused
100+
* @param dryRun flag to indicate if the operation is a dry run
101+
*/
102+
@Autowired
103+
public UnusedPipelineDisablePollingNotificationAgent(
104+
NotificationClusterLock clusterLock,
105+
ExecutionRepository executionRepository,
106+
Front50Service front50Service,
107+
Clock clock,
108+
Registry registry,
109+
@Value("${pollers.unused-pipelines-disable.interval-sec:3600}") long pollingIntervalSec,
110+
@Value("${pollers.unused-pipelines-disable.threshold-days:365}") int thresholdDays,
111+
@Value("${pollers.unused-pipelines-disable.dry-run:true}") boolean dryRun) {
112+
super(clusterLock);
113+
this.executionRepository = executionRepository;
114+
this.clock = clock;
115+
this.registry = registry;
116+
this.pollingIntervalSec = pollingIntervalSec;
117+
this.thresholdDays = thresholdDays;
118+
this.dryRun = dryRun;
119+
this.front50service = front50Service;
120+
121+
timerId = registry.createId("pollers.unusedPipelineDisable.timing");
122+
}
123+
124+
/**
125+
* Returns the polling interval in milliseconds.
126+
*
127+
* @return the polling interval in milliseconds
128+
*/
129+
@Override
130+
protected long getPollingInterval() {
131+
return pollingIntervalSec * 1000;
132+
}
133+
134+
/**
135+
* Returns the notification type for this agent.
136+
*
137+
* @return the notification type
138+
*/
139+
@Override
140+
protected String getNotificationType() {
141+
return UnusedPipelineDisablePollingNotificationAgent.class.getSimpleName();
142+
}
143+
144+
/**
145+
* The main logic for polling and disabling unused pipelines. It retrieves all application names
146+
* from Front50, checks for pipelines that have not been executed since the thresholdDays, and
147+
* sends a request to Front50 to disable them if necessary.
148+
*/
149+
@Override
150+
protected void tick() {
151+
LongTaskTimer timer = registry.longTaskTimer(timerId);
152+
long timerId = timer.start();
153+
try {
154+
executionRepository
155+
.retrieveAllApplicationNames(PIPELINE)
156+
.forEach(
157+
app -> {
158+
log.debug("Evaluating " + app + " for unused pipelines");
159+
List<String> pipelineConfigIds =
160+
front50service.getPipelines(app, false, true).stream()
161+
.map(p -> (String) p.get("id"))
162+
.collect(Collectors.toList());
163+
164+
ExecutionRepository.ExecutionCriteria criteria =
165+
new ExecutionRepository.ExecutionCriteria();
166+
criteria.setStatuses(COMPLETED_STATUSES);
167+
criteria.setStartTimeCutoff(
168+
clock.instant().atZone(ZoneOffset.UTC).minusDays(thresholdDays).toInstant());
169+
170+
List<String> orcaExecutionsPipelineConfigIds =
171+
executionRepository.retrievePipelineConfigIdsForApplicationWithCriteria(
172+
app, criteria);
173+
174+
disableAppPipelines(app, orcaExecutionsPipelineConfigIds, pipelineConfigIds);
175+
});
176+
} catch (Exception e) {
177+
log.error("Disabling pipelines failed", e);
178+
} finally {
179+
timer.stop(timerId);
180+
}
181+
}
182+
183+
/**
184+
* Disables pipelines for a given application if they have not been executed within the threshold
185+
* days.
186+
*
187+
* @param app the application name
188+
* @param orcaExecutionsPipelineConfigIds the list of pipeline config IDs that have been executed
189+
* @param front50PipelineConfigIds the list of pipeline config IDs from Front50
190+
*/
191+
public void disableAppPipelines(
192+
String app,
193+
List<String> orcaExecutionsPipelineConfigIds,
194+
List<String> front50PipelineConfigIds) {
195+
196+
List<String> front50PipelineConfigIdsNotExecuted =
197+
front50PipelineConfigIds.stream()
198+
.filter(p -> !orcaExecutionsPipelineConfigIds.contains(p))
199+
.collect(Collectors.toList());
200+
201+
log.info(
202+
"Found "
203+
+ front50PipelineConfigIdsNotExecuted.size()
204+
+ " pipelines to disable for Application "
205+
+ app);
206+
front50PipelineConfigIdsNotExecuted.forEach(
207+
p -> {
208+
if (!dryRun) {
209+
log.debug("Disabling pipeline execution " + p);
210+
disableFront50PipelineConfigId(p);
211+
} else {
212+
log.info("DryRun mode: Disabling pipeline execution " + p);
213+
}
214+
});
215+
}
216+
217+
/**
218+
* Disables a specific pipeline config ID in Front50.
219+
*
220+
* @param pipelineConfigId the pipeline config ID to disable
221+
*/
222+
public void disableFront50PipelineConfigId(String pipelineConfigId) {
223+
Map<String, Object> pipeline = front50service.getPipeline(pipelineConfigId);
224+
if (pipeline.get("disabled") == null || !(boolean) pipeline.get("disabled")) {
225+
pipeline.put("disabled", true);
226+
try {
227+
front50service.updatePipeline(pipelineConfigId, pipeline);
228+
} catch (SpinnakerHttpException e) {
229+
if (Arrays.asList(404, 403).contains(e.getResponseCode())) {
230+
log.warn("Failed to disable pipeline " + pipelineConfigId + " due to " + e.getMessage());
231+
} else {
232+
throw e;
233+
}
234+
}
235+
}
236+
}
237+
}

0 commit comments

Comments
 (0)