diff --git a/examples/schedule-config.yaml b/examples/schedule-config.yaml new file mode 100644 index 000000000..b9877ff95 --- /dev/null +++ b/examples/schedule-config.yaml @@ -0,0 +1,58 @@ +# Example configuration for time-based cluster activation +# This file shows how to configure cluster schedules for the Trino Gateway + +# Main configuration +server: + applicationConnectors: + - type: http + port: 8080 + adminConnectors: + - type: http + port: 8081 + +# Database configuration +dataStore: + jdbcUrl: jdbc:h2:./gateway-ha/gateway-ha;DB_CLOSE_DELAY=-1;MODE=MYSQL + user: sa + password: "" + driverClass: org.h2.Driver + +# Monitor configuration +monitor: + active: true + connectionTimeout: 10s + refreshInterval: 10s + backends: ["trino-1", "trino-2"] + +# Backend configurations +backends: + - name: trino-1 + proxyTo: http://localhost:8080 + active: true + routingGroup: adhoc + externalUrl: http://localhost:8080 + - name: trino-2 + proxyTo: http://localhost:8081 + active: true + routingGroup: adhoc + externalUrl: http://localhost:8081 + +# Schedule configuration for time-based activation +scheduleConfiguration: + enabled: true + checkInterval: 5m # Check every 5 minutes + schedules: + - clusterName: trino-1 + cronExpression: "0 0 9-17 * * ?" # Active from 9 AM to 5 PM every day + activeDuringCron: true # Active when cron matches + - clusterName: trino-2 + cronExpression: "0 0 17-9 * * ?" # Active from 5 PM to 9 AM every day + activeDuringCron: true # Active when cron matches + +# Authentication configuration (example) +authentication: + type: none + +# Authorization configuration (example) +authorization: + type: none diff --git a/gateway-ha/pom.xml b/gateway-ha/pom.xml index 9fe50911c..617064e3b 100644 --- a/gateway-ha/pom.xml +++ b/gateway-ha/pom.xml @@ -42,6 +42,12 @@ 0.22.1 + + com.cronutils + cron-utils + 9.2.1 + + com.fasterxml.jackson.core jackson-annotations @@ -201,6 +207,11 @@ jakarta.annotation-api + + jakarta.inject + jakarta.inject-api + + jakarta.servlet jakarta.servlet-api diff --git a/gateway-ha/src/main/java/io/trino/gateway/ha/config/ClusterSchedulerConfiguration.java b/gateway-ha/src/main/java/io/trino/gateway/ha/config/ClusterSchedulerConfiguration.java new file mode 100644 index 000000000..dc4db48ec --- /dev/null +++ b/gateway-ha/src/main/java/io/trino/gateway/ha/config/ClusterSchedulerConfiguration.java @@ -0,0 +1,55 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.trino.gateway.ha.config; + +import com.google.inject.Inject; +import io.airlift.log.Logger; +import io.trino.gateway.ha.scheduler.ClusterScheduler; +import jakarta.annotation.Nullable; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; + +public class ClusterSchedulerConfiguration +{ + private static final Logger log = Logger.get(ClusterSchedulerConfiguration.class); + + private final ClusterScheduler scheduler; + + @Inject + public ClusterSchedulerConfiguration(@Nullable ClusterScheduler scheduler) + { + this.scheduler = scheduler; + } + + @PostConstruct + public void start() + { + if (scheduler != null) { + scheduler.start(); + } + } + + @PreDestroy + public void stop() + { + if (scheduler != null) { + try { + scheduler.close(); + } + catch (Exception e) { + log.error(e, "Exception occurred while shutting down ClusterScheduler"); + } + } + } +} diff --git a/gateway-ha/src/main/java/io/trino/gateway/ha/config/HaGatewayConfiguration.java b/gateway-ha/src/main/java/io/trino/gateway/ha/config/HaGatewayConfiguration.java index 61a859ce7..0290b757e 100644 --- a/gateway-ha/src/main/java/io/trino/gateway/ha/config/HaGatewayConfiguration.java +++ b/gateway-ha/src/main/java/io/trino/gateway/ha/config/HaGatewayConfiguration.java @@ -13,6 +13,7 @@ */ package io.trino.gateway.ha.config; +import com.fasterxml.jackson.annotation.JsonProperty; import com.google.common.collect.ImmutableList; import com.google.common.collect.Streams; @@ -40,6 +41,7 @@ public class HaGatewayConfiguration private List extraWhitelistPaths = new ArrayList<>(); private OAuth2GatewayCookieConfiguration oauth2GatewayCookieConfiguration = new OAuth2GatewayCookieConfiguration(); private GatewayCookieConfiguration gatewayCookieConfiguration = new GatewayCookieConfiguration(); + private ScheduleConfiguration scheduleConfiguration = new ScheduleConfiguration(); private List statementPaths = ImmutableList.of(V1_STATEMENT_PATH); private boolean includeClusterHostInResponse; private ProxyResponseConfiguration proxyResponseConfiguration = new ProxyResponseConfiguration(); @@ -191,6 +193,18 @@ public OAuth2GatewayCookieConfiguration getOauth2GatewayCookieConfiguration() return oauth2GatewayCookieConfiguration; } + @JsonProperty + public ScheduleConfiguration getScheduleConfiguration() + { + return scheduleConfiguration; + } + + @JsonProperty + public void setScheduleConfiguration(ScheduleConfiguration scheduleConfiguration) + { + this.scheduleConfiguration = scheduleConfiguration; + } + public void setOauth2GatewayCookieConfiguration(OAuth2GatewayCookieConfiguration oauth2GatewayCookieConfiguration) { this.oauth2GatewayCookieConfiguration = oauth2GatewayCookieConfiguration; diff --git a/gateway-ha/src/main/java/io/trino/gateway/ha/config/ScheduleConfiguration.java b/gateway-ha/src/main/java/io/trino/gateway/ha/config/ScheduleConfiguration.java new file mode 100644 index 000000000..d96e240b6 --- /dev/null +++ b/gateway-ha/src/main/java/io/trino/gateway/ha/config/ScheduleConfiguration.java @@ -0,0 +1,121 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.trino.gateway.ha.config; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.airlift.units.Duration; + +import java.util.ArrayList; +import java.util.List; + +import static java.util.Objects.requireNonNull; + +public class ScheduleConfiguration +{ + private boolean enabled; + private Duration checkInterval = new Duration(5, java.util.concurrent.TimeUnit.MINUTES); + private String timezone = "GMT"; // Default to GMT if not specified + private List schedules = new ArrayList<>(); + + @JsonProperty + public boolean isEnabled() + { + return enabled; + } + + @JsonProperty + public void setEnabled(boolean enabled) + { + this.enabled = enabled; + } + + @JsonProperty + public Duration getCheckInterval() + { + return checkInterval; + } + + @JsonProperty + public void setCheckInterval(Duration checkInterval) + { + this.checkInterval = requireNonNull(checkInterval, "checkInterval is null"); + } + + @JsonProperty + public String getTimezone() + { + return timezone; + } + + @JsonProperty + public void setTimezone(String timezone) + { + this.timezone = requireNonNull(timezone, "timezone is null"); + } + + @JsonProperty + public List getSchedules() + { + return schedules; + } + + @JsonProperty + public void setSchedules(List schedules) + { + this.schedules = schedules; + } + + public static class ClusterSchedule + { + private String clusterName; + private String cronExpression; + private boolean activeDuringCron; + + @JsonProperty + public String getClusterName() + { + return clusterName; + } + + @JsonProperty + public void setClusterName(String clusterName) + { + this.clusterName = requireNonNull(clusterName, "clusterName is null"); + } + + @JsonProperty + public String getCronExpression() + { + return cronExpression; + } + + @JsonProperty + public void setCronExpression(String cronExpression) + { + this.cronExpression = requireNonNull(cronExpression, "cronExpression is null"); + } + + @JsonProperty + public boolean isActiveDuringCron() + { + return activeDuringCron; + } + + @JsonProperty + public void setActiveDuringCron(boolean activeDuringCron) + { + this.activeDuringCron = activeDuringCron; + } + } +} diff --git a/gateway-ha/src/main/java/io/trino/gateway/ha/module/ClusterSchedulerModule.java b/gateway-ha/src/main/java/io/trino/gateway/ha/module/ClusterSchedulerModule.java new file mode 100644 index 000000000..85642842a --- /dev/null +++ b/gateway-ha/src/main/java/io/trino/gateway/ha/module/ClusterSchedulerModule.java @@ -0,0 +1,71 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.trino.gateway.ha.module; + +import com.google.inject.AbstractModule; +import com.google.inject.Provides; +import io.airlift.log.Logger; +import io.trino.gateway.ha.config.HaGatewayConfiguration; +import io.trino.gateway.ha.config.ScheduleConfiguration; +import io.trino.gateway.ha.router.GatewayBackendManager; +import io.trino.gateway.ha.scheduler.ClusterScheduler; +import jakarta.inject.Singleton; + +import static java.util.Objects.requireNonNull; + +public class ClusterSchedulerModule + extends AbstractModule +{ + private static final Logger log = Logger.get(ClusterSchedulerModule.class); + private final HaGatewayConfiguration configuration; + + // We require all modules to take HaGatewayConfiguration as the only parameter + public ClusterSchedulerModule(HaGatewayConfiguration configuration) + { + this.configuration = requireNonNull(configuration, "configuration is null"); + } + + @Override + public void configure() + { + // Configuration-based binding is handled in the provider methods + if (configuration.getScheduleConfiguration() != null + && configuration.getScheduleConfiguration().isEnabled()) { + log.info("ClusterScheduler configuration is enabled"); + } + else { + log.info("ClusterScheduler is disabled or not configured"); + } + } + + @Provides + @Singleton + public ScheduleConfiguration provideScheduleConfiguration() + { + return configuration.getScheduleConfiguration(); + } + + @Provides + @Singleton + public ClusterScheduler provideClusterScheduler( + GatewayBackendManager backendManager, + ScheduleConfiguration scheduleConfiguration) + { + if (scheduleConfiguration == null || !scheduleConfiguration.isEnabled()) { + return null; + } + log.info("Creating ClusterScheduler instance"); + return new ClusterScheduler(backendManager, scheduleConfiguration); + } +} diff --git a/gateway-ha/src/main/java/io/trino/gateway/ha/scheduler/ClusterScheduler.java b/gateway-ha/src/main/java/io/trino/gateway/ha/scheduler/ClusterScheduler.java new file mode 100644 index 000000000..8decfee3d --- /dev/null +++ b/gateway-ha/src/main/java/io/trino/gateway/ha/scheduler/ClusterScheduler.java @@ -0,0 +1,202 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.trino.gateway.ha.scheduler; + +import com.cronutils.model.Cron; +import com.cronutils.model.CronType; +import com.cronutils.model.definition.CronDefinition; +import com.cronutils.model.definition.CronDefinitionBuilder; +import com.cronutils.model.time.ExecutionTime; +import com.cronutils.parser.CronParser; +import io.airlift.log.Logger; +import io.trino.gateway.ha.config.ProxyBackendConfiguration; +import io.trino.gateway.ha.config.ScheduleConfiguration; +import io.trino.gateway.ha.router.GatewayBackendManager; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import jakarta.inject.Inject; + +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import static java.util.Objects.requireNonNull; + +public class ClusterScheduler + implements AutoCloseable +{ + private static final Logger log = Logger.get(ClusterScheduler.class); + private ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); + private final GatewayBackendManager backendManager; + private final ScheduleConfiguration config; + private final Map executionTimes = new ConcurrentHashMap<>(); + private final CronParser cronParser; + private final ZoneId timezone; + + @Inject + public ClusterScheduler(GatewayBackendManager backendManager, ScheduleConfiguration config) + { + this.backendManager = requireNonNull(backendManager, "backendManager is null"); + this.config = requireNonNull(config, "config is null"); + CronDefinition cronDefinition = CronDefinitionBuilder.instanceDefinitionFor(CronType.UNIX); + this.cronParser = new CronParser(cronDefinition); + + // Initialize timezone from config, default to GMT if not specified or invalid + ZoneId configuredTimezone; + try { + String timezoneStr = config.getTimezone(); + if (timezoneStr == null || timezoneStr.trim().isEmpty()) { + configuredTimezone = ZoneId.of("GMT"); + log.info("No timezone specified in configuration, using default: GMT"); + } + else { + configuredTimezone = ZoneId.of(timezoneStr); + log.info("Using configured timezone: %s", timezoneStr); + } + } + catch (Exception e) { + configuredTimezone = ZoneId.of("GMT"); + log.warn(e, "Invalid timezone '%s' in configuration, falling back to GMT", config.getTimezone()); + } + this.timezone = configuredTimezone; + } + + @PostConstruct + public void start() + { + if (!config.isEnabled()) { + log.info("Cluster scheduling is disabled"); + return; + } + + // Initialize execution times + for (ScheduleConfiguration.ClusterSchedule schedule : config.getSchedules()) { + try { + Cron cron = cronParser.parse(schedule.getCronExpression()); + executionTimes.put(schedule.getClusterName(), ExecutionTime.forCron(cron)); + log.info("Scheduled cluster {} with cron expression: {}, activeDuringCron: {}", + schedule.getClusterName(), + schedule.getCronExpression(), + schedule.isActiveDuringCron()); + } + catch (Exception e) { + log.error("Skipping cluster {} due to invalid cron expression '{}': {}", + schedule.getClusterName(), + schedule.getCronExpression(), + e.getMessage()); + } + } + + // Schedule the task + scheduler.scheduleAtFixedRate( + () -> checkAndUpdateClusterStatus(ZonedDateTime.now(timezone)), + 0, + config.getCheckInterval().toMillis(), + TimeUnit.MILLISECONDS); + log.info("Started cluster scheduler with check interval: %s (using %s timezone)", + config.getCheckInterval(), + timezone); + } + + public void checkAndUpdateClusterStatus(ZonedDateTime currentTime) + { + try { + log.debug("Checking cluster status at: %s (%s)", currentTime, timezone); + + for (Map.Entry entry : executionTimes.entrySet()) { + String clusterName = entry.getKey(); + ExecutionTime executionTime = entry.getValue(); + + // Find the schedule for this cluster + Optional scheduleOpt = config.getSchedules().stream() + .filter(s -> s.getClusterName().equals(clusterName)) + .findFirst(); + + if (scheduleOpt.isEmpty()) { + log.warn("No schedule configuration found for cluster: %s", clusterName); + continue; + } + + ScheduleConfiguration.ClusterSchedule schedule = scheduleOpt.get(); + boolean cronMatches = executionTime.isMatch(currentTime); + boolean shouldBeActive = cronMatches == schedule.isActiveDuringCron(); + + log.info("Cluster: %s, cronMatches: %s, activeDuringCron: %s, shouldBeActive: %s", + clusterName, + cronMatches, + schedule.isActiveDuringCron(), + shouldBeActive); + + // Update cluster status if needed + Optional clusterOpt = backendManager.getBackendByName(clusterName); + if (clusterOpt.isPresent()) { + ProxyBackendConfiguration cluster = clusterOpt.get(); + boolean currentlyActive = cluster.isActive(); + + log.debug("Cluster: %s, currentlyActive: %s, shouldBeActive: %s", + clusterName, + currentlyActive, + shouldBeActive); + + if (currentlyActive != shouldBeActive) { + if (shouldBeActive) { + backendManager.activateBackend(clusterName); + log.info("Activated cluster %s based on schedule (cron match: %s, activeDuringCron: %s)", + clusterName, + cronMatches, + schedule.isActiveDuringCron()); + } + else { + backendManager.deactivateBackend(clusterName); + log.info("Deactivated cluster %s based on schedule (cron match: %s, activeDuringCron: %s)", + clusterName, + cronMatches, + schedule.isActiveDuringCron()); + } + } + else { + log.debug("Cluster %s status unchanged: active=%s", clusterName, currentlyActive); + } + } + else { + log.warn("Cluster %s not found in backend manager", clusterName); + } + } + } + catch (Exception e) { + log.error(e, "Error in cluster scheduler task"); + } + } + + @PreDestroy + @Override + public void close() + { + scheduler.shutdownNow(); + try { + if (!scheduler.awaitTermination(10, TimeUnit.SECONDS)) { + log.warn("Cluster scheduler did not terminate in time"); + } + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn(e, "Interrupted while waiting for scheduler to terminate"); + } + } +} diff --git a/gateway-ha/src/test/java/io/trino/gateway/ha/scheduler/TestClusterScheduler.java b/gateway-ha/src/test/java/io/trino/gateway/ha/scheduler/TestClusterScheduler.java new file mode 100644 index 000000000..1e34ede14 --- /dev/null +++ b/gateway-ha/src/test/java/io/trino/gateway/ha/scheduler/TestClusterScheduler.java @@ -0,0 +1,387 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.trino.gateway.ha.scheduler; + +import io.airlift.units.Duration; +import io.trino.gateway.ha.config.ProxyBackendConfiguration; +import io.trino.gateway.ha.config.ScheduleConfiguration; +import io.trino.gateway.ha.router.GatewayBackendManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TestClusterScheduler +{ + private static final String CLUSTER_NAME = "test-cluster"; + // Match every minute from 9 AM to 5 PM to ensure the test time is always matched + // Unix cron format: minute hour day month day-of-week (5 parts) + private static final String CRON_EXPRESSION = "* 9-17 * * *"; // Every minute from 9 AM to 5 PM + private static final ZoneId TEST_TIMEZONE = ZoneId.of("America/Los_Angeles"); + + @Mock + private GatewayBackendManager backendManager; + + @Mock + private ScheduleConfiguration scheduleConfig; + + @Mock + private ScheduleConfiguration.ClusterSchedule clusterSchedule; + + @Mock + private ProxyBackendConfiguration backendConfig; + + private ClusterScheduler scheduler; + + @BeforeEach + void setUp() + { + // Reset all mocks before each test to ensure clean state + reset(backendManager, scheduleConfig, clusterSchedule, backendConfig); + } + + private void setupTestCluster(boolean activeDuringCron) + { + // Setup for a test cluster - call this in tests that need it + when(scheduleConfig.isEnabled()).thenReturn(true); + when(scheduleConfig.getCheckInterval()).thenReturn(new Duration(1, TimeUnit.MINUTES)); + when(scheduleConfig.getTimezone()).thenReturn(TEST_TIMEZONE.toString()); + when(scheduleConfig.getSchedules()).thenReturn(java.util.List.of(clusterSchedule)); + when(clusterSchedule.getClusterName()).thenReturn(CLUSTER_NAME); + when(clusterSchedule.getCronExpression()).thenReturn(CRON_EXPRESSION); + when(clusterSchedule.isActiveDuringCron()).thenReturn(activeDuringCron); + when(backendManager.getBackendByName(CLUSTER_NAME)).thenReturn(java.util.Optional.of(backendConfig)); + + // Initialize the scheduler with the test cluster + scheduler = new ClusterScheduler(backendManager, scheduleConfig); + // Note: Don't start the scheduler here to avoid background executions during tests + } + + @Test + void testSchedulerInitialization() + { + // Only mock what's actually needed in the constructor + when(scheduleConfig.getTimezone()).thenReturn(TEST_TIMEZONE.toString()); + + // Initialize the scheduler + scheduler = new ClusterScheduler(backendManager, scheduleConfig); + + assertThat(scheduler).isNotNull(); + verify(scheduleConfig).getTimezone(); + } + + @Test + void testClusterActivationWhenCronMatches() + { + // Setup test cluster with activeDuringCron = true + setupTestCluster(true); + + // Initialize the scheduler + scheduler.start(); + + // Time within the cron schedule (9 AM - 5 PM) + ZonedDateTime activeTime = ZonedDateTime.of(2025, 9, 29, 10, 0, 0, 0, TEST_TIMEZONE); + when(backendConfig.isActive()).thenReturn(false); + + // Execute + scheduler.checkAndUpdateClusterStatus(activeTime); + + // Verify + // We expect at least one call to activateBackend + verify(backendManager, atLeastOnce()).activateBackend(CLUSTER_NAME); + // We expect exactly one call to getBackendByName + verify(backendManager, atLeastOnce()).getBackendByName(CLUSTER_NAME); + // We expect exactly one call to isActive + verify(backendConfig, atLeastOnce()).isActive(); + } + + @Test + void testClusterDeactivationWhenCronDoesNotMatch() + { + // Setup test cluster with activeDuringCron = true + setupTestCluster(true); + + // Initialize the scheduler + scheduler.start(); + + // Time outside the cron schedule (before 9 AM) + ZonedDateTime inactiveTime = ZonedDateTime.of(2025, 9, 29, 8, 0, 0, 0, TEST_TIMEZONE); + when(backendConfig.isActive()).thenReturn(true); + + // Execute + scheduler.checkAndUpdateClusterStatus(inactiveTime); + + // Verify + verify(backendManager, atLeastOnce()).deactivateBackend(CLUSTER_NAME); + // Verify getBackendByName is called at least once (actual implementation may call it multiple times) + verify(backendManager, atLeastOnce()).getBackendByName(CLUSTER_NAME); + verify(backendConfig, atLeastOnce()).isActive(); + } + + @Test + void testNoActionWhenClusterStatusMatchesSchedule() + { + // Setup test cluster with activeDuringCron = true + setupTestCluster(true); + + // Initialize the scheduler (needed to parse cron expressions) + scheduler.start(); + + // Time within the cron schedule (9 AM - 5 PM) + ZonedDateTime activeTime = ZonedDateTime.of(2025, 9, 29, 10, 0, 0, 0, TEST_TIMEZONE); + when(backendConfig.isActive()).thenReturn(true); + + // Execute + scheduler.checkAndUpdateClusterStatus(activeTime); + + // Verify no action taken when status already matches + verify(backendManager, never()).activateBackend(anyString()); + verify(backendManager, atLeastOnce()).getBackendByName(CLUSTER_NAME); + verify(backendConfig, atLeastOnce()).isActive(); + } + + @Test + void testClusterNotFoundInBackendManager() + { + // Setup test cluster with empty backend + when(scheduleConfig.isEnabled()).thenReturn(true); + when(scheduleConfig.getCheckInterval()).thenReturn(new Duration(1, TimeUnit.MINUTES)); + when(scheduleConfig.getTimezone()).thenReturn(TEST_TIMEZONE.toString()); + when(scheduleConfig.getSchedules()).thenReturn(java.util.List.of(clusterSchedule)); + when(clusterSchedule.getClusterName()).thenReturn(CLUSTER_NAME); + when(clusterSchedule.getCronExpression()).thenReturn(CRON_EXPRESSION); + when(clusterSchedule.isActiveDuringCron()).thenReturn(true); + when(backendManager.getBackendByName(CLUSTER_NAME)).thenReturn(java.util.Optional.empty()); + + // Initialize scheduler with the test configuration + scheduler = new ClusterScheduler(backendManager, scheduleConfig); + + // Initialize the scheduler + scheduler.start(); + + // Time within the cron schedule (9 AM - 5 PM) + ZonedDateTime testTime = ZonedDateTime.of(2025, 9, 29, 10, 0, 0, 0, TEST_TIMEZONE); + + // Execute + scheduler.checkAndUpdateClusterStatus(testTime); + + // Verify no action taken when cluster not found + verify(backendManager, never()).activateBackend(anyString()); + verify(backendManager, never()).deactivateBackend(anyString()); + verify(backendManager, atLeastOnce()).getBackendByName(CLUSTER_NAME); + } + + @Test + void testSchedulerDoesNotTriggerWhenDisabled() + { + // Setup test cluster with scheduling disabled + when(scheduleConfig.isEnabled()).thenReturn(false); + when(scheduleConfig.getTimezone()).thenReturn(TEST_TIMEZONE.toString()); + + // Initialize the scheduler with disabled configuration + scheduler = new ClusterScheduler(backendManager, scheduleConfig); + scheduler.start(); + + // Time within the cron schedule (9 AM - 5 PM) + ZonedDateTime testTime = ZonedDateTime.of(2025, 9, 29, 10, 0, 0, 0, TEST_TIMEZONE); + + // Execute + scheduler.checkAndUpdateClusterStatus(testTime); + + // Verify no actions are taken when scheduler is disabled + verify(backendManager, never()).activateBackend(anyString()); + verify(backendManager, never()).deactivateBackend(anyString()); + verify(backendManager, never()).getBackendByName(anyString()); + } + + @Test + void testSchedulerWithDifferentTimezones() + { + // Set up timezone to New York + ZoneId newYorkTime = ZoneId.of("America/New_York"); + // Setup test cluster with activeDuringCron = true + when(scheduleConfig.isEnabled()).thenReturn(true); + when(scheduleConfig.getCheckInterval()).thenReturn(new Duration(1, TimeUnit.MINUTES)); + when(scheduleConfig.getTimezone()).thenReturn(newYorkTime.toString()); + when(scheduleConfig.getSchedules()).thenReturn(java.util.List.of(clusterSchedule)); + when(clusterSchedule.getClusterName()).thenReturn(CLUSTER_NAME); + when(clusterSchedule.getCronExpression()).thenReturn(CRON_EXPRESSION); + when(clusterSchedule.isActiveDuringCron()).thenReturn(true); + when(backendManager.getBackendByName(CLUSTER_NAME)).thenReturn(java.util.Optional.of(backendConfig)); + + // Initialize scheduler with the new timezone + scheduler = new ClusterScheduler(backendManager, scheduleConfig); + + // Initialize the scheduler + scheduler.start(); + + // Time within the cron schedule (9 AM - 5 PM) in New York time + ZonedDateTime testTime = ZonedDateTime.of(2025, 9, 29, 10, 0, 0, 0, newYorkTime); + when(backendConfig.isActive()).thenReturn(false); + + // Execute + scheduler.checkAndUpdateClusterStatus(testTime); + + // Verify + verify(backendManager, atLeastOnce()).activateBackend(CLUSTER_NAME); + verify(backendManager, atLeastOnce()).getBackendByName(CLUSTER_NAME); + verify(backendConfig, atLeastOnce()).isActive(); + verify(scheduleConfig, atLeastOnce()).getTimezone(); + } + + @Test + void testInvalidCronExpression() + { + // Setup test cluster with an invalid cron expression (valid format but invalid values) + when(scheduleConfig.isEnabled()).thenReturn(true); + when(scheduleConfig.getCheckInterval()).thenReturn(new Duration(1, TimeUnit.MINUTES)); + when(scheduleConfig.getTimezone()).thenReturn(TEST_TIMEZONE.toString()); + when(scheduleConfig.getSchedules()).thenReturn(java.util.List.of(clusterSchedule)); + when(clusterSchedule.getClusterName()).thenReturn(CLUSTER_NAME); + // Using a cron expression with invalid values that will fail validation + when(clusterSchedule.getCronExpression()).thenReturn("99 25 32 13 8"); // Invalid: minute=99, hour=25, day=32, month=13, day-of-week=8 + + // Initialize scheduler with the test configuration + scheduler = new ClusterScheduler(backendManager, scheduleConfig); + + // Initialize the scheduler - this should log an error but not throw + assertThatNoException().isThrownBy(() -> scheduler.start()); + + // Verify the error was logged (you might want to add a test logger to verify this) + // For now, we'll just verify the mocks were called as expected + verify(scheduleConfig).getSchedules(); + verify(clusterSchedule).getClusterName(); + verify(clusterSchedule, atLeastOnce()).getCronExpression(); + + // Verify no action taken due to invalid cron expression + verify(backendManager, never()).activateBackend(anyString()); + verify(backendManager, never()).deactivateBackend(anyString()); + } + + @Test + void testSchedulerWithMultipleClusters() + { + // Setup first cluster with activeDuringCron = true + when(clusterSchedule.getClusterName()).thenReturn(CLUSTER_NAME); + when(clusterSchedule.getCronExpression()).thenReturn(CRON_EXPRESSION); + when(clusterSchedule.isActiveDuringCron()).thenReturn(true); + + // Setup second cluster with activeDuringCron = false + ScheduleConfiguration.ClusterSchedule secondCluster = mock(ScheduleConfiguration.ClusterSchedule.class); + String secondClusterName = "another-cluster"; + when(secondCluster.getClusterName()).thenReturn(secondClusterName); + when(secondCluster.getCronExpression()).thenReturn(CRON_EXPRESSION); + when(secondCluster.isActiveDuringCron()).thenReturn(false); + + // Setup backend configs + ProxyBackendConfiguration secondBackendConfig = mock(ProxyBackendConfiguration.class); + when(backendManager.getBackendByName(CLUSTER_NAME)).thenReturn(java.util.Optional.of(backendConfig)); + when(backendManager.getBackendByName(secondClusterName)).thenReturn(java.util.Optional.of(secondBackendConfig)); + + // Setup schedules and config + when(scheduleConfig.isEnabled()).thenReturn(true); + when(scheduleConfig.getCheckInterval()).thenReturn(new Duration(1, TimeUnit.MINUTES)); + when(scheduleConfig.getTimezone()).thenReturn(TEST_TIMEZONE.toString()); + when(scheduleConfig.getSchedules()).thenReturn(java.util.List.of(clusterSchedule, secondCluster)); + + // Initialize scheduler with the test configuration + scheduler = new ClusterScheduler(backendManager, scheduleConfig); + + // Initialize the scheduler + scheduler.start(); + + // Time within the cron schedule (9 AM - 5 PM) + ZonedDateTime testTime = ZonedDateTime.of(2025, 9, 29, 10, 0, 0, 0, TEST_TIMEZONE); + when(backendConfig.isActive()).thenReturn(false); + when(secondBackendConfig.isActive()).thenReturn(true); + + // Execute + scheduler.checkAndUpdateClusterStatus(testTime); + + // Verify first cluster is activated + verify(backendManager, atLeastOnce()).activateBackend(CLUSTER_NAME); + verify(backendManager, atLeastOnce()).getBackendByName(CLUSTER_NAME); + verify(backendConfig, atLeastOnce()).isActive(); + + // Verify second cluster is deactivated + verify(backendManager, atLeastOnce()).deactivateBackend(secondClusterName); + verify(backendManager, atLeastOnce()).getBackendByName(secondClusterName); + verify(secondBackendConfig, atLeastOnce()).isActive(); + } + + @Test + void testSchedulerWithInvertedActiveLogic() + { + // Setup test with activeDuringCron = false (inverted logic) + when(scheduleConfig.isEnabled()).thenReturn(true); + when(scheduleConfig.getCheckInterval()).thenReturn(new Duration(1, TimeUnit.MINUTES)); + when(scheduleConfig.getTimezone()).thenReturn(TEST_TIMEZONE.toString()); + when(scheduleConfig.getSchedules()).thenReturn(java.util.List.of(clusterSchedule)); + when(clusterSchedule.getClusterName()).thenReturn(CLUSTER_NAME); + when(clusterSchedule.getCronExpression()).thenReturn(CRON_EXPRESSION); + when(clusterSchedule.isActiveDuringCron()).thenReturn(false); + when(backendManager.getBackendByName(CLUSTER_NAME)).thenReturn(Optional.of(backendConfig)); + + // Initialize the scheduler + scheduler = new ClusterScheduler(backendManager, scheduleConfig); + scheduler.start(); + + // Test 1: During cron time (10 AM) - should be INACTIVE due to inverted logic + ZonedDateTime activeTime = ZonedDateTime.of(2025, 9, 29, 10, 0, 0, 0, TEST_TIMEZONE); + when(backendConfig.isActive()).thenReturn(true); + + // Execute + scheduler.checkAndUpdateClusterStatus(activeTime); + + // Verify cluster is deactivated when cron matches (inverted logic) + verify(backendManager, atLeastOnce()).deactivateBackend(CLUSTER_NAME); + verify(backendManager, atLeastOnce()).getBackendByName(CLUSTER_NAME); + verify(backendConfig, atLeastOnce()).isActive(); + + // Reset mocks for second test + reset(backendManager, backendConfig); + + // Re-setup mocks for second test + when(backendManager.getBackendByName(CLUSTER_NAME)).thenReturn(Optional.of(backendConfig)); + when(backendConfig.isActive()).thenReturn(false); + + // Test 2: Outside cron time (after 5 PM) - should be ACTIVE due to inverted logic + ZonedDateTime inactiveTime = ZonedDateTime.of(2025, 9, 29, 18, 0, 0, 0, TEST_TIMEZONE); + + // Execute + scheduler.checkAndUpdateClusterStatus(inactiveTime); + + // Verify cluster is activated (inverted logic: active when cron doesn't match) + verify(backendManager).activateBackend(CLUSTER_NAME); + verify(backendManager, atLeastOnce()).getBackendByName(CLUSTER_NAME); + verify(backendConfig).isActive(); + } +}