From f8938b6552555120acf88060632c76c95503e85c Mon Sep 17 00:00:00 2001 From: tjnaik Date: Thu, 8 Jan 2026 10:48:11 -0800 Subject: [PATCH 1/3] Add runtime_env config for multi-cluster path isolation --- docker/application.yml | 5 + .../ClusterControllerApplication.java | 5 + .../config/ClusterControllerConfig.java | 18 +- .../clustercontroller/config/Constants.java | 1 + .../store/EtcdPathResolver.java | 73 ++++++--- src/main/resources/application.yml | 5 + .../store/EtcdPathResolverTest.java | 154 ++++++++++++++++++ 7 files changed, 237 insertions(+), 24 deletions(-) diff --git a/docker/application.yml b/docker/application.yml index 6ae94271..6c5edc19 100644 --- a/docker/application.yml +++ b/docker/application.yml @@ -1,4 +1,9 @@ # Application Configuration + +# Runtime environment for multi-cluster path isolation (staging, production, etc.) +# This is patched at build time for different environments +runtime_env: staging + cluster: name: default-cluster diff --git a/src/main/java/io/clustercontroller/ClusterControllerApplication.java b/src/main/java/io/clustercontroller/ClusterControllerApplication.java index 89423d43..04b05232 100644 --- a/src/main/java/io/clustercontroller/ClusterControllerApplication.java +++ b/src/main/java/io/clustercontroller/ClusterControllerApplication.java @@ -15,6 +15,7 @@ import io.clustercontroller.templates.TemplateManager; import io.clustercontroller.store.MetadataStore; import io.clustercontroller.store.EtcdMetadataStore; +import io.clustercontroller.store.EtcdPathResolver; import io.etcd.jetcd.Client; import lombok.extern.slf4j.Slf4j; @@ -64,6 +65,10 @@ public ClusterControllerConfig config() { public MetadataStore metadataStore(ClusterControllerConfig config) { log.info("Initializing cluster-agnostic MetadataStore connection to etcd"); try { + // Configure EtcdPathResolver with runtime environment for path isolation + EtcdPathResolver.getInstance().setRuntimeEnv(config.getRuntimeEnv()); + log.info("EtcdPathResolver configured with runtime_env: {}", config.getRuntimeEnv()); + EtcdMetadataStore store = EtcdMetadataStore.getInstance(config.getEtcdEndpoints()); // Configure coordinator goal state path from YAML config store.setCoordinatorGoalStateLocation( diff --git a/src/main/java/io/clustercontroller/config/ClusterControllerConfig.java b/src/main/java/io/clustercontroller/config/ClusterControllerConfig.java index 27a2edc4..c51b29e8 100644 --- a/src/main/java/io/clustercontroller/config/ClusterControllerConfig.java +++ b/src/main/java/io/clustercontroller/config/ClusterControllerConfig.java @@ -30,6 +30,7 @@ public class ClusterControllerConfig { private final long taskIntervalSeconds; private final String coordinatorGoalStateGroup; private final String coordinatorGoalStateUnit; + private final String runtimeEnv; // Default classpath location private static final String DEFAULT_CONFIG_FILE_CLASSPATH = "application.yml"; @@ -44,9 +45,10 @@ public ClusterControllerConfig() { this.taskIntervalSeconds = parseTaskIntervalSeconds(config); this.coordinatorGoalStateGroup = parseCoordinatorGoalStateGroup(config); this.coordinatorGoalStateUnit = parseCoordinatorGoalStateUnit(config); + this.runtimeEnv = parseRuntimeEnv(config); - log.info("Loaded cluster controller config - etcd endpoints: {}, task interval: {}s", - String.join(", ", etcdEndpoints), taskIntervalSeconds); + log.info("Loaded cluster controller config - etcd endpoints: {}, task interval: {}s, runtime_env: {}", + String.join(", ", etcdEndpoints), taskIntervalSeconds, runtimeEnv); } @@ -156,6 +158,17 @@ private String parseCoordinatorGoalStateUnit(ConfigModel config) { return COORDINATOR_DEFAULT_UNIT; } + private String parseRuntimeEnv(ConfigModel config) { + try { + if (config.getRuntime_env() != null && !config.getRuntime_env().isBlank()) { + return config.getRuntime_env(); + } + } catch (Exception e) { + log.warn("Failed to parse runtime_env, using default 'staging': {}", e.getMessage()); + } + return DEFAULT_RUNTIME_ENV; + } + /** * Configuration model for the application.yml file. */ @@ -165,6 +178,7 @@ public static class ConfigModel { private Task task; private Controller controller; // Multi-cluster controller config (used by Spring @Value) private CoordinatorGoalState coordinator_goal_state; + private String runtime_env; // Environment isolation for multi-cluster paths (staging, production, etc.) } @Data diff --git a/src/main/java/io/clustercontroller/config/Constants.java b/src/main/java/io/clustercontroller/config/Constants.java index f8c71f20..b6323ae8 100644 --- a/src/main/java/io/clustercontroller/config/Constants.java +++ b/src/main/java/io/clustercontroller/config/Constants.java @@ -12,6 +12,7 @@ private Constants() { // Default configuration values public static final String DEFAULT_ETCD_ENDPOINT = "http://localhost:2379"; public static final long DEFAULT_TASK_INTERVAL_SECONDS = 30L; + public static final String DEFAULT_RUNTIME_ENV = "staging"; // Task statuses public static final String TASK_STATUS_PENDING = "PENDING"; diff --git a/src/main/java/io/clustercontroller/store/EtcdPathResolver.java b/src/main/java/io/clustercontroller/store/EtcdPathResolver.java index e777d428..96a021b2 100644 --- a/src/main/java/io/clustercontroller/store/EtcdPathResolver.java +++ b/src/main/java/io/clustercontroller/store/EtcdPathResolver.java @@ -9,24 +9,52 @@ * Centralized etcd path resolver for all metadata keys with multi-cluster support. * Provides consistent path structure for tasks, search units, indices, and other cluster metadata. * All methods accept dynamic cluster names to support multi-cluster operations. - * Stateless singleton - no cluster-specific state stored. + * + * Multi-cluster coordination paths are isolated by runtime environment (staging, production, etc.) + * to prevent controllers from different environments from interfering with each other. */ @Component public class EtcdPathResolver { private static final String PATH_DELIMITER = "/"; - // Singleton instance - stateless - private static final EtcdPathResolver INSTANCE = new EtcdPathResolver(); + // Singleton instance + private static EtcdPathResolver INSTANCE; - private EtcdPathResolver() { - // Private constructor for singleton + // Runtime environment for multi-cluster path isolation (staging, production, etc.) + private String runtimeEnv = "staging"; + + public EtcdPathResolver() { + // Public constructor for Spring + INSTANCE = this; } public static EtcdPathResolver getInstance() { + if (INSTANCE == null) { + INSTANCE = new EtcdPathResolver(); + } return INSTANCE; } + /** + * Set the runtime environment for multi-cluster path isolation. + * This is called during application startup from the config. + * + * @param runtimeEnv the environment name (e.g., "staging", "production") + */ + public void setRuntimeEnv(String runtimeEnv) { + if (runtimeEnv != null && !runtimeEnv.isBlank()) { + this.runtimeEnv = runtimeEnv; + } + } + + /** + * Get the current runtime environment. + */ + public String getRuntimeEnv() { + return runtimeEnv; + } + // ================================================================= // CONTROLLER TASKS PATHS // ================================================================= @@ -243,69 +271,70 @@ public String getClusterRoot(String clusterName) { // ================================================================= // MULTI-CLUSTER COORDINATION PATHS + // All paths include runtime_env to isolate different environments // ================================================================= /** - * Get multi-cluster root path - * Pattern: /multi-cluster + * Get multi-cluster root path with environment isolation + * Pattern: /multi-cluster/ */ public String getMultiClusterRoot() { - return Paths.get(PATH_DELIMITER, PATH_MULTI_CLUSTER).toString(); + return Paths.get(PATH_DELIMITER, PATH_MULTI_CLUSTER, runtimeEnv).toString(); } /** * Get controller heartbeat path - * Pattern: /multi-cluster/controllers//heartbeat + * Pattern: /multi-cluster//controllers//heartbeat */ public String getControllerHeartbeatPath(String controllerId) { - return Paths.get(PATH_DELIMITER, PATH_MULTI_CLUSTER, PATH_CONTROLLERS, controllerId, PATH_HEARTBEAT).toString(); + return Paths.get(PATH_DELIMITER, PATH_MULTI_CLUSTER, runtimeEnv, PATH_CONTROLLERS, controllerId, PATH_HEARTBEAT).toString(); } /** * Get controller assignment path - * Pattern: /multi-cluster/controllers//assigned/ + * Pattern: /multi-cluster//controllers//assigned/ */ public String getControllerAssignmentPath(String controllerId, String clusterId) { - return Paths.get(PATH_DELIMITER, PATH_MULTI_CLUSTER, PATH_CONTROLLERS, controllerId, PATH_ASSIGNED, clusterId).toString(); + return Paths.get(PATH_DELIMITER, PATH_MULTI_CLUSTER, runtimeEnv, PATH_CONTROLLERS, controllerId, PATH_ASSIGNED, clusterId).toString(); } /** * Get cluster lock path - * Pattern: /multi-cluster/locks/clusters/ + * Pattern: /multi-cluster//locks/clusters/ */ public String getClusterLockPath(String clusterId) { - return Paths.get(PATH_DELIMITER, PATH_MULTI_CLUSTER, PATH_LOCKS, PATH_CLUSTERS, clusterId).toString(); + return Paths.get(PATH_DELIMITER, PATH_MULTI_CLUSTER, runtimeEnv, PATH_LOCKS, PATH_CLUSTERS, clusterId).toString(); } /** * Get cluster registry path - * Pattern: /multi-cluster/clusters//metadata + * Pattern: /multi-cluster//clusters//metadata */ public String getClusterRegistryPath(String clusterId) { - return Paths.get(PATH_DELIMITER, PATH_MULTI_CLUSTER, PATH_CLUSTERS, clusterId, PATH_METADATA).toString(); + return Paths.get(PATH_DELIMITER, PATH_MULTI_CLUSTER, runtimeEnv, PATH_CLUSTERS, clusterId, PATH_METADATA).toString(); } /** * Get cluster's assigned controller path (for observability at cluster level) - * Pattern: /multi-cluster/clusters//assigned-to + * Pattern: /multi-cluster//clusters//assigned-to */ public String getClusterAssignedControllerPath(String clusterId) { - return Paths.get(PATH_DELIMITER, PATH_MULTI_CLUSTER, PATH_CLUSTERS, clusterId, "assigned-to").toString(); + return Paths.get(PATH_DELIMITER, PATH_MULTI_CLUSTER, runtimeEnv, PATH_CLUSTERS, clusterId, "assigned-to").toString(); } /** * Get controllers prefix for listing - * Pattern: /multi-cluster/controllers/ + * Pattern: /multi-cluster//controllers/ */ public String getControllersPrefix() { - return Paths.get(PATH_DELIMITER, PATH_MULTI_CLUSTER, PATH_CONTROLLERS, "").toString(); + return Paths.get(PATH_DELIMITER, PATH_MULTI_CLUSTER, runtimeEnv, PATH_CONTROLLERS, "").toString(); } /** * Get clusters prefix for listing - * Pattern: /multi-cluster/clusters/ + * Pattern: /multi-cluster//clusters/ */ public String getClustersPrefix() { - return Paths.get(PATH_DELIMITER, PATH_MULTI_CLUSTER, PATH_CLUSTERS, "").toString(); + return Paths.get(PATH_DELIMITER, PATH_MULTI_CLUSTER, runtimeEnv, PATH_CLUSTERS, "").toString(); } } \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index be20d365..23d9a101 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,4 +1,9 @@ # Application Configuration + +# Runtime environment for multi-cluster path isolation (staging, production, etc.) +# This is patched at build time for different environments +runtime_env: staging + etcd: endpoints: - http://localhost:2379 diff --git a/src/test/java/io/clustercontroller/store/EtcdPathResolverTest.java b/src/test/java/io/clustercontroller/store/EtcdPathResolverTest.java index c7c08443..c8e90b9b 100644 --- a/src/test/java/io/clustercontroller/store/EtcdPathResolverTest.java +++ b/src/test/java/io/clustercontroller/store/EtcdPathResolverTest.java @@ -133,4 +133,158 @@ void testMultipleClusterNames() { assertThat(cluster2Path).isEqualTo("/cluster2/indices/index1/conf"); assertThat(cluster1Path).isNotEqualTo(cluster2Path); } + + // ================================================================= + // RUNTIME ENVIRONMENT TESTS + // ================================================================= + + @Test + void testDefaultRuntimeEnv() { + // Default should be "staging" + assertThat(pathResolver.getRuntimeEnv()).isNotNull(); + } + + @Test + void testSetRuntimeEnv() { + String originalEnv = pathResolver.getRuntimeEnv(); + try { + pathResolver.setRuntimeEnv("production"); + assertThat(pathResolver.getRuntimeEnv()).isEqualTo("production"); + } finally { + // Restore original to not affect other tests + pathResolver.setRuntimeEnv(originalEnv); + } + } + + @Test + void testSetRuntimeEnvIgnoresNull() { + String originalEnv = pathResolver.getRuntimeEnv(); + pathResolver.setRuntimeEnv(null); + assertThat(pathResolver.getRuntimeEnv()).isEqualTo(originalEnv); + } + + @Test + void testSetRuntimeEnvIgnoresBlank() { + String originalEnv = pathResolver.getRuntimeEnv(); + pathResolver.setRuntimeEnv(" "); + assertThat(pathResolver.getRuntimeEnv()).isEqualTo(originalEnv); + } + + // ================================================================= + // MULTI-CLUSTER PATHS WITH RUNTIME ENVIRONMENT + // ================================================================= + + @Test + void testGetMultiClusterRootIncludesEnv() { + String originalEnv = pathResolver.getRuntimeEnv(); + try { + pathResolver.setRuntimeEnv("staging"); + String path = pathResolver.getMultiClusterRoot(); + assertThat(path).isEqualTo("/multi-cluster/staging"); + } finally { + pathResolver.setRuntimeEnv(originalEnv); + } + } + + @Test + void testGetControllerHeartbeatPathIncludesEnv() { + String originalEnv = pathResolver.getRuntimeEnv(); + try { + pathResolver.setRuntimeEnv("production"); + String path = pathResolver.getControllerHeartbeatPath("controller-1"); + assertThat(path).isEqualTo("/multi-cluster/production/controllers/controller-1/heartbeat"); + } finally { + pathResolver.setRuntimeEnv(originalEnv); + } + } + + @Test + void testGetControllerAssignmentPathIncludesEnv() { + String originalEnv = pathResolver.getRuntimeEnv(); + try { + pathResolver.setRuntimeEnv("staging"); + String path = pathResolver.getControllerAssignmentPath("controller-1", "cluster-a"); + assertThat(path).isEqualTo("/multi-cluster/staging/controllers/controller-1/assigned/cluster-a"); + } finally { + pathResolver.setRuntimeEnv(originalEnv); + } + } + + @Test + void testGetClusterLockPathIncludesEnv() { + String originalEnv = pathResolver.getRuntimeEnv(); + try { + pathResolver.setRuntimeEnv("production"); + String path = pathResolver.getClusterLockPath("cluster-a"); + assertThat(path).isEqualTo("/multi-cluster/production/locks/clusters/cluster-a"); + } finally { + pathResolver.setRuntimeEnv(originalEnv); + } + } + + @Test + void testGetClusterRegistryPathIncludesEnv() { + String originalEnv = pathResolver.getRuntimeEnv(); + try { + pathResolver.setRuntimeEnv("staging"); + String path = pathResolver.getClusterRegistryPath("cluster-a"); + assertThat(path).isEqualTo("/multi-cluster/staging/clusters/cluster-a/metadata"); + } finally { + pathResolver.setRuntimeEnv(originalEnv); + } + } + + @Test + void testGetClusterAssignedControllerPathIncludesEnv() { + String originalEnv = pathResolver.getRuntimeEnv(); + try { + pathResolver.setRuntimeEnv("production"); + String path = pathResolver.getClusterAssignedControllerPath("cluster-a"); + assertThat(path).isEqualTo("/multi-cluster/production/clusters/cluster-a/assigned-to"); + } finally { + pathResolver.setRuntimeEnv(originalEnv); + } + } + + @Test + void testGetControllersPrefixIncludesEnv() { + String originalEnv = pathResolver.getRuntimeEnv(); + try { + pathResolver.setRuntimeEnv("staging"); + String path = pathResolver.getControllersPrefix(); + assertThat(path).isEqualTo("/multi-cluster/staging/controllers"); + } finally { + pathResolver.setRuntimeEnv(originalEnv); + } + } + + @Test + void testGetClustersPrefixIncludesEnv() { + String originalEnv = pathResolver.getRuntimeEnv(); + try { + pathResolver.setRuntimeEnv("production"); + String path = pathResolver.getClustersPrefix(); + assertThat(path).isEqualTo("/multi-cluster/production/clusters"); + } finally { + pathResolver.setRuntimeEnv(originalEnv); + } + } + + @Test + void testDifferentEnvsProduceDifferentPaths() { + String originalEnv = pathResolver.getRuntimeEnv(); + try { + pathResolver.setRuntimeEnv("staging"); + String stagingPath = pathResolver.getClusterLockPath("cluster-a"); + + pathResolver.setRuntimeEnv("production"); + String productionPath = pathResolver.getClusterLockPath("cluster-a"); + + assertThat(stagingPath).isNotEqualTo(productionPath); + assertThat(stagingPath).contains("/staging/"); + assertThat(productionPath).contains("/production/"); + } finally { + pathResolver.setRuntimeEnv(originalEnv); + } + } } \ No newline at end of file From 096a5cf80282fef71b22fb852d872cd7ed3ee60b Mon Sep 17 00:00:00 2001 From: tjnaik Date: Thu, 8 Jan 2026 11:03:56 -0800 Subject: [PATCH 2/3] Fix path parsing in ClusterRegistry and ControllerRegistry for runtime_env --- .../multicluster/registry/ClusterRegistry.java | 6 +++--- .../registry/ControllerRegistry.java | 6 +++--- .../registry/ClusterRegistryTest.java | 18 +++++++++--------- .../registry/ControllerRegistryTest.java | 14 +++++++------- 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/main/java/io/clustercontroller/multicluster/registry/ClusterRegistry.java b/src/main/java/io/clustercontroller/multicluster/registry/ClusterRegistry.java index 6a1e79bd..827eb33d 100644 --- a/src/main/java/io/clustercontroller/multicluster/registry/ClusterRegistry.java +++ b/src/main/java/io/clustercontroller/multicluster/registry/ClusterRegistry.java @@ -54,12 +54,12 @@ public Set listClusters() { Set clusters = new HashSet<>(); for (KeyValue kv : response.getKvs()) { String path = kv.getKey().toString(UTF_8); - // Extract cluster ID from path: /multi-cluster/clusters/{id}/metadata + // Extract cluster ID from path: /multi-cluster//clusters/{id}/metadata // Only include clusters that have metadata key (ignore assignment keys) if (path.endsWith("/metadata")) { String[] parts = path.split("/"); - if (parts.length >= 4) { - clusters.add(parts[3]); + if (parts.length >= 5) { + clusters.add(parts[4]); } } } diff --git a/src/main/java/io/clustercontroller/multicluster/registry/ControllerRegistry.java b/src/main/java/io/clustercontroller/multicluster/registry/ControllerRegistry.java index f7b91ec2..37f90e47 100644 --- a/src/main/java/io/clustercontroller/multicluster/registry/ControllerRegistry.java +++ b/src/main/java/io/clustercontroller/multicluster/registry/ControllerRegistry.java @@ -131,12 +131,12 @@ public Set listActiveControllers() { Set controllers = new HashSet<>(); for (KeyValue kv : response.getKvs()) { String path = kv.getKey().toString(UTF_8); - // Extract controller ID from path: /multi-cluster/controllers/{id}/heartbeat + // Extract controller ID from path: /multi-cluster//controllers/{id}/heartbeat // Only include paths that end with /heartbeat if (path.endsWith(HEARTBEAT_SUFFIX)) { String[] parts = path.split("/"); - if (parts.length >= 4) { - controllers.add(parts[3]); + if (parts.length >= 5) { + controllers.add(parts[4]); } } } diff --git a/src/test/java/io/clustercontroller/multicluster/registry/ClusterRegistryTest.java b/src/test/java/io/clustercontroller/multicluster/registry/ClusterRegistryTest.java index 7323ec92..b4733228 100644 --- a/src/test/java/io/clustercontroller/multicluster/registry/ClusterRegistryTest.java +++ b/src/test/java/io/clustercontroller/multicluster/registry/ClusterRegistryTest.java @@ -54,17 +54,17 @@ void setUp() { @Test void testListClusters_ReturnsClusterIds() throws Exception { // Given - String prefix = "/multi-cluster/clusters/"; + String prefix = "/multi-cluster/staging/clusters/"; when(pathResolver.getClustersPrefix()).thenReturn(prefix); KeyValue kv1 = mock(KeyValue.class); - when(kv1.getKey()).thenReturn(ByteSequence.from("/multi-cluster/clusters/cluster-1/metadata", UTF_8)); + when(kv1.getKey()).thenReturn(ByteSequence.from("/multi-cluster/staging/clusters/cluster-1/metadata", UTF_8)); KeyValue kv2 = mock(KeyValue.class); - when(kv2.getKey()).thenReturn(ByteSequence.from("/multi-cluster/clusters/cluster-2/metadata", UTF_8)); + when(kv2.getKey()).thenReturn(ByteSequence.from("/multi-cluster/staging/clusters/cluster-2/metadata", UTF_8)); KeyValue kv3 = mock(KeyValue.class); - when(kv3.getKey()).thenReturn(ByteSequence.from("/multi-cluster/clusters/cluster-3/metadata", UTF_8)); + when(kv3.getKey()).thenReturn(ByteSequence.from("/multi-cluster/staging/clusters/cluster-3/metadata", UTF_8)); GetResponse getResponse = mock(GetResponse.class); when(getResponse.getKvs()).thenReturn(List.of(kv1, kv2, kv3)); @@ -83,7 +83,7 @@ void testListClusters_ReturnsClusterIds() throws Exception { @Test void testListClusters_ReturnsEmptyOnError() { // Given - String prefix = "/multi-cluster/clusters/"; + String prefix = "/multi-cluster/staging/clusters/"; when(pathResolver.getClustersPrefix()).thenReturn(prefix); when(kvClient.get(any(ByteSequence.class), any(GetOption.class))) @@ -99,17 +99,17 @@ void testListClusters_ReturnsEmptyOnError() { @Test void testListClusters_HandlesInvalidPaths() throws Exception { // Given - String prefix = "/multi-cluster/clusters/"; + String prefix = "/multi-cluster/staging/clusters/"; when(pathResolver.getClustersPrefix()).thenReturn(prefix); KeyValue kv1 = mock(KeyValue.class); - when(kv1.getKey()).thenReturn(ByteSequence.from("/multi-cluster/clusters/cluster-1/metadata", UTF_8)); + when(kv1.getKey()).thenReturn(ByteSequence.from("/multi-cluster/staging/clusters/cluster-1/metadata", UTF_8)); KeyValue kv2 = mock(KeyValue.class); when(kv2.getKey()).thenReturn(ByteSequence.from("/invalid/path", UTF_8)); // Invalid path KeyValue kv3 = mock(KeyValue.class); - when(kv3.getKey()).thenReturn(ByteSequence.from("/multi-cluster/clusters/", UTF_8)); // Too short + when(kv3.getKey()).thenReturn(ByteSequence.from("/multi-cluster/staging/clusters/", UTF_8)); // Too short GetResponse getResponse = mock(GetResponse.class); when(getResponse.getKvs()).thenReturn(List.of(kv1, kv2, kv3)); @@ -127,7 +127,7 @@ void testListClusters_HandlesInvalidPaths() throws Exception { @Test void testWatchClusters_TriggersCallbackOnChange() { // Given - String prefix = "/multi-cluster/clusters/"; + String prefix = "/multi-cluster/staging/clusters/"; when(pathResolver.getClustersPrefix()).thenReturn(prefix); // Capture the watch callback diff --git a/src/test/java/io/clustercontroller/multicluster/registry/ControllerRegistryTest.java b/src/test/java/io/clustercontroller/multicluster/registry/ControllerRegistryTest.java index 71e28412..fe096a89 100644 --- a/src/test/java/io/clustercontroller/multicluster/registry/ControllerRegistryTest.java +++ b/src/test/java/io/clustercontroller/multicluster/registry/ControllerRegistryTest.java @@ -69,7 +69,7 @@ void testRegister_Success() throws Exception { String controllerId = "controller-1"; int ttl = 60; long leaseId = 12345L; - String heartbeatPath = "/multi-cluster/controllers/controller-1/heartbeat"; + String heartbeatPath = "/multi-cluster/staging/controllers/controller-1/heartbeat"; when(pathResolver.getControllerHeartbeatPath(controllerId)).thenReturn(heartbeatPath); @@ -144,17 +144,17 @@ void testDeregister_HandlesNotFoundGracefully() { @Test void testListActiveControllers_ReturnsControllerIds() throws Exception { // Given - String prefix = "/multi-cluster/controllers/"; + String prefix = "/multi-cluster/staging/controllers/"; when(pathResolver.getControllersPrefix()).thenReturn(prefix); KeyValue kv1 = mock(KeyValue.class); - when(kv1.getKey()).thenReturn(ByteSequence.from("/multi-cluster/controllers/controller-1/heartbeat", UTF_8)); + when(kv1.getKey()).thenReturn(ByteSequence.from("/multi-cluster/staging/controllers/controller-1/heartbeat", UTF_8)); KeyValue kv2 = mock(KeyValue.class); - when(kv2.getKey()).thenReturn(ByteSequence.from("/multi-cluster/controllers/controller-2/heartbeat", UTF_8)); + when(kv2.getKey()).thenReturn(ByteSequence.from("/multi-cluster/staging/controllers/controller-2/heartbeat", UTF_8)); KeyValue kv3 = mock(KeyValue.class); - when(kv3.getKey()).thenReturn(ByteSequence.from("/multi-cluster/controllers/controller-3/assigned/cluster-1", UTF_8)); + when(kv3.getKey()).thenReturn(ByteSequence.from("/multi-cluster/staging/controllers/controller-3/assigned/cluster-1", UTF_8)); GetResponse getResponse = mock(GetResponse.class); when(getResponse.getKvs()).thenReturn(List.of(kv1, kv2, kv3)); @@ -173,7 +173,7 @@ void testListActiveControllers_ReturnsControllerIds() throws Exception { @Test void testListActiveControllers_ReturnsEmptyOnError() { // Given - String prefix = "/multi-cluster/controllers/"; + String prefix = "/multi-cluster/staging/controllers/"; when(pathResolver.getControllersPrefix()).thenReturn(prefix); when(kvClient.get(any(ByteSequence.class), any(GetOption.class))) @@ -189,7 +189,7 @@ void testListActiveControllers_ReturnsEmptyOnError() { @Test void testWatchControllers_TriggersCallbackOnChange() { // Given - String prefix = "/multi-cluster/controllers/"; + String prefix = "/multi-cluster/staging/controllers/"; when(pathResolver.getControllersPrefix()).thenReturn(prefix); // Capture the watch callback From 243c929bdf9efa3aadc82eafdb514fd9232338d8 Mon Sep 17 00:00:00 2001 From: tjnaik Date: Sat, 10 Jan 2026 20:01:20 -0800 Subject: [PATCH 3/3] Refactor config to use EnvironmentUtils.get() pattern --- docker/application.yml | 6 +- .../ClusterControllerApplication.java | 11 +- .../config/ClusterControllerConfig.java | 18 +-- .../clustercontroller/config/Constants.java | 1 - .../store/EtcdMetadataStore.java | 2 +- .../store/EtcdPathResolver.java | 48 ++++---- .../util/EnvironmentUtils.java | 72 ++++++++---- src/main/resources/application.yml | 6 +- .../ClusterControllerApplicationTest.java | 6 + .../indices/IndexManagerTest.java | 6 + .../store/EtcdPathResolverTest.java | 62 +++++------ .../store/LeaderElectionTest.java | 5 + .../util/EnvironmentUtilsTest.java | 104 ++++++++++++------ 13 files changed, 200 insertions(+), 147 deletions(-) diff --git a/docker/application.yml b/docker/application.yml index 6c5edc19..2429bb75 100644 --- a/docker/application.yml +++ b/docker/application.yml @@ -1,9 +1,5 @@ # Application Configuration -# Runtime environment for multi-cluster path isolation (staging, production, etc.) -# This is patched at build time for different environments -runtime_env: staging - cluster: name: default-cluster @@ -23,6 +19,8 @@ coordinator_goal_state: controller: # Controller ID - REQUIRED: reads from NODE_NAME environment variable id: ${NODE_NAME} + # Runtime environment for multi-cluster path isolation (staging, production, etc.) + runtime_env: ${RUNTIME_ENV:staging} ttl: seconds: 60 keepalive: diff --git a/src/main/java/io/clustercontroller/ClusterControllerApplication.java b/src/main/java/io/clustercontroller/ClusterControllerApplication.java index 04b05232..e839d84d 100644 --- a/src/main/java/io/clustercontroller/ClusterControllerApplication.java +++ b/src/main/java/io/clustercontroller/ClusterControllerApplication.java @@ -18,7 +18,9 @@ import io.clustercontroller.store.EtcdPathResolver; import io.etcd.jetcd.Client; +import io.clustercontroller.util.EnvironmentUtils; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; @@ -60,14 +62,15 @@ public ClusterControllerConfig config() { /** * MetadataStore bean - cluster-agnostic, uses etcd endpoints only + * Depends on EtcdPathResolver to ensure it's initialized first (reads its own config via @Value) */ @Bean - public MetadataStore metadataStore(ClusterControllerConfig config) { + public MetadataStore metadataStore( + ClusterControllerConfig config, + EnvironmentUtils envUtils, + EtcdPathResolver pathResolver) { log.info("Initializing cluster-agnostic MetadataStore connection to etcd"); try { - // Configure EtcdPathResolver with runtime environment for path isolation - EtcdPathResolver.getInstance().setRuntimeEnv(config.getRuntimeEnv()); - log.info("EtcdPathResolver configured with runtime_env: {}", config.getRuntimeEnv()); EtcdMetadataStore store = EtcdMetadataStore.getInstance(config.getEtcdEndpoints()); // Configure coordinator goal state path from YAML config diff --git a/src/main/java/io/clustercontroller/config/ClusterControllerConfig.java b/src/main/java/io/clustercontroller/config/ClusterControllerConfig.java index c51b29e8..27a2edc4 100644 --- a/src/main/java/io/clustercontroller/config/ClusterControllerConfig.java +++ b/src/main/java/io/clustercontroller/config/ClusterControllerConfig.java @@ -30,7 +30,6 @@ public class ClusterControllerConfig { private final long taskIntervalSeconds; private final String coordinatorGoalStateGroup; private final String coordinatorGoalStateUnit; - private final String runtimeEnv; // Default classpath location private static final String DEFAULT_CONFIG_FILE_CLASSPATH = "application.yml"; @@ -45,10 +44,9 @@ public ClusterControllerConfig() { this.taskIntervalSeconds = parseTaskIntervalSeconds(config); this.coordinatorGoalStateGroup = parseCoordinatorGoalStateGroup(config); this.coordinatorGoalStateUnit = parseCoordinatorGoalStateUnit(config); - this.runtimeEnv = parseRuntimeEnv(config); - log.info("Loaded cluster controller config - etcd endpoints: {}, task interval: {}s, runtime_env: {}", - String.join(", ", etcdEndpoints), taskIntervalSeconds, runtimeEnv); + log.info("Loaded cluster controller config - etcd endpoints: {}, task interval: {}s", + String.join(", ", etcdEndpoints), taskIntervalSeconds); } @@ -158,17 +156,6 @@ private String parseCoordinatorGoalStateUnit(ConfigModel config) { return COORDINATOR_DEFAULT_UNIT; } - private String parseRuntimeEnv(ConfigModel config) { - try { - if (config.getRuntime_env() != null && !config.getRuntime_env().isBlank()) { - return config.getRuntime_env(); - } - } catch (Exception e) { - log.warn("Failed to parse runtime_env, using default 'staging': {}", e.getMessage()); - } - return DEFAULT_RUNTIME_ENV; - } - /** * Configuration model for the application.yml file. */ @@ -178,7 +165,6 @@ public static class ConfigModel { private Task task; private Controller controller; // Multi-cluster controller config (used by Spring @Value) private CoordinatorGoalState coordinator_goal_state; - private String runtime_env; // Environment isolation for multi-cluster paths (staging, production, etc.) } @Data diff --git a/src/main/java/io/clustercontroller/config/Constants.java b/src/main/java/io/clustercontroller/config/Constants.java index b6323ae8..f8c71f20 100644 --- a/src/main/java/io/clustercontroller/config/Constants.java +++ b/src/main/java/io/clustercontroller/config/Constants.java @@ -12,7 +12,6 @@ private Constants() { // Default configuration values public static final String DEFAULT_ETCD_ENDPOINT = "http://localhost:2379"; public static final long DEFAULT_TASK_INTERVAL_SECONDS = 30L; - public static final String DEFAULT_RUNTIME_ENV = "staging"; // Task statuses public static final String TASK_STATUS_PENDING = "PENDING"; diff --git a/src/main/java/io/clustercontroller/store/EtcdMetadataStore.java b/src/main/java/io/clustercontroller/store/EtcdMetadataStore.java index cd983bf3..c0b6d18b 100644 --- a/src/main/java/io/clustercontroller/store/EtcdMetadataStore.java +++ b/src/main/java/io/clustercontroller/store/EtcdMetadataStore.java @@ -74,7 +74,7 @@ public class EtcdMetadataStore implements MetadataStore { */ private EtcdMetadataStore(String[] etcdEndpoints) throws Exception { this.etcdEndpoints = etcdEndpoints; - this.nodeId = EnvironmentUtils.getRequiredEnv("NODE_NAME"); + this.nodeId = EnvironmentUtils.get("controller.id"); // Initialize Jackson ObjectMapper this.objectMapper = new ObjectMapper() diff --git a/src/main/java/io/clustercontroller/store/EtcdPathResolver.java b/src/main/java/io/clustercontroller/store/EtcdPathResolver.java index 96a021b2..323f1c16 100644 --- a/src/main/java/io/clustercontroller/store/EtcdPathResolver.java +++ b/src/main/java/io/clustercontroller/store/EtcdPathResolver.java @@ -1,7 +1,11 @@ package io.clustercontroller.store; import java.nio.file.Paths; +import org.springframework.context.annotation.DependsOn; import org.springframework.stereotype.Component; +import lombok.extern.slf4j.Slf4j; + +import io.clustercontroller.util.EnvironmentUtils; import static io.clustercontroller.config.Constants.*; @@ -13,7 +17,9 @@ * Multi-cluster coordination paths are isolated by runtime environment (staging, production, etc.) * to prevent controllers from different environments from interfering with each other. */ +@Slf4j @Component +@DependsOn("environmentUtils") public class EtcdPathResolver { private static final String PATH_DELIMITER = "/"; @@ -21,38 +27,24 @@ public class EtcdPathResolver { // Singleton instance private static EtcdPathResolver INSTANCE; - // Runtime environment for multi-cluster path isolation (staging, production, etc.) - private String runtimeEnv = "staging"; - public EtcdPathResolver() { - // Public constructor for Spring INSTANCE = this; + log.info("EtcdPathResolver initialized"); } public static EtcdPathResolver getInstance() { if (INSTANCE == null) { - INSTANCE = new EtcdPathResolver(); + throw new IllegalStateException("EtcdPathResolver not initialized. Ensure Spring context is loaded."); } return INSTANCE; } /** - * Set the runtime environment for multi-cluster path isolation. - * This is called during application startup from the config. - * - * @param runtimeEnv the environment name (e.g., "staging", "production") - */ - public void setRuntimeEnv(String runtimeEnv) { - if (runtimeEnv != null && !runtimeEnv.isBlank()) { - this.runtimeEnv = runtimeEnv; - } - } - - /** - * Get the current runtime environment. + * Get the current runtime environment from config. + * Package-private for testing. */ - public String getRuntimeEnv() { - return runtimeEnv; + String getRuntimeEnv() { + return EnvironmentUtils.get("controller.runtime_env"); } // ================================================================= @@ -279,7 +271,7 @@ public String getClusterRoot(String clusterName) { * Pattern: /multi-cluster/ */ public String getMultiClusterRoot() { - return Paths.get(PATH_DELIMITER, PATH_MULTI_CLUSTER, runtimeEnv).toString(); + return Paths.get(PATH_DELIMITER, PATH_MULTI_CLUSTER, getRuntimeEnv()).toString(); } /** @@ -287,7 +279,7 @@ public String getMultiClusterRoot() { * Pattern: /multi-cluster//controllers//heartbeat */ public String getControllerHeartbeatPath(String controllerId) { - return Paths.get(PATH_DELIMITER, PATH_MULTI_CLUSTER, runtimeEnv, PATH_CONTROLLERS, controllerId, PATH_HEARTBEAT).toString(); + return Paths.get(PATH_DELIMITER, PATH_MULTI_CLUSTER, getRuntimeEnv(), PATH_CONTROLLERS, controllerId, PATH_HEARTBEAT).toString(); } /** @@ -295,7 +287,7 @@ public String getControllerHeartbeatPath(String controllerId) { * Pattern: /multi-cluster//controllers//assigned/ */ public String getControllerAssignmentPath(String controllerId, String clusterId) { - return Paths.get(PATH_DELIMITER, PATH_MULTI_CLUSTER, runtimeEnv, PATH_CONTROLLERS, controllerId, PATH_ASSIGNED, clusterId).toString(); + return Paths.get(PATH_DELIMITER, PATH_MULTI_CLUSTER, getRuntimeEnv(), PATH_CONTROLLERS, controllerId, PATH_ASSIGNED, clusterId).toString(); } /** @@ -303,7 +295,7 @@ public String getControllerAssignmentPath(String controllerId, String clusterId) * Pattern: /multi-cluster//locks/clusters/ */ public String getClusterLockPath(String clusterId) { - return Paths.get(PATH_DELIMITER, PATH_MULTI_CLUSTER, runtimeEnv, PATH_LOCKS, PATH_CLUSTERS, clusterId).toString(); + return Paths.get(PATH_DELIMITER, PATH_MULTI_CLUSTER, getRuntimeEnv(), PATH_LOCKS, PATH_CLUSTERS, clusterId).toString(); } /** @@ -311,7 +303,7 @@ public String getClusterLockPath(String clusterId) { * Pattern: /multi-cluster//clusters//metadata */ public String getClusterRegistryPath(String clusterId) { - return Paths.get(PATH_DELIMITER, PATH_MULTI_CLUSTER, runtimeEnv, PATH_CLUSTERS, clusterId, PATH_METADATA).toString(); + return Paths.get(PATH_DELIMITER, PATH_MULTI_CLUSTER, getRuntimeEnv(), PATH_CLUSTERS, clusterId, PATH_METADATA).toString(); } /** @@ -319,7 +311,7 @@ public String getClusterRegistryPath(String clusterId) { * Pattern: /multi-cluster//clusters//assigned-to */ public String getClusterAssignedControllerPath(String clusterId) { - return Paths.get(PATH_DELIMITER, PATH_MULTI_CLUSTER, runtimeEnv, PATH_CLUSTERS, clusterId, "assigned-to").toString(); + return Paths.get(PATH_DELIMITER, PATH_MULTI_CLUSTER, getRuntimeEnv(), PATH_CLUSTERS, clusterId, "assigned-to").toString(); } /** @@ -327,7 +319,7 @@ public String getClusterAssignedControllerPath(String clusterId) { * Pattern: /multi-cluster//controllers/ */ public String getControllersPrefix() { - return Paths.get(PATH_DELIMITER, PATH_MULTI_CLUSTER, runtimeEnv, PATH_CONTROLLERS, "").toString(); + return Paths.get(PATH_DELIMITER, PATH_MULTI_CLUSTER, getRuntimeEnv(), PATH_CONTROLLERS, "").toString(); } /** @@ -335,6 +327,6 @@ public String getControllersPrefix() { * Pattern: /multi-cluster//clusters/ */ public String getClustersPrefix() { - return Paths.get(PATH_DELIMITER, PATH_MULTI_CLUSTER, runtimeEnv, PATH_CLUSTERS, "").toString(); + return Paths.get(PATH_DELIMITER, PATH_MULTI_CLUSTER, getRuntimeEnv(), PATH_CLUSTERS, "").toString(); } } \ No newline at end of file diff --git a/src/main/java/io/clustercontroller/util/EnvironmentUtils.java b/src/main/java/io/clustercontroller/util/EnvironmentUtils.java index c961b831..4d585fc3 100644 --- a/src/main/java/io/clustercontroller/util/EnvironmentUtils.java +++ b/src/main/java/io/clustercontroller/util/EnvironmentUtils.java @@ -1,38 +1,72 @@ package io.clustercontroller.util; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; +import jakarta.annotation.PostConstruct; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + /** - * Utility class for environment variable operations + * Simple utility for accessing config from application.yml via Spring Environment. + * Callers pass the key they need (e.g., "controller.id", "controller.runtime_env"). */ -public final class EnvironmentUtils { +@Component +public class EnvironmentUtils { + + private static Environment springEnvironment; + + // Test overrides - takes precedence over Spring Environment + private static final Map testOverrides = new ConcurrentHashMap<>(); - private EnvironmentUtils() { - // Utility class - prevent instantiation + private final Environment environment; + + public EnvironmentUtils(Environment environment) { + this.environment = environment; + } + + @PostConstruct + public void init() { + springEnvironment = this.environment; } /** - * Get required environment variable - throws exception if not set + * Get a config value by key from application.yml. * - * @param name the environment variable name - * @return the trimmed environment variable value - * @throws IllegalStateException if the environment variable is not set or is empty + * @param key the property key (e.g., "controller.id", "controller.runtime_env") + * @return the property value + * @throws IllegalStateException if not initialized or property not set */ - public static String getRequiredEnv(String name) { - String value = System.getenv(name); + public static String get(String key) { + // Check test overrides first + String override = testOverrides.get(key); + if (override != null) { + return override; + } + + if (springEnvironment == null) { + throw new IllegalStateException("EnvironmentUtils not initialized yet"); + } + String value = springEnvironment.getProperty(key); if (value == null || value.trim().isEmpty()) { - throw new IllegalStateException("Required environment variable '" + name + "' is not set or is empty"); + throw new IllegalStateException("Property '" + key + "' is not set in application.yml"); } return value.trim(); } + // ========== Test Support ========== + /** - * Get environment variable with default value - * - * @param name the environment variable name - * @param defaultValue the default value to return if not set - * @return the environment variable value or default if not set + * For testing only - override a config value. + */ + public static void setForTesting(String key, String value) { + testOverrides.put(key, value); + } + + /** + * For testing only - clear all overrides. */ - public static String getEnv(String name, String defaultValue) { - String value = System.getenv(name); - return value != null ? value.trim() : defaultValue; + public static void clearTestOverrides() { + testOverrides.clear(); } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 23d9a101..b75c4fd1 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,9 +1,5 @@ # Application Configuration -# Runtime environment for multi-cluster path isolation (staging, production, etc.) -# This is patched at build time for different environments -runtime_env: staging - etcd: endpoints: - http://localhost:2379 @@ -20,6 +16,8 @@ coordinator_goal_state: controller: # Controller ID - REQUIRED: reads from NODE_NAME environment variable id: ${NODE_NAME} + # Runtime environment for multi-cluster path isolation (staging, production, etc.) + runtime_env: ${RUNTIME_ENV:staging} ttl: seconds: 60 keepalive: diff --git a/src/test/java/io/clustercontroller/ClusterControllerApplicationTest.java b/src/test/java/io/clustercontroller/ClusterControllerApplicationTest.java index 987fdc9b..f9dc190f 100644 --- a/src/test/java/io/clustercontroller/ClusterControllerApplicationTest.java +++ b/src/test/java/io/clustercontroller/ClusterControllerApplicationTest.java @@ -1,7 +1,9 @@ package io.clustercontroller; import io.clustercontroller.store.EtcdMetadataStore; +import io.clustercontroller.store.EtcdPathResolver; import io.clustercontroller.store.MetadataStore; +import io.clustercontroller.util.EnvironmentUtils; import io.etcd.jetcd.*; import io.etcd.jetcd.election.CampaignResponse; import io.etcd.jetcd.lease.LeaseGrantResponse; @@ -47,6 +49,10 @@ class ClusterControllerApplicationTest { @BeforeEach void setUp() { + // Initialize EtcdPathResolver for tests + EnvironmentUtils.setForTesting("controller.runtime_env", "staging"); + new EtcdPathResolver(); + // Reset singleton instance before each test EtcdMetadataStore.resetInstance(); diff --git a/src/test/java/io/clustercontroller/indices/IndexManagerTest.java b/src/test/java/io/clustercontroller/indices/IndexManagerTest.java index 59d4ed80..68b11b28 100644 --- a/src/test/java/io/clustercontroller/indices/IndexManagerTest.java +++ b/src/test/java/io/clustercontroller/indices/IndexManagerTest.java @@ -4,8 +4,10 @@ import io.clustercontroller.models.IndexMetadata; import io.clustercontroller.models.IndexSettings; import io.clustercontroller.models.TypeMapping; +import io.clustercontroller.store.EtcdPathResolver; import io.clustercontroller.store.MetadataStore; import io.clustercontroller.templates.TemplateManager; +import io.clustercontroller.util.EnvironmentUtils; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -38,6 +40,10 @@ class IndexManagerTest { @BeforeEach void setUp() throws Exception { + // Initialize EtcdPathResolver for tests + EnvironmentUtils.setForTesting("controller.runtime_env", "staging"); + new EtcdPathResolver(); + // Mock template manager to return empty list by default (no matching templates) // Use lenient() to avoid UnnecessaryStubbingException in tests that don't create indices lenient().when(templateManager.findMatchingTemplates(any(), any())).thenReturn(new ArrayList<>()); diff --git a/src/test/java/io/clustercontroller/store/EtcdPathResolverTest.java b/src/test/java/io/clustercontroller/store/EtcdPathResolverTest.java index c8e90b9b..ce0a5f31 100644 --- a/src/test/java/io/clustercontroller/store/EtcdPathResolverTest.java +++ b/src/test/java/io/clustercontroller/store/EtcdPathResolverTest.java @@ -1,5 +1,6 @@ package io.clustercontroller.store; +import io.clustercontroller.util.EnvironmentUtils; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -12,7 +13,10 @@ class EtcdPathResolverTest { @BeforeEach void setUp() { - pathResolver = EtcdPathResolver.getInstance(); + // Set up test environment before creating the resolver + EnvironmentUtils.setForTesting("controller.runtime_env", "staging"); + // Create instance manually (in production, Spring does this) + pathResolver = new EtcdPathResolver(); } @Test @@ -148,28 +152,14 @@ void testDefaultRuntimeEnv() { void testSetRuntimeEnv() { String originalEnv = pathResolver.getRuntimeEnv(); try { - pathResolver.setRuntimeEnv("production"); + EnvironmentUtils.setForTesting("controller.runtime_env", "production"); assertThat(pathResolver.getRuntimeEnv()).isEqualTo("production"); } finally { // Restore original to not affect other tests - pathResolver.setRuntimeEnv(originalEnv); + EnvironmentUtils.setForTesting("controller.runtime_env", originalEnv); } } - @Test - void testSetRuntimeEnvIgnoresNull() { - String originalEnv = pathResolver.getRuntimeEnv(); - pathResolver.setRuntimeEnv(null); - assertThat(pathResolver.getRuntimeEnv()).isEqualTo(originalEnv); - } - - @Test - void testSetRuntimeEnvIgnoresBlank() { - String originalEnv = pathResolver.getRuntimeEnv(); - pathResolver.setRuntimeEnv(" "); - assertThat(pathResolver.getRuntimeEnv()).isEqualTo(originalEnv); - } - // ================================================================= // MULTI-CLUSTER PATHS WITH RUNTIME ENVIRONMENT // ================================================================= @@ -178,11 +168,11 @@ void testSetRuntimeEnvIgnoresBlank() { void testGetMultiClusterRootIncludesEnv() { String originalEnv = pathResolver.getRuntimeEnv(); try { - pathResolver.setRuntimeEnv("staging"); + EnvironmentUtils.setForTesting("controller.runtime_env", "staging"); String path = pathResolver.getMultiClusterRoot(); assertThat(path).isEqualTo("/multi-cluster/staging"); } finally { - pathResolver.setRuntimeEnv(originalEnv); + EnvironmentUtils.setForTesting("controller.runtime_env", originalEnv); } } @@ -190,11 +180,11 @@ void testGetMultiClusterRootIncludesEnv() { void testGetControllerHeartbeatPathIncludesEnv() { String originalEnv = pathResolver.getRuntimeEnv(); try { - pathResolver.setRuntimeEnv("production"); + EnvironmentUtils.setForTesting("controller.runtime_env", "production"); String path = pathResolver.getControllerHeartbeatPath("controller-1"); assertThat(path).isEqualTo("/multi-cluster/production/controllers/controller-1/heartbeat"); } finally { - pathResolver.setRuntimeEnv(originalEnv); + EnvironmentUtils.setForTesting("controller.runtime_env", originalEnv); } } @@ -202,11 +192,11 @@ void testGetControllerHeartbeatPathIncludesEnv() { void testGetControllerAssignmentPathIncludesEnv() { String originalEnv = pathResolver.getRuntimeEnv(); try { - pathResolver.setRuntimeEnv("staging"); + EnvironmentUtils.setForTesting("controller.runtime_env", "staging"); String path = pathResolver.getControllerAssignmentPath("controller-1", "cluster-a"); assertThat(path).isEqualTo("/multi-cluster/staging/controllers/controller-1/assigned/cluster-a"); } finally { - pathResolver.setRuntimeEnv(originalEnv); + EnvironmentUtils.setForTesting("controller.runtime_env", originalEnv); } } @@ -214,11 +204,11 @@ void testGetControllerAssignmentPathIncludesEnv() { void testGetClusterLockPathIncludesEnv() { String originalEnv = pathResolver.getRuntimeEnv(); try { - pathResolver.setRuntimeEnv("production"); + EnvironmentUtils.setForTesting("controller.runtime_env", "production"); String path = pathResolver.getClusterLockPath("cluster-a"); assertThat(path).isEqualTo("/multi-cluster/production/locks/clusters/cluster-a"); } finally { - pathResolver.setRuntimeEnv(originalEnv); + EnvironmentUtils.setForTesting("controller.runtime_env", originalEnv); } } @@ -226,11 +216,11 @@ void testGetClusterLockPathIncludesEnv() { void testGetClusterRegistryPathIncludesEnv() { String originalEnv = pathResolver.getRuntimeEnv(); try { - pathResolver.setRuntimeEnv("staging"); + EnvironmentUtils.setForTesting("controller.runtime_env", "staging"); String path = pathResolver.getClusterRegistryPath("cluster-a"); assertThat(path).isEqualTo("/multi-cluster/staging/clusters/cluster-a/metadata"); } finally { - pathResolver.setRuntimeEnv(originalEnv); + EnvironmentUtils.setForTesting("controller.runtime_env", originalEnv); } } @@ -238,11 +228,11 @@ void testGetClusterRegistryPathIncludesEnv() { void testGetClusterAssignedControllerPathIncludesEnv() { String originalEnv = pathResolver.getRuntimeEnv(); try { - pathResolver.setRuntimeEnv("production"); + EnvironmentUtils.setForTesting("controller.runtime_env", "production"); String path = pathResolver.getClusterAssignedControllerPath("cluster-a"); assertThat(path).isEqualTo("/multi-cluster/production/clusters/cluster-a/assigned-to"); } finally { - pathResolver.setRuntimeEnv(originalEnv); + EnvironmentUtils.setForTesting("controller.runtime_env", originalEnv); } } @@ -250,11 +240,11 @@ void testGetClusterAssignedControllerPathIncludesEnv() { void testGetControllersPrefixIncludesEnv() { String originalEnv = pathResolver.getRuntimeEnv(); try { - pathResolver.setRuntimeEnv("staging"); + EnvironmentUtils.setForTesting("controller.runtime_env", "staging"); String path = pathResolver.getControllersPrefix(); assertThat(path).isEqualTo("/multi-cluster/staging/controllers"); } finally { - pathResolver.setRuntimeEnv(originalEnv); + EnvironmentUtils.setForTesting("controller.runtime_env", originalEnv); } } @@ -262,11 +252,11 @@ void testGetControllersPrefixIncludesEnv() { void testGetClustersPrefixIncludesEnv() { String originalEnv = pathResolver.getRuntimeEnv(); try { - pathResolver.setRuntimeEnv("production"); + EnvironmentUtils.setForTesting("controller.runtime_env", "production"); String path = pathResolver.getClustersPrefix(); assertThat(path).isEqualTo("/multi-cluster/production/clusters"); } finally { - pathResolver.setRuntimeEnv(originalEnv); + EnvironmentUtils.setForTesting("controller.runtime_env", originalEnv); } } @@ -274,17 +264,17 @@ void testGetClustersPrefixIncludesEnv() { void testDifferentEnvsProduceDifferentPaths() { String originalEnv = pathResolver.getRuntimeEnv(); try { - pathResolver.setRuntimeEnv("staging"); + EnvironmentUtils.setForTesting("controller.runtime_env", "staging"); String stagingPath = pathResolver.getClusterLockPath("cluster-a"); - pathResolver.setRuntimeEnv("production"); + EnvironmentUtils.setForTesting("controller.runtime_env", "production"); String productionPath = pathResolver.getClusterLockPath("cluster-a"); assertThat(stagingPath).isNotEqualTo(productionPath); assertThat(stagingPath).contains("/staging/"); assertThat(productionPath).contains("/production/"); } finally { - pathResolver.setRuntimeEnv(originalEnv); + EnvironmentUtils.setForTesting("controller.runtime_env", originalEnv); } } } \ No newline at end of file diff --git a/src/test/java/io/clustercontroller/store/LeaderElectionTest.java b/src/test/java/io/clustercontroller/store/LeaderElectionTest.java index 87a4877a..fa542b73 100644 --- a/src/test/java/io/clustercontroller/store/LeaderElectionTest.java +++ b/src/test/java/io/clustercontroller/store/LeaderElectionTest.java @@ -1,5 +1,6 @@ package io.clustercontroller.store; +import io.clustercontroller.util.EnvironmentUtils; import io.etcd.jetcd.*; import io.etcd.jetcd.election.CampaignResponse; import io.etcd.jetcd.kv.GetResponse; @@ -53,6 +54,10 @@ class LeaderElectionTest { @BeforeEach void setUp() { + // Initialize EtcdPathResolver for tests + EnvironmentUtils.setForTesting("controller.runtime_env", "staging"); + new EtcdPathResolver(); + // Reset singleton instance before each test EtcdMetadataStore.resetInstance(); diff --git a/src/test/java/io/clustercontroller/util/EnvironmentUtilsTest.java b/src/test/java/io/clustercontroller/util/EnvironmentUtilsTest.java index 0dfdcd79..7880bd3a 100644 --- a/src/test/java/io/clustercontroller/util/EnvironmentUtilsTest.java +++ b/src/test/java/io/clustercontroller/util/EnvironmentUtilsTest.java @@ -1,66 +1,102 @@ package io.clustercontroller.util; +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 org.springframework.core.env.Environment; + +import java.lang.reflect.Field; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; /** * Tests for EnvironmentUtils */ +@ExtendWith(MockitoExtension.class) class EnvironmentUtilsTest { + @Mock + private Environment mockEnvironment; + + @BeforeEach + void setUp() { + EnvironmentUtils.clearTestOverrides(); + } + @Test - void testGetRequiredEnvWithValidValue() { - // Test with an environment variable that should exist (PATH is standard) - String path = EnvironmentUtils.getRequiredEnv("PATH"); - assertNotNull(path); - assertFalse(path.trim().isEmpty()); + void testGetWithValidValue() throws Exception { + // Set up mock environment + setSpringEnvironment(mockEnvironment); + when(mockEnvironment.getProperty("test.key")).thenReturn("test-value"); + + String result = EnvironmentUtils.get("test.key"); + + assertEquals("test-value", result); + verify(mockEnvironment).getProperty("test.key"); } @Test - void testGetRequiredEnvWithMissingValue() { - // Test with a non-existent environment variable + void testGetWithMissingValue() throws Exception { + // Set up mock environment + setSpringEnvironment(mockEnvironment); + when(mockEnvironment.getProperty("missing.key")).thenReturn(null); + IllegalStateException exception = assertThrows( IllegalStateException.class, - () -> EnvironmentUtils.getRequiredEnv("NON_EXISTENT_ENV_VAR_12345") + () -> EnvironmentUtils.get("missing.key") ); - assertTrue(exception.getMessage().contains("NON_EXISTENT_ENV_VAR_12345")); - assertTrue(exception.getMessage().contains("not set or is empty")); + assertTrue(exception.getMessage().contains("missing.key")); + assertTrue(exception.getMessage().contains("not set")); } @Test - void testGetEnvWithValidValue() { - // Test with an environment variable that should exist - String path = EnvironmentUtils.getEnv("PATH", "default-value"); - assertNotNull(path); - assertFalse(path.trim().isEmpty()); - assertNotEquals("default-value", path); + void testGetWithEmptyValue() throws Exception { + // Set up mock environment + setSpringEnvironment(mockEnvironment); + when(mockEnvironment.getProperty("empty.key")).thenReturn(" "); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> EnvironmentUtils.get("empty.key") + ); + + assertTrue(exception.getMessage().contains("empty.key")); } @Test - void testGetEnvWithMissingValue() { - // Test with a non-existent environment variable - String result = EnvironmentUtils.getEnv("NON_EXISTENT_ENV_VAR_12345", "my-default"); - assertEquals("my-default", result); + void testGetTrimsValue() throws Exception { + // Set up mock environment + setSpringEnvironment(mockEnvironment); + when(mockEnvironment.getProperty("trim.key")).thenReturn(" value-with-spaces "); + + String result = EnvironmentUtils.get("trim.key"); + + assertEquals("value-with-spaces", result); } @Test - void testGetEnvWithNullDefault() { - // Test with null default value - String result = EnvironmentUtils.getEnv("NON_EXISTENT_ENV_VAR_12345", null); - assertNull(result); + void testGetThrowsWhenSpringNotInitialized() throws Exception { + // Clear Spring environment + setSpringEnvironment(null); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> EnvironmentUtils.get("any.key") + ); + + assertTrue(exception.getMessage().contains("not initialized")); } - @Test - void testTrimmingBehavior() { - // Since we can't easily set environment variables in tests, - // we test the trimming behavior indirectly by verifying - // that existing env vars are trimmed (though they usually don't have whitespace) - String path = EnvironmentUtils.getEnv("PATH", "default"); - - // The trimming should not change valid paths, but ensures consistency - assertNotNull(path); - assertEquals(path, path.trim()); + /** + * Helper to set the static springEnvironment field for testing + */ + private void setSpringEnvironment(Environment env) throws Exception { + Field field = EnvironmentUtils.class.getDeclaredField("springEnvironment"); + field.setAccessible(true); + field.set(null, env); } }