diff --git a/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/BuildAgentInformation.java b/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/BuildAgentInformation.java index 35625765d858..ab24012f51fa 100644 --- a/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/BuildAgentInformation.java +++ b/src/main/java/de/tum/cit/aet/artemis/buildagent/dto/BuildAgentInformation.java @@ -11,8 +11,8 @@ // in the future are migrated or cleared. Changes should be communicated in release notes as potentially breaking changes. @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_EMPTY) -public record BuildAgentInformation(String name, int maxNumberOfConcurrentBuildJobs, int numberOfCurrentBuildJobs, List runningBuildJobs, boolean status, - List recentBuildJobs, String publicSshKey) implements Serializable { +public record BuildAgentInformation(String name, int maxNumberOfConcurrentBuildJobs, int numberOfCurrentBuildJobs, List runningBuildJobs, + BuildAgentStatus status, List recentBuildJobs, String publicSshKey) implements Serializable { @Serial private static final long serialVersionUID = 1L; @@ -27,4 +27,8 @@ public BuildAgentInformation(BuildAgentInformation agentInformation, List> runningFutures = new ConcurrentHashMap<>(); + private final Map> runningFuturesWrapper = new ConcurrentHashMap<>(); + /** * A set that contains all build jobs that were cancelled by the user. * This set is unique for each node and contains only the build jobs that were cancelled on this node. @@ -178,9 +180,20 @@ public CompletableFuture executeBuildJob(BuildJobQueueItem buildJob } } }); - futureResult.whenComplete(((result, throwable) -> runningFutures.remove(buildJobItem.id()))); - return futureResult; + runningFuturesWrapper.put(buildJobItem.id(), futureResult); + return futureResult.whenComplete(((result, throwable) -> { + runningFutures.remove(buildJobItem.id()); + runningFuturesWrapper.remove(buildJobItem.id()); + })); + } + + Set getRunningBuildJobIds() { + return Set.copyOf(runningFutures.keySet()); + } + + CompletableFuture getRunningBuildJobFutureWrapper(String buildJobId) { + return runningFuturesWrapper.get(buildJobId); } /** @@ -235,7 +248,7 @@ private void finishBuildJobExceptionally(String buildJobId, String containerName * * @param buildJobId The id of the build job that should be cancelled. */ - private void cancelBuildJob(String buildJobId) { + void cancelBuildJob(String buildJobId) { Future future = runningFutures.get(buildJobId); if (future != null) { try { diff --git a/src/main/java/de/tum/cit/aet/artemis/buildagent/service/SharedQueueProcessingService.java b/src/main/java/de/tum/cit/aet/artemis/buildagent/service/SharedQueueProcessingService.java index 7534de04e3bf..69402e346a1f 100644 --- a/src/main/java/de/tum/cit/aet/artemis/buildagent/service/SharedQueueProcessingService.java +++ b/src/main/java/de/tum/cit/aet/artemis/buildagent/service/SharedQueueProcessingService.java @@ -2,6 +2,7 @@ import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_BUILDAGENT; +import java.time.Duration; import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.List; @@ -10,9 +11,14 @@ import java.util.UUID; import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.locks.ReentrantLock; import java.util.stream.Collectors; @@ -23,7 +29,9 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; +import org.springframework.scheduling.TaskScheduler; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; @@ -33,6 +41,7 @@ import com.hazelcast.collection.ItemListener; import com.hazelcast.core.HazelcastInstance; import com.hazelcast.map.IMap; +import com.hazelcast.topic.ITopic; import de.tum.cit.aet.artemis.buildagent.dto.BuildAgentInformation; import de.tum.cit.aet.artemis.buildagent.dto.BuildJobQueueItem; @@ -64,6 +73,8 @@ public class SharedQueueProcessingService { private final BuildAgentSshKeyService buildAgentSSHKeyService; + private final TaskScheduler taskScheduler; + private IQueue queue; private IQueue resultQueue; @@ -80,15 +91,40 @@ public class SharedQueueProcessingService { */ private final ReentrantLock instanceLock = new ReentrantLock(); + /** + * Lock for pausing and resuming the build agent. + */ + private final ReentrantLock pauseResumeLock = new ReentrantLock(); + private UUID listenerId; + /** + * Scheduled future for checking availability and processing next build job. + */ + private ScheduledFuture scheduledFuture; + + /** + * Flag to indicate whether the build agent is paused. + */ + private final AtomicBoolean isPaused = new AtomicBoolean(false); + + /** + * Flag to indicate whether the build agent should process build results. This is necessary to differentiate between when the build agent is paused and grace period is not over + * yet. + */ + private final AtomicBoolean processResults = new AtomicBoolean(true); + + @Value("${artemis.continuous-integration.pause-grace-period-seconds:15}") + private int pauseGracePeriodSeconds; + public SharedQueueProcessingService(@Qualifier("hazelcastInstance") HazelcastInstance hazelcastInstance, ExecutorService localCIBuildExecutorService, - BuildJobManagementService buildJobManagementService, BuildLogsMap buildLogsMap, BuildAgentSshKeyService buildAgentSSHKeyService) { + BuildJobManagementService buildJobManagementService, BuildLogsMap buildLogsMap, BuildAgentSshKeyService buildAgentSSHKeyService, TaskScheduler taskScheduler) { this.hazelcastInstance = hazelcastInstance; this.localCIBuildExecutorService = (ThreadPoolExecutor) localCIBuildExecutorService; this.buildJobManagementService = buildJobManagementService; this.buildLogsMap = buildLogsMap; this.buildAgentSSHKeyService = buildAgentSSHKeyService; + this.taskScheduler = taskScheduler; } /** @@ -100,14 +136,51 @@ public void init() { this.processingJobs = this.hazelcastInstance.getMap("processingJobs"); this.queue = this.hazelcastInstance.getQueue("buildJobQueue"); this.resultQueue = this.hazelcastInstance.getQueue("buildResultQueue"); + // Remove listener if already present + if (this.listenerId != null) { + this.queue.removeItemListener(this.listenerId); + } this.listenerId = this.queue.addItemListener(new QueuedBuildJobItemListener(), true); + + /* + * Check every 10 seconds whether the node has at least one thread available for a new build job. + * If so, process the next build job. + * This is a backup mechanism in case the build queue is not empty, no new build jobs are entering the queue and the + * node otherwise stopped checking for build jobs in the queue. + */ + scheduledFuture = taskScheduler.scheduleAtFixedRate(this::checkAvailabilityAndProcessNextBuild, Duration.ofSeconds(10)); + + ITopic pauseBuildAgentTopic = hazelcastInstance.getTopic("pauseBuildAgentTopic"); + pauseBuildAgentTopic.addMessageListener(message -> { + if (message.getMessageObject().equals(hazelcastInstance.getCluster().getLocalMember().getAddress().toString())) { + pauseBuildAgent(); + } + }); + + ITopic resumeBuildAgentTopic = hazelcastInstance.getTopic("resumeBuildAgentTopic"); + resumeBuildAgentTopic.addMessageListener(message -> { + if (message.getMessageObject().equals(hazelcastInstance.getCluster().getLocalMember().getAddress().toString())) { + resumeBuildAgent(); + } + }); } @PreDestroy - public void removeListener() { + public void removeListenerAndCancelScheduledFuture() { + removeListener(); + cancelCheckAvailabilityAndProcessNextBuildScheduledFuture(); + } + + private void removeListener() { this.queue.removeItemListener(this.listenerId); } + private void cancelCheckAvailabilityAndProcessNextBuildScheduledFuture() { + if (scheduledFuture != null && !scheduledFuture.isCancelled()) { + scheduledFuture.cancel(false); + } + } + /** * Wait 1 minute after startup and then every 1 minute update the build agent information of the local hazelcast member. * This is necessary because the build agent information is not updated automatically when a node joins the cluster. @@ -127,17 +200,6 @@ public void updateBuildAgentInformation() { } } - /** - * Check every 10 seconds whether the node has at least one thread available for a new build job. - * If so, process the next build job. - * This is a backup mechanism in case the build queue is not empty, no new build jobs are entering the queue and the - * node otherwise stopped checking for build jobs in the queue. - */ - @Scheduled(fixedRate = 10000) - public void checkForBuildJobs() { - checkAvailabilityAndProcessNextBuild(); - } - /** * Checks whether the node has at least one thread available for a new build job. * If so, process the next build job. @@ -158,14 +220,14 @@ private void checkAvailabilityAndProcessNextBuild() { return; } - if (queue.isEmpty()) { + if (queue.isEmpty() || isPaused.get()) { return; } BuildJobQueueItem buildJob = null; instanceLock.lock(); try { // Recheck conditions after acquiring the lock to ensure they are still valid - if (!nodeIsAvailable() || queue.isEmpty()) { + if (!nodeIsAvailable() || queue.isEmpty() || isPaused.get()) { return; } @@ -241,7 +303,9 @@ private BuildAgentInformation getUpdatedLocalBuildAgentInformation(BuildJobQueue List processingJobsOfMember = getProcessingJobsOfNode(memberAddress); int numberOfCurrentBuildJobs = processingJobsOfMember.size(); int maxNumberOfConcurrentBuilds = localCIBuildExecutorService.getMaximumPoolSize(); - boolean active = numberOfCurrentBuildJobs > 0; + boolean hasJobs = numberOfCurrentBuildJobs > 0; + BuildAgentInformation.BuildAgentStatus status = isPaused.get() ? BuildAgentInformation.BuildAgentStatus.PAUSED + : hasJobs ? BuildAgentInformation.BuildAgentStatus.ACTIVE : BuildAgentInformation.BuildAgentStatus.IDLE; BuildAgentInformation agent = buildAgentInformation.get(memberAddress); List recentBuildJobs; if (agent != null) { @@ -260,7 +324,7 @@ private BuildAgentInformation getUpdatedLocalBuildAgentInformation(BuildJobQueue String publicSshKey = buildAgentSSHKeyService.getPublicKeyAsString(); - return new BuildAgentInformation(memberAddress, maxNumberOfConcurrentBuilds, numberOfCurrentBuildJobs, processingJobsOfMember, active, recentBuildJobs, publicSshKey); + return new BuildAgentInformation(memberAddress, maxNumberOfConcurrentBuilds, numberOfCurrentBuildJobs, processingJobsOfMember, status, recentBuildJobs, publicSshKey); } private List getProcessingJobsOfNode(String memberAddress) { @@ -305,7 +369,12 @@ private void processBuild(BuildJobQueueItem buildJob) { buildLogsMap.removeBuildLogs(buildJob.id()); ResultQueueItem resultQueueItem = new ResultQueueItem(buildResult, finishedJob, buildLogs, null); - resultQueue.add(resultQueueItem); + if (processResults.get()) { + resultQueue.add(resultQueueItem); + } + else { + log.info("Build agent is paused. Not adding build result to result queue for build job: {}", buildJob); + } // after processing a build job, remove it from the processing jobs processingJobs.remove(buildJob.id()); @@ -342,7 +411,12 @@ private void processBuild(BuildJobQueueItem buildJob) { failedResult.setBuildLogEntries(buildLogs); ResultQueueItem resultQueueItem = new ResultQueueItem(failedResult, job, buildLogs, ex); - resultQueue.add(resultQueueItem); + if (processResults.get()) { + resultQueue.add(resultQueueItem); + } + else { + log.info("Build agent is paused. Not adding build result to result queue for build job: {}", buildJob); + } processingJobs.remove(buildJob.id()); localProcessingJobs.decrementAndGet(); @@ -353,6 +427,90 @@ private void processBuild(BuildJobQueueItem buildJob) { }); } + private void pauseBuildAgent() { + if (isPaused.get()) { + log.info("Build agent is already paused"); + return; + } + + pauseResumeLock.lock(); + try { + log.info("Pausing build agent with address {}", hazelcastInstance.getCluster().getLocalMember().getAddress().toString()); + + isPaused.set(true); + removeListenerAndCancelScheduledFuture(); + updateLocalBuildAgentInformation(); + + log.info("Gracefully cancelling running build jobs"); + + Set runningBuildJobIds = buildJobManagementService.getRunningBuildJobIds(); + if (runningBuildJobIds.isEmpty()) { + log.info("No running build jobs to cancel"); + } + else { + List> runningFuturesWrapper = runningBuildJobIds.stream().map(buildJobManagementService::getRunningBuildJobFutureWrapper) + .filter(Objects::nonNull).toList(); + + if (!runningFuturesWrapper.isEmpty()) { + CompletableFuture allFuturesWrapper = CompletableFuture.allOf(runningFuturesWrapper.toArray(new CompletableFuture[0])); + + try { + allFuturesWrapper.get(pauseGracePeriodSeconds, TimeUnit.SECONDS); + log.info("All running build jobs finished during grace period"); + } + catch (TimeoutException e) { + handleTimeoutAndCancelRunningJobs(); + } + catch (InterruptedException | ExecutionException e) { + log.error("Error while waiting for running build jobs to finish", e); + } + } + } + } + finally { + pauseResumeLock.unlock(); + } + } + + private void handleTimeoutAndCancelRunningJobs() { + if (!isPaused.get()) { + log.info("Build agent was resumed before the build jobs could be cancelled"); + return; + } + log.info("Grace period exceeded. Cancelling running build jobs."); + + processResults.set(false); + Set runningBuildJobIdsAfterGracePeriod = buildJobManagementService.getRunningBuildJobIds(); + List runningBuildJobsAfterGracePeriod = processingJobs.getAll(runningBuildJobIdsAfterGracePeriod).values().stream().toList(); + runningBuildJobIdsAfterGracePeriod.forEach(buildJobManagementService::cancelBuildJob); + queue.addAll(runningBuildJobsAfterGracePeriod); + log.info("Cancelled running build jobs and added them back to the queue with Ids {}", runningBuildJobIdsAfterGracePeriod); + log.debug("Cancelled running build jobs: {}", runningBuildJobsAfterGracePeriod); + } + + private void resumeBuildAgent() { + if (!isPaused.get()) { + log.info("Build agent is already running"); + return; + } + + pauseResumeLock.lock(); + try { + log.info("Resuming build agent with address {}", hazelcastInstance.getCluster().getLocalMember().getAddress().toString()); + isPaused.set(false); + processResults.set(true); + // We remove the listener and scheduledTask first to avoid having multiple listeners and scheduled tasks running + removeListenerAndCancelScheduledFuture(); + listenerId = queue.addItemListener(new QueuedBuildJobItemListener(), true); + scheduledFuture = taskScheduler.scheduleAtFixedRate(this::checkAvailabilityAndProcessNextBuild, Duration.ofSeconds(10)); + checkAvailabilityAndProcessNextBuild(); + updateLocalBuildAgentInformation(); + } + finally { + pauseResumeLock.unlock(); + } + } + /** * Checks whether the node has at least one thread available for a new build job. */ diff --git a/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminBuildJobQueueResource.java b/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminBuildJobQueueResource.java index db71ab34c05a..33e7bd099155 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminBuildJobQueueResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/web/admin/AdminBuildJobQueueResource.java @@ -15,6 +15,7 @@ import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -188,4 +189,44 @@ public ResponseEntity getBuildJobStatistics(@RequestPara BuildJobsStatisticsDTO buildJobStatistics = BuildJobsStatisticsDTO.of(buildJobResultCountDtos); return ResponseEntity.ok(buildJobStatistics); } + + /** + * {@code PUT /api/admin/agent/{agentName}/pause} : Pause the specified build agent. + * This endpoint allows administrators to pause a specific build agent by its name. + * Pausing a build agent will prevent it from picking up any new build jobs until it is resumed. + * + *

+ * Authorization: This operation requires admin privileges, enforced by {@code @EnforceAdmin}. + *

+ * + * @param agentName the name of the build agent to be paused (provided as a path variable) + * @return {@link ResponseEntity} with status code 204 (No Content) if the agent was successfully paused + * or an appropriate error response if something went wrong + */ + @PutMapping("agent/{agentName}/pause") + public ResponseEntity pauseBuildAgent(@PathVariable String agentName) { + log.debug("REST request to pause agent {}", agentName); + localCIBuildJobQueueService.pauseBuildAgent(agentName); + return ResponseEntity.noContent().build(); + } + + /** + * {@code PUT /api/admin/agent/{agentName}/resume} : Resume the specified build agent. + * This endpoint allows administrators to resume a specific build agent by its name. + * Resuming a build agent will allow it to pick up new build jobs again. + * + *

+ * Authorization: This operation requires admin privileges, enforced by {@code @EnforceAdmin}. + *

+ * + * @param agentName the name of the build agent to be resumed (provided as a path variable) + * @return {@link ResponseEntity} with status code 204 (No Content) if the agent was successfully resumed + * or an appropriate error response if something went wrong + */ + @PutMapping("agent/{agentName}/resume") + public ResponseEntity resumeBuildAgent(@PathVariable String agentName) { + log.debug("REST request to resume agent {}", agentName); + localCIBuildJobQueueService.resumeBuildAgent(agentName); + return ResponseEntity.noContent().build(); + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/SharedQueueManagementService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/SharedQueueManagementService.java index a99766da005b..9af5fe3b45c1 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/SharedQueueManagementService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localci/SharedQueueManagementService.java @@ -67,6 +67,10 @@ public class SharedQueueManagementService { private ITopic canceledBuildJobsTopic; + private ITopic pauseBuildAgentTopic; + + private ITopic resumeBuildAgentTopic; + public SharedQueueManagementService(BuildJobRepository buildJobRepository, @Qualifier("hazelcastInstance") HazelcastInstance hazelcastInstance, ProfileService profileService) { this.buildJobRepository = buildJobRepository; this.hazelcastInstance = hazelcastInstance; @@ -83,6 +87,8 @@ public void init() { this.queue = this.hazelcastInstance.getQueue("buildJobQueue"); this.canceledBuildJobsTopic = hazelcastInstance.getTopic("canceledBuildJobsTopic"); this.dockerImageCleanupInfo = this.hazelcastInstance.getMap("dockerImageCleanupInfo"); + this.pauseBuildAgentTopic = hazelcastInstance.getTopic("pauseBuildAgentTopic"); + this.resumeBuildAgentTopic = hazelcastInstance.getTopic("resumeBuildAgentTopic"); } /** @@ -135,6 +141,14 @@ public List getBuildAgentInformationWithoutRecentBuildJob agent.numberOfCurrentBuildJobs(), agent.runningBuildJobs(), agent.status(), null, null)).toList(); } + public void pauseBuildAgent(String agent) { + pauseBuildAgentTopic.publish(agent); + } + + public void resumeBuildAgent(String agent) { + resumeBuildAgentTopic.publish(agent); + } + /** * Cancel a build job by removing it from the queue or stopping the build process. * diff --git a/src/main/webapp/app/entities/programming/build-agent.model.ts b/src/main/webapp/app/entities/programming/build-agent.model.ts index 172a8596b299..86c51ffc8b35 100644 --- a/src/main/webapp/app/entities/programming/build-agent.model.ts +++ b/src/main/webapp/app/entities/programming/build-agent.model.ts @@ -1,12 +1,18 @@ import { BaseEntity } from 'app/shared/model/base-entity'; import { BuildJob } from 'app/entities/programming/build-job.model'; +export enum BuildAgentStatus { + ACTIVE = 'ACTIVE', + PAUSED = 'PAUSED', + IDLE = 'IDLE', +} + export class BuildAgent implements BaseEntity { public id?: number; public name?: string; public maxNumberOfConcurrentBuildJobs?: number; public numberOfCurrentBuildJobs?: number; public runningBuildJobs?: BuildJob[]; - public status?: boolean; + public status?: BuildAgentStatus; public recentBuildJobs?: BuildJob[]; } diff --git a/src/main/webapp/app/localci/build-agents/build-agent-details/build-agent-details/build-agent-details.component.html b/src/main/webapp/app/localci/build-agents/build-agent-details/build-agent-details/build-agent-details.component.html index ea1b2da0f1d2..1a88dba6a6d8 100644 --- a/src/main/webapp/app/localci/build-agents/build-agent-details/build-agent-details/build-agent-details.component.html +++ b/src/main/webapp/app/localci/build-agents/build-agent-details/build-agent-details/build-agent-details.component.html @@ -1,10 +1,25 @@
@if (buildAgent) { -
-
-

- : -

{{ buildAgent.name }}

+
+
+
+

+ : +

{{ buildAgent.name }}

+
+
+ @if (buildAgent.status === 'PAUSED') { + + } @else { + + } +
@@ -28,10 +43,16 @@

{{ buildAgent.nam - @if (value) { - - } @else { - + @switch (value) { + @case ('ACTIVE') { + + } + @case ('IDLE') { + + } + @case ('PAUSED') { + + } } diff --git a/src/main/webapp/app/localci/build-agents/build-agent-details/build-agent-details/build-agent-details.component.ts b/src/main/webapp/app/localci/build-agents/build-agent-details/build-agent-details/build-agent-details.component.ts index 074c1e704695..de19544b01ad 100644 --- a/src/main/webapp/app/localci/build-agents/build-agent-details/build-agent-details/build-agent-details.component.ts +++ b/src/main/webapp/app/localci/build-agents/build-agent-details/build-agent-details/build-agent-details.component.ts @@ -2,12 +2,13 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { BuildAgent } from 'app/entities/programming/build-agent.model'; import { BuildAgentsService } from 'app/localci/build-agents/build-agents.service'; import { Subscription } from 'rxjs'; -import { faCircleCheck, faExclamationCircle, faExclamationTriangle, faTimes } from '@fortawesome/free-solid-svg-icons'; +import { faCircleCheck, faExclamationCircle, faExclamationTriangle, faPause, faPlay, faTimes } from '@fortawesome/free-solid-svg-icons'; import dayjs from 'dayjs/esm'; import { TriggeredByPushTo } from 'app/entities/programming/repository-info.model'; import { ActivatedRoute } from '@angular/router'; import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; import { BuildQueueService } from 'app/localci/build-queue/build-queue.service'; +import { AlertService, AlertType } from 'app/core/util/alert.service'; @Component({ selector: 'jhi-build-agent-details', @@ -28,12 +29,15 @@ export class BuildAgentDetailsComponent implements OnInit, OnDestroy { faExclamationCircle = faExclamationCircle; faExclamationTriangle = faExclamationTriangle; faTimes = faTimes; + readonly faPause = faPause; + readonly faPlay = faPlay; constructor( private websocketService: JhiWebsocketService, private buildAgentsService: BuildAgentsService, private route: ActivatedRoute, private buildQueueService: BuildQueueService, + private alertService: AlertService, ) {} ngOnInit() { @@ -105,4 +109,52 @@ export class BuildAgentDetailsComponent implements OnInit, OnDestroy { const url = `/api/build-log/${resultId}`; window.open(url, '_blank'); } + + pauseBuildAgent(): void { + if (this.buildAgent.name) { + this.buildAgentsService.pauseBuildAgent(this.buildAgent.name).subscribe({ + next: () => { + this.alertService.addAlert({ + type: AlertType.SUCCESS, + message: 'artemisApp.buildAgents.alerts.buildAgentPaused', + }); + }, + error: () => { + this.alertService.addAlert({ + type: AlertType.DANGER, + message: 'artemisApp.buildAgents.alerts.buildAgentPauseFailed', + }); + }, + }); + } else { + this.alertService.addAlert({ + type: AlertType.WARNING, + message: 'artemisApp.buildAgents.alerts.buildAgentWithoutName', + }); + } + } + + resumeBuildAgent(): void { + if (this.buildAgent.name) { + this.buildAgentsService.resumeBuildAgent(this.buildAgent.name).subscribe({ + next: () => { + this.alertService.addAlert({ + type: AlertType.SUCCESS, + message: 'artemisApp.buildAgents.alerts.buildAgentResumed', + }); + }, + error: () => { + this.alertService.addAlert({ + type: AlertType.DANGER, + message: 'artemisApp.buildAgents.alerts.buildAgentResumeFailed', + }); + }, + }); + } else { + this.alertService.addAlert({ + type: AlertType.WARNING, + message: 'artemisApp.buildAgents.alerts.buildAgentWithoutName', + }); + } + } } diff --git a/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-summary.component.html b/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-summary.component.html index a9b878d14d33..0982463d75a9 100644 --- a/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-summary.component.html +++ b/src/main/webapp/app/localci/build-agents/build-agent-summary/build-agent-summary.component.html @@ -41,10 +41,16 @@

- @if (value) { - - } @else { - + @switch (value) { + @case ('ACTIVE') { + + } + @case ('IDLE') { + + } + @case ('PAUSED') { + + } } diff --git a/src/main/webapp/app/localci/build-agents/build-agents.service.ts b/src/main/webapp/app/localci/build-agents/build-agents.service.ts index 99905ad80c51..b701b85fe5d0 100644 --- a/src/main/webapp/app/localci/build-agents/build-agents.service.ts +++ b/src/main/webapp/app/localci/build-agents/build-agents.service.ts @@ -27,4 +27,28 @@ export class BuildAgentsService { }), ); } + + /** + * Pause Build Agent + */ + pauseBuildAgent(agentName: string): Observable { + const encodedAgentName = encodeURIComponent(agentName); + return this.http.put(`${this.adminResourceUrl}/agent/${encodedAgentName}/pause`, null).pipe( + catchError((err) => { + return throwError(() => new Error(`Failed to pause build agent ${agentName}\n${err.message}`)); + }), + ); + } + + /** + * Resume Build Agent + */ + resumeBuildAgent(agentName: string): Observable { + const encodedAgentName = encodeURIComponent(agentName); + return this.http.put(`${this.adminResourceUrl}/agent/${encodedAgentName}/resume`, null).pipe( + catchError((err) => { + return throwError(() => new Error(`Failed to resume build agent ${agentName}\n${err.message}`)); + }), + ); + } } diff --git a/src/main/webapp/i18n/de/buildAgents.json b/src/main/webapp/i18n/de/buildAgents.json index cabe778eee21..2cd4ca72def3 100644 --- a/src/main/webapp/i18n/de/buildAgents.json +++ b/src/main/webapp/i18n/de/buildAgents.json @@ -12,9 +12,19 @@ "recentBuildJobs": "Letzte Build Jobs", "running": "Laufend", "idle": "Inaktiv", + "paused": "Angehalten", "onlineAgents": "online Agent(en)", "of": "von", - "buildJobsRunning": "Build Jobs laufen" + "buildJobsRunning": "Build Jobs laufen", + "alerts": { + "buildAgentPaused": "Anfrage zum Anhalten des Build-Agenten erfolgreich gesendet. Der Agent akzeptiert keine neuen BuildJobs und wird die aktuellen BuildJobs entweder ordnungsgemäß beenden oder nach einer konfigurierbaren Anzahl von Sekunden abbrechen.", + "buildAgentResumed": "Anfrage zum Fortsetzen des BuildJobs erfolgreich gesendet. Der Agent wird wieder neue BuildJobs annehmen.", + "buildAgentPauseFailed": "Anhalten des Build-Agenten fehlgeschlagen.", + "buildAgentResumeFailed": "Fortsetzen des Build-Agenten fehlgeschlagen.", + "buildAgentWithoutName": "Der Name des Build-Agenten ist erforderlich." + }, + "pause": "Anhalten", + "resume": "Fortsetzen" } } } diff --git a/src/main/webapp/i18n/en/buildAgents.json b/src/main/webapp/i18n/en/buildAgents.json index 195a549cda0f..e1401d97e272 100644 --- a/src/main/webapp/i18n/en/buildAgents.json +++ b/src/main/webapp/i18n/en/buildAgents.json @@ -12,9 +12,19 @@ "recentBuildJobs": "Recent build jobs", "running": "Running", "idle": "Idle", + "paused": "Paused", "onlineAgents": "online agent(s)", "of": "of", - "buildJobsRunning": "build jobs running" + "buildJobsRunning": "build jobs running", + "alerts": { + "buildAgentPaused": "Build agent pause request sent successfully. The agent will not accept new build jobs and will gracefully finish the current build jobs or will cancel them after a configurable seconds.", + "buildAgentResumed": "Build agent resume request sent successfully. The agent will start accepting new build jobs.", + "buildAgentPauseFailed": "Failed to pause the build agent.", + "buildAgentResumeFailed": "Failed to resume the build agent.", + "buildAgentWithoutName": "Build agent name is required." + }, + "pause": "Pause", + "resume": "Resume" } } } diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIIntegrationTest.java index 7d17a712b4ec..4563d7c9f95c 100644 --- a/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIIntegrationTest.java @@ -41,6 +41,7 @@ import org.mockito.ArgumentMatcher; import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.FileSystemResource; import org.springframework.http.HttpStatus; @@ -51,8 +52,12 @@ import com.github.dockerjava.api.command.ExecStartCmd; import com.github.dockerjava.api.exception.NotFoundException; import com.github.dockerjava.api.model.Frame; +import com.hazelcast.collection.IQueue; +import com.hazelcast.core.HazelcastInstance; +import com.hazelcast.map.IMap; import de.tum.cit.aet.artemis.assessment.domain.Result; +import de.tum.cit.aet.artemis.buildagent.dto.BuildJobQueueItem; import de.tum.cit.aet.artemis.buildagent.dto.ResultBuildJob; import de.tum.cit.aet.artemis.core.exception.VersionControlException; import de.tum.cit.aet.artemis.exercise.domain.ExerciseMode; @@ -95,6 +100,10 @@ class LocalCIIntegrationTest extends AbstractLocalCILocalVCIntegrationTest { @Autowired private BuildLogEntryService buildLogEntryService; + @Autowired + @Qualifier("hazelcastInstance") + private HazelcastInstance hazelcastInstance; + @Value("${artemis.user-management.internal-admin.username}") private String localVCUsername; @@ -502,4 +511,25 @@ void testCustomCheckoutPaths() { localVCServletService.processNewPush(commitHash, studentAssignmentRepository.originGit.getRepository()); localVCLocalCITestService.testLatestSubmission(participation.getId(), commitHash, 1, false); } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void testPauseAndResumeBuildAgent() { + String memberAddress = hazelcastInstance.getCluster().getLocalMember().getAddress().toString(); + hazelcastInstance.getTopic("pauseBuildAgentTopic").publish(memberAddress); + + ProgrammingExerciseStudentParticipation studentParticipation = localVCLocalCITestService.createParticipation(programmingExercise, student1Login); + + localVCServletService.processNewPush(commitHash, studentAssignmentRepository.originGit.getRepository()); + await().until(() -> { + IQueue buildQueue = hazelcastInstance.getQueue("buildJobQueue"); + IMap buildJobMap = hazelcastInstance.getMap("processingJobs"); + BuildJobQueueItem buildJobQueueItem = buildQueue.peek(); + + return buildJobQueueItem != null && buildJobQueueItem.buildConfig().commitHashToBuild().equals(commitHash) && !buildJobMap.containsKey(buildJobQueueItem.id()); + }); + + hazelcastInstance.getTopic("resumeBuildAgentTopic").publish(memberAddress); + localVCLocalCITestService.testLatestSubmission(studentParticipation.getId(), commitHash, 1, false); + } } diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIResourceIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIResourceIntegrationTest.java index 3b7ced720320..e05b87776154 100644 --- a/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIResourceIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIResourceIntegrationTest.java @@ -1,7 +1,10 @@ package de.tum.cit.aet.artemis.programming.icl; import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.time.ZonedDateTime; @@ -85,7 +88,7 @@ protected String getTestPrefix() { @BeforeEach void createJobs() { // temporarily remove listener to avoid triggering build job processing - sharedQueueProcessingService.removeListener(); + sharedQueueProcessingService.removeListenerAndCancelScheduledFuture(); JobTimingInfo jobTimingInfo1 = new JobTimingInfo(ZonedDateTime.now().plusMinutes(1), ZonedDateTime.now().plusMinutes(2), ZonedDateTime.now().plusMinutes(3)); JobTimingInfo jobTimingInfo2 = new JobTimingInfo(ZonedDateTime.now(), ZonedDateTime.now().plusMinutes(1), ZonedDateTime.now().plusMinutes(2)); @@ -97,7 +100,7 @@ void createJobs() { job1 = new BuildJobQueueItem("1", "job1", "address1", 1, course.getId(), 1, 1, 1, BuildStatus.SUCCESSFUL, repositoryInfo, jobTimingInfo1, buildConfig, null); job2 = new BuildJobQueueItem("2", "job2", "address1", 2, course.getId(), 1, 1, 1, BuildStatus.SUCCESSFUL, repositoryInfo, jobTimingInfo2, buildConfig, null); String memberAddress = hazelcastInstance.getCluster().getLocalMember().getAddress().toString(); - agent1 = new BuildAgentInformation(memberAddress, 1, 0, new ArrayList<>(List.of(job1)), false, new ArrayList<>(List.of(job2)), null); + agent1 = new BuildAgentInformation(memberAddress, 1, 0, new ArrayList<>(List.of(job1)), BuildAgentInformation.BuildAgentStatus.IDLE, new ArrayList<>(List.of(job2)), null); BuildJobQueueItem finishedJobQueueItem1 = new BuildJobQueueItem("3", "job3", "address1", 3, course.getId(), 1, 1, 1, BuildStatus.SUCCESSFUL, repositoryInfo, jobTimingInfo1, buildConfig, null); BuildJobQueueItem finishedJobQueueItem2 = new BuildJobQueueItem("4", "job4", "address1", 4, course.getId() + 1, 1, 1, 1, BuildStatus.FAILED, repositoryInfo, jobTimingInfo2, @@ -360,4 +363,14 @@ void testGetBuildJobStatistics() throws Exception { assertThat(response.failedBuilds()).isEqualTo(1); assertThat(response.cancelledBuilds()).isEqualTo(0); } + + @Test + @WithMockUser(username = TEST_PREFIX + "admin", roles = "ADMIN") + void testPauseBuildAgent() throws Exception { + request.put("/api/admin/agent/" + URLEncoder.encode(agent1.name(), StandardCharsets.UTF_8) + "/pause", null, HttpStatus.NO_CONTENT); + await().until(() -> buildAgentInformation.get(agent1.name()).status() == BuildAgentInformation.BuildAgentStatus.PAUSED); + + request.put("/api/admin/agent/" + URLEncoder.encode(agent1.name(), StandardCharsets.UTF_8) + "/resume", null, HttpStatus.NO_CONTENT); + await().until(() -> buildAgentInformation.get(agent1.name()).status() == BuildAgentInformation.BuildAgentStatus.IDLE); + } } diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIServiceTest.java b/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIServiceTest.java index 040aef91d87e..b935eb8d2d67 100644 --- a/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIServiceTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalCIServiceTest.java @@ -80,7 +80,7 @@ void setUp() { processingJobs = hazelcastInstance.getMap("processingJobs"); // remove listener to avoid triggering build job processing - sharedQueueProcessingService.removeListener(); + sharedQueueProcessingService.removeListenerAndCancelScheduledFuture(); } @AfterEach diff --git a/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalVCLocalCIIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalVCLocalCIIntegrationTest.java index 877ccd6493e0..6b8eaf1b4f9b 100644 --- a/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalVCLocalCIIntegrationTest.java +++ b/src/test/java/de/tum/cit/aet/artemis/programming/icl/LocalVCLocalCIIntegrationTest.java @@ -973,11 +973,6 @@ void testFetchPush_instructorPracticeRepository() throws Exception { @Nested class BuildJobPriorityTest { - @BeforeEach - void setUp() { - sharedQueueProcessingService.removeListener(); - } - @AfterEach void tearDown() { queuedJobs.clear(); @@ -1031,6 +1026,9 @@ void testPriorityRunningExam() throws Exception { } private void testPriority(String login, int expectedPriority) throws Exception { + queuedJobs.clear(); + sharedQueueProcessingService.removeListenerAndCancelScheduledFuture(); + log.info("Creating participation"); ProgrammingExerciseStudentParticipation studentParticipation = localVCLocalCITestService.createParticipation(programmingExercise, student1Login); diff --git a/src/test/javascript/spec/component/localci/build-agents/build-agent-details.component.spec.ts b/src/test/javascript/spec/component/localci/build-agents/build-agent-details.component.spec.ts index 9c4ddded2d45..2db5c31ac80b 100644 --- a/src/test/javascript/spec/component/localci/build-agents/build-agent-details.component.spec.ts +++ b/src/test/javascript/spec/component/localci/build-agents/build-agent-details.component.spec.ts @@ -1,21 +1,22 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { JhiWebsocketService } from 'app/core/websocket/websocket.service'; import { BuildAgentsService } from 'app/localci/build-agents/build-agents.service'; -import { of } from 'rxjs'; +import { of, throwError } from 'rxjs'; import { BuildJob } from 'app/entities/programming/build-job.model'; import dayjs from 'dayjs/esm'; import { ArtemisTestModule } from '../../../test.module'; import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; import { DataTableComponent } from 'app/shared/data-table/data-table.component'; -import { MockComponent, MockPipe } from 'ng-mocks'; +import { MockComponent, MockPipe, MockProvider } from 'ng-mocks'; import { NgxDatatableModule } from '@siemens/ngx-datatable'; -import { BuildAgent } from 'app/entities/programming/build-agent.model'; +import { BuildAgent, BuildAgentStatus } from 'app/entities/programming/build-agent.model'; import { RepositoryInfo, TriggeredByPushTo } from 'app/entities/programming/repository-info.model'; import { JobTimingInfo } from 'app/entities/job-timing-info.model'; import { BuildConfig } from 'app/entities/programming/build-config.model'; import { BuildAgentDetailsComponent } from 'app/localci/build-agents/build-agent-details/build-agent-details/build-agent-details.component'; import { MockActivatedRoute } from '../../../helpers/mocks/activated-route/mock-activated-route'; import { ActivatedRoute } from '@angular/router'; +import { AlertService, AlertType } from 'app/core/util/alert.service'; describe('BuildAgentDetailsComponent', () => { let component: BuildAgentDetailsComponent; @@ -30,6 +31,8 @@ describe('BuildAgentDetailsComponent', () => { const mockBuildAgentsService = { getBuildAgentDetails: jest.fn().mockReturnValue(of([])), + pauseBuildAgent: jest.fn().mockReturnValue(of({})), + resumeBuildAgent: jest.fn().mockReturnValue(of({})), }; const repositoryInfo: RepositoryInfo = { @@ -114,9 +117,12 @@ describe('BuildAgentDetailsComponent', () => { numberOfCurrentBuildJobs: 2, runningBuildJobs: mockRunningJobs1, recentBuildJobs: mockRecentBuildJobs1, - status: true, + status: BuildAgentStatus.ACTIVE, }; + let alertService: AlertService; + let alertServiceAddAlertStub: jest.SpyInstance; + beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ imports: [ArtemisTestModule, NgxDatatableModule], @@ -126,6 +132,7 @@ describe('BuildAgentDetailsComponent', () => { { provide: ActivatedRoute, useValue: new MockActivatedRoute({ key: 'ABC123' }) }, { provide: BuildAgentsService, useValue: mockBuildAgentsService }, { provide: DataTableComponent, useClass: DataTableComponent }, + MockProvider(AlertService), ], }).compileComponents(); @@ -133,6 +140,8 @@ describe('BuildAgentDetailsComponent', () => { component = fixture.componentInstance; activatedRoute = fixture.debugElement.injector.get(ActivatedRoute) as MockActivatedRoute; activatedRoute.setParameters({ agentName: mockBuildAgent.name }); + alertService = TestBed.inject(AlertService); + alertServiceAddAlertStub = jest.spyOn(alertService, 'addAlert'); })); beforeEach(() => { @@ -202,4 +211,66 @@ describe('BuildAgentDetailsComponent', () => { expect(spy).toHaveBeenCalledOnce(); }); + + it('should show an alert when pausing build agent without a name', () => { + component.buildAgent = { ...mockBuildAgent, name: '' }; + component.pauseBuildAgent(); + + expect(alertServiceAddAlertStub).toHaveBeenCalledWith({ + type: AlertType.WARNING, + message: 'artemisApp.buildAgents.alerts.buildAgentWithoutName', + }); + }); + + it('should show an alert when resuming build agent without a name', () => { + component.buildAgent = { ...mockBuildAgent, name: '' }; + component.resumeBuildAgent(); + + expect(alertServiceAddAlertStub).toHaveBeenCalledWith({ + type: AlertType.WARNING, + message: 'artemisApp.buildAgents.alerts.buildAgentWithoutName', + }); + }); + + it('should show success alert when pausing build agent', () => { + component.buildAgent = mockBuildAgent; + + component.pauseBuildAgent(); + expect(alertServiceAddAlertStub).toHaveBeenCalledWith({ + type: AlertType.SUCCESS, + message: 'artemisApp.buildAgents.alerts.buildAgentPaused', + }); + }); + + it('should show error alert when pausing build agent fails', () => { + mockBuildAgentsService.pauseBuildAgent.mockReturnValue(throwError(() => new Error())); + component.buildAgent = mockBuildAgent; + + component.pauseBuildAgent(); + expect(alertServiceAddAlertStub).toHaveBeenCalledWith({ + type: AlertType.DANGER, + message: 'artemisApp.buildAgents.alerts.buildAgentPauseFailed', + }); + }); + + it('should show success alert when resuming build agent', () => { + component.buildAgent = mockBuildAgent; + + component.resumeBuildAgent(); + expect(alertServiceAddAlertStub).toHaveBeenCalledWith({ + type: AlertType.SUCCESS, + message: 'artemisApp.buildAgents.alerts.buildAgentResumed', + }); + }); + + it('should show error alert when resuming build agent fails', () => { + mockBuildAgentsService.resumeBuildAgent.mockReturnValue(throwError(() => new Error())); + component.buildAgent = mockBuildAgent; + + component.resumeBuildAgent(); + expect(alertServiceAddAlertStub).toHaveBeenCalledWith({ + type: AlertType.DANGER, + message: 'artemisApp.buildAgents.alerts.buildAgentResumeFailed', + }); + }); }); diff --git a/src/test/javascript/spec/component/localci/build-agents/build-agent-summary.component.spec.ts b/src/test/javascript/spec/component/localci/build-agents/build-agent-summary.component.spec.ts index 7c18ab22e946..9a4e94b1cb7e 100644 --- a/src/test/javascript/spec/component/localci/build-agents/build-agent-summary.component.spec.ts +++ b/src/test/javascript/spec/component/localci/build-agents/build-agent-summary.component.spec.ts @@ -10,7 +10,7 @@ import { ArtemisTranslatePipe } from 'app/shared/pipes/artemis-translate.pipe'; import { DataTableComponent } from 'app/shared/data-table/data-table.component'; import { MockComponent, MockPipe } from 'ng-mocks'; import { NgxDatatableModule } from '@siemens/ngx-datatable'; -import { BuildAgent } from 'app/entities/programming/build-agent.model'; +import { BuildAgent, BuildAgentStatus } from 'app/entities/programming/build-agent.model'; import { RepositoryInfo, TriggeredByPushTo } from 'app/entities/programming/repository-info.model'; import { JobTimingInfo } from 'app/entities/job-timing-info.model'; import { BuildConfig } from 'app/entities/programming/build-config.model'; @@ -124,7 +124,7 @@ describe('BuildAgentSummaryComponent', () => { maxNumberOfConcurrentBuildJobs: 2, numberOfCurrentBuildJobs: 2, runningBuildJobs: mockRunningJobs1, - status: true, + status: BuildAgentStatus.ACTIVE, }, { id: 2, @@ -132,7 +132,7 @@ describe('BuildAgentSummaryComponent', () => { maxNumberOfConcurrentBuildJobs: 2, numberOfCurrentBuildJobs: 2, runningBuildJobs: mockRunningJobs2, - status: true, + status: BuildAgentStatus.ACTIVE, }, ]; diff --git a/src/test/javascript/spec/component/localci/build-agents/build-agents.service.spec.ts b/src/test/javascript/spec/component/localci/build-agents/build-agents.service.spec.ts index 421b868c03fe..c73f5d49ee15 100644 --- a/src/test/javascript/spec/component/localci/build-agents/build-agents.service.spec.ts +++ b/src/test/javascript/spec/component/localci/build-agents/build-agents.service.spec.ts @@ -1,16 +1,17 @@ import { TestBed } from '@angular/core/testing'; +import { provideHttpClient } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { MockTranslateService } from '../../../helpers/mocks/service/mock-translate.service'; import { TranslateService } from '@ngx-translate/core'; import { BuildJob } from 'app/entities/programming/build-job.model'; import dayjs from 'dayjs/esm'; +import { lastValueFrom } from 'rxjs'; import { BuildAgentsService } from 'app/localci/build-agents/build-agents.service'; import { BuildAgent } from 'app/entities/programming/build-agent.model'; import { RepositoryInfo, TriggeredByPushTo } from 'app/entities/programming/repository-info.model'; import { JobTimingInfo } from 'app/entities/job-timing-info.model'; import { BuildConfig } from 'app/entities/programming/build-config.model'; -import { provideHttpClient } from '@angular/common/http'; describe('BuildAgentsService', () => { let service: BuildAgentsService; @@ -113,6 +114,74 @@ describe('BuildAgentsService', () => { req.flush(expectedResponse); }); + it('should handle get build agent details error', async () => { + const errorMessage = 'Failed to fetch build agent details buildAgent1'; + + const observable = lastValueFrom(service.getBuildAgentDetails('buildAgent1')); + + const req = httpMock.expectOne(`${service.adminResourceUrl}/build-agent?agentName=buildAgent1`); + expect(req.request.method).toBe('GET'); + req.flush({ message: errorMessage }, { status: 500, statusText: 'Internal Server Error' }); + + try { + await observable; + throw new Error('expected an error, but got a success'); + } catch (error) { + expect(error.message).toContain(errorMessage); + } + }); + + it('should pause build agent', () => { + service.pauseBuildAgent('buildAgent1').subscribe(); + + const req = httpMock.expectOne(`${service.adminResourceUrl}/agent/buildAgent1/pause`); + expect(req.request.method).toBe('PUT'); + req.flush({}); + }); + + it('should resume build agent', () => { + service.resumeBuildAgent('buildAgent1').subscribe(); + + const req = httpMock.expectOne(`${service.adminResourceUrl}/agent/buildAgent1/resume`); + expect(req.request.method).toBe('PUT'); + req.flush({}); + }); + + it('should handle pause build agent error', async () => { + const errorMessage = 'Failed to pause build agent buildAgent1'; + + const observable = lastValueFrom(service.pauseBuildAgent('buildAgent1')); + + const req = httpMock.expectOne(`${service.adminResourceUrl}/agent/buildAgent1/pause`); + expect(req.request.method).toBe('PUT'); + req.flush({ message: errorMessage }, { status: 500, statusText: 'Internal Server Error' }); + + try { + await observable; + throw new Error('expected an error, but got a success'); + } catch (error) { + expect(error.message).toContain(errorMessage); + } + }); + + it('should handle resume build agent error', async () => { + const errorMessage = 'Failed to resume build agent buildAgent1'; + + const observable = lastValueFrom(service.resumeBuildAgent('buildAgent1')); + + // Set up the expected HTTP request and flush the response with an error. + const req = httpMock.expectOne(`${service.adminResourceUrl}/agent/buildAgent1/resume`); + expect(req.request.method).toBe('PUT'); + req.flush({ message: errorMessage }, { status: 500, statusText: 'Internal Server Error' }); + + try { + await observable; + throw new Error('expected an error, but got a success'); + } catch (error) { + expect(error.message).toContain(errorMessage); + } + }); + afterEach(() => { httpMock.verify(); // Verify that there are no outstanding requests. });