From 9d9a433c13e6df5d402a4ff79316f6f692ce146c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Fri, 11 Sep 2026 12:44:03 +0200 Subject: [PATCH 1/2] feat: config property to set watched namespaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `josdk.controller..namespaces` to ConfigLoader, which was previously the only way of configuring watched namespaces that was not available from an external configuration source. The value is a comma-separated list; entries are trimmed and blank ones ignored. The special values JOSDK_ALL_NAMESPACES and JOSDK_WATCH_CURRENT are supported but, as everywhere else, only on their own. Validation happens while building the consumer, so an invalid value fails at wiring time rather than at reconciliation time. Signed-off-by: Attila Mészáros --- .../documentation/operations/configuration.md | 29 ++++- .../operator/config/loader/ConfigLoader.java | 55 +++++++++ .../baseapi/configloader/ConfigLoaderIT.java | 45 ++++++++ .../config/loader/ConfigLoaderTest.java | 109 ++++++++++++++++++ 4 files changed, 237 insertions(+), 1 deletion(-) diff --git a/docs/content/en/docs/documentation/operations/configuration.md b/docs/content/en/docs/documentation/operations/configuration.md index cdfb1b7fdb..e7d2f07990 100644 --- a/docs/content/en/docs/documentation/operations/configuration.md +++ b/docs/content/en/docs/documentation/operations/configuration.md @@ -40,7 +40,9 @@ operator.register(reconciler, configOverrider -> ## Dynamically Changing Target Namespaces A controller can be configured to watch a specific set of namespaces in addition of the -namespace in which it is currently deployed or the whole cluster. The framework supports +namespace in which it is currently deployed or the whole cluster. The initial set can be provided +programmatically, via the `@Informer` annotation, or read from an external configuration source (see +[the `namespaces` property](#watched-namespaces)). The framework supports dynamically changing the list of these namespaces while the operator is running. When a reconciler is registered, an instance of [`RegisteredController`](https://github.com/java-operator-sdk/java-operator-sdk/blob/ec37025a15046d8f409c77616110024bf32c3416/operator-framework-core/src/main/java/io/javaoperatorsdk/operator/RegisteredController.java#L5) @@ -349,6 +351,31 @@ All controller-level keys are prefixed with `josdk.controller.. | `josdk.controller..field-manager` | `String` | Field manager name used for SSA operations | | `josdk.controller..trigger-reconciler-on-all-events` | `Boolean` | Trigger reconciliation on every event, not only meaningful changes | +#### Watched Namespaces + +| Key | Type | Description | +|---|---|---| +| `josdk.controller..namespaces` | `String` | Comma-separated list of namespaces the controller watches | + +Entries are trimmed and blank ones are ignored, so `ns1, ns2` and `ns1,ns2` are equivalent. Instead +of a list of namespaces, the value can also be one of the two special values below, which have to be +used on their own — combining them with a namespace name is an error: + +| Value | Meaning | +|---|---| +| `JOSDK_ALL_NAMESPACES` | Watch the whole cluster (the default) | +| `JOSDK_WATCH_CURRENT` | Watch only the namespace the operator is deployed in | + +Setting this property is equivalent to calling `settingNamespaces` on +`ControllerConfigurationOverrider` and therefore replaces, rather than extends, the namespaces +configured via the `@Informer` annotation. The set of watched namespaces can still be changed while +the operator is running, see +[Dynamically Changing Target Namespaces](#dynamically-changing-target-namespaces). + +```properties +josdk.controller.mycontroller.namespaces=team-a,team-b +``` + #### Informer | Key | Type | Description | diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java index c8daf89724..8f2e4240a1 100644 --- a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java @@ -16,9 +16,12 @@ package io.javaoperatorsdk.operator.config.loader; import java.time.Duration; +import java.util.Arrays; +import java.util.LinkedHashSet; import java.util.List; import java.util.Optional; import java.util.function.Consumer; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -27,6 +30,7 @@ import io.javaoperatorsdk.operator.api.config.ConfigurationServiceOverrider; import io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider; import io.javaoperatorsdk.operator.api.config.LeaderElectionConfigurationBuilder; +import io.javaoperatorsdk.operator.api.config.informer.InformerConfiguration; import io.javaoperatorsdk.operator.config.loader.provider.AggregatePriorityListConfigProvider; import io.javaoperatorsdk.operator.config.loader.provider.EnvVarConfigProvider; import io.javaoperatorsdk.operator.config.loader.provider.PropertiesConfigProvider; @@ -132,6 +136,14 @@ public static ConfigLoader getDefault() { static final String RATE_LIMITER_REFRESH_PERIOD_SUFFIX = "rate-limiter.refresh-period"; static final String RATE_LIMITER_LIMIT_FOR_PERIOD_SUFFIX = "rate-limiter.limit-for-period"; + // --------------------------------------------------------------------------- + // Controller-level watched namespaces property suffix. Not a plain binding since the value is a + // set of namespaces, expressed as a comma-separated list. + // --------------------------------------------------------------------------- + static final String NAMESPACES_SUFFIX = "namespaces"; + + private static final String NAMESPACES_SEPARATOR = ","; + // --------------------------------------------------------------------------- // Controller-level (ControllerConfigurationOverrider) bindings // The key used at runtime is built as: @@ -226,6 +238,10 @@ Consumer> applyControllerConfigs(String cont (List, ?>>) (List) CONTROLLER_BINDINGS; Consumer> consumer = buildConsumer(bindings, prefix); + Consumer> namespacesStep = buildNamespacesConsumer(prefix); + if (namespacesStep != null) { + consumer = consumer.andThen(namespacesStep); + } Consumer> retryStep = buildRetryConsumer(prefix); if (retryStep != null) { consumer = consumer == null ? retryStep : consumer.andThen(retryStep); @@ -297,6 +313,45 @@ Consumer> buildRateLimiterConsumer(String pr }; } + /** + * If the {@code namespaces} property is present, returns a {@link Consumer} that sets the + * namespaces watched by the controller to the comma-separated list it holds. Entries are trimmed + * and blank ones are ignored. The special values {@link + * io.javaoperatorsdk.operator.api.reconciler.Constants#WATCH_ALL_NAMESPACES} and {@link + * io.javaoperatorsdk.operator.api.reconciler.Constants#WATCH_CURRENT_NAMESPACE} are supported but + * can only be used on their own. Returns {@code null} when the property is not present. + * + * @throws IllegalArgumentException if the property is present but does not resolve to a valid set + * of namespaces + */ + private + Consumer> buildNamespacesConsumer(String prefix) { + final var key = prefix + NAMESPACES_SUFFIX; + final var value = configProvider.getValue(key, String.class); + if (value.isEmpty()) { + return null; + } + + final var namespaces = + Arrays.stream(value.get().split(NAMESPACES_SEPARATOR)) + .map(String::trim) + .filter(namespace -> !namespace.isEmpty()) + .collect(Collectors.toCollection(LinkedHashSet::new)); + if (namespaces.isEmpty()) { + throw new IllegalArgumentException(key + " must list at least one namespace"); + } + try { + InformerConfiguration.failIfNotValid(namespaces); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid value for " + key + ": " + value.get(), e); + } + + return overrider -> { + log.debug("Found config property: {} = {}", key, value.get()); + overrider.settingNamespaces(namespaces); + }; + } + /** * If leader election is explicitly disabled via {@code leader-election.enabled=false}, returns * {@code null}. Otherwise, if at least one leader-election property is present (with {@code diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/configloader/ConfigLoaderIT.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/configloader/ConfigLoaderIT.java index d1ee0afa59..512fa45f45 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/configloader/ConfigLoaderIT.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/configloader/ConfigLoaderIT.java @@ -143,4 +143,49 @@ private ConfigLoaderTestCustomResource createResource(String id) { return resource; } } + + // --------------------------------------------------------------------------- + // Controller-level watched namespaces + // --------------------------------------------------------------------------- + + @Nested + class ControllerNamespacesProperty { + + // controller name is the lower-cased simple class name by default + static final String CTRL_NAME = ConfigLoaderTestReconciler.class.getSimpleName().toLowerCase(); + + /** + * Verifies that {@code josdk.controller..namespaces} read by {@link ConfigLoader} + * replaces the default "watch all namespaces" setting of the registered controller. + */ + @RegisterExtension + LocallyRunOperatorExtension operator = + LocallyRunOperatorExtension.builder() + .withReconciler( + new ConfigLoaderTestReconciler(0), + (Consumer) + (Consumer) + new ConfigLoader( + mapProvider( + Map.of( + "josdk.controller." + CTRL_NAME + ".namespaces", + "default, kube-public"))) + .applyControllerConfigs(CTRL_NAME)) + .build(); + + @Test + void watchedNamespacesAreAppliedFromConfigLoader() { + var informerConfig = + operator + .getOperator() + .getRegisteredController(CTRL_NAME) + .orElseThrow() + .getConfiguration() + .getInformerConfig(); + + assertThat(informerConfig.getNamespaces()) + .containsExactlyInAnyOrder("default", "kube-public"); + assertThat(informerConfig.watchAllNamespaces()).isFalse(); + } + } } diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java index 44fac32b7d..eb926fc664 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java @@ -30,6 +30,7 @@ import io.javaoperatorsdk.operator.api.config.ConfigurationService; import io.javaoperatorsdk.operator.api.config.ConfigurationServiceOverrider; import io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider; +import io.javaoperatorsdk.operator.api.reconciler.Constants; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; @@ -218,6 +219,7 @@ public Optional getValue(String key, Class type) { "josdk.controller.ctrl.informer.label-selector", "josdk.controller.ctrl.informer.shard-selector", "josdk.controller.ctrl.informer.list-limit", + "josdk.controller.ctrl.namespaces", "josdk.controller.ctrl.rate-limiter.refresh-period", "josdk.controller.ctrl.rate-limiter.limit-for-period"); } @@ -558,6 +560,113 @@ void retryIsIsolatedPerControllerName() { assertThat(betaRetry.getMaxAttempts()).isEqualTo(9); } + // -- watched namespaces ----------------------------------------------------- + + private static Set applyAndGetNamespaces( + java.util.function.Consumer< + io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider< + io.fabric8.kubernetes.api.model.ConfigMap>> + consumer) { + var overrider = + io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider.override( + baseControllerConfig()); + consumer.accept(overrider); + return overrider.build().getInformerConfig().getNamespaces(); + } + + @Test + void namespacesAreLeftUntouchedWhenPropertyIsAbsent() { + var loader = new ConfigLoader(mapProvider(Map.of())); + assertThat(applyAndGetNamespaces(loader.applyControllerConfigs("ctrl"))) + .isEqualTo(Constants.DEFAULT_NAMESPACES_SET); + } + + @Test + void singleNamespaceIsApplied() { + var loader = new ConfigLoader(mapProvider(Map.of("josdk.controller.ctrl.namespaces", "foo"))); + assertThat(applyAndGetNamespaces(loader.applyControllerConfigs("ctrl"))) + .containsExactlyInAnyOrder("foo"); + } + + @Test + void commaSeparatedNamespacesAreApplied() { + var loader = + new ConfigLoader(mapProvider(Map.of("josdk.controller.ctrl.namespaces", "foo,bar,baz"))); + assertThat(applyAndGetNamespaces(loader.applyControllerConfigs("ctrl"))) + .containsExactlyInAnyOrder("foo", "bar", "baz"); + } + + @Test + void namespacesAreTrimmedAndBlankEntriesIgnored() { + var loader = + new ConfigLoader(mapProvider(Map.of("josdk.controller.ctrl.namespaces", " foo , ,bar ,"))); + assertThat(applyAndGetNamespaces(loader.applyControllerConfigs("ctrl"))) + .containsExactlyInAnyOrder("foo", "bar"); + } + + @Test + void watchAllNamespacesCanBeRequestedExplicitly() { + var loader = + new ConfigLoader( + mapProvider( + Map.of("josdk.controller.ctrl.namespaces", Constants.WATCH_ALL_NAMESPACES))); + var overrider = + io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider.override( + baseControllerConfig()); + loader + .applyControllerConfigs("ctrl") + .accept(overrider); + assertThat(overrider.build().getInformerConfig().watchAllNamespaces()).isTrue(); + } + + @Test + void watchCurrentNamespaceCanBeRequested() { + var loader = + new ConfigLoader( + mapProvider( + Map.of("josdk.controller.ctrl.namespaces", Constants.WATCH_CURRENT_NAMESPACE))); + var overrider = + io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider.override( + baseControllerConfig()); + loader + .applyControllerConfigs("ctrl") + .accept(overrider); + assertThat(overrider.build().getInformerConfig().watchCurrentNamespace()).isTrue(); + } + + @Test + void specialNamespaceValueCannotBeCombinedWithOthers() { + var loader = + new ConfigLoader( + mapProvider( + Map.of( + "josdk.controller.ctrl.namespaces", Constants.WATCH_ALL_NAMESPACES + ",foo"))); + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> loader.applyControllerConfigs("ctrl")) + .withMessageContaining("josdk.controller.ctrl.namespaces"); + } + + @Test + void blankNamespacesValueIsRejected() { + var loader = new ConfigLoader(mapProvider(Map.of("josdk.controller.ctrl.namespaces", " , "))); + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> loader.applyControllerConfigs("ctrl")) + .withMessageContaining("at least one namespace"); + } + + @Test + void namespacesAreIsolatedPerControllerName() { + var values = new HashMap(); + values.put("josdk.controller.alpha.namespaces", "alpha-ns"); + values.put("josdk.controller.beta.namespaces", "beta-ns1,beta-ns2"); + var loader = new ConfigLoader(mapProvider(values)); + + assertThat(applyAndGetNamespaces(loader.applyControllerConfigs("alpha"))) + .containsExactlyInAnyOrder("alpha-ns"); + assertThat(applyAndGetNamespaces(loader.applyControllerConfigs("beta"))) + .containsExactlyInAnyOrder("beta-ns1", "beta-ns2"); + } + private static boolean isTypeCompatible(Class methodParam, Class bindingType) { if (methodParam == bindingType) return true; if (methodParam == boolean.class && bindingType == Boolean.class) return true; From 7470a8a623ae9e3cc4e52e4894dbef5280e5c174 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20M=C3=A9sz=C3=A1ros?= Date: Fri, 11 Sep 2026 15:07:21 +0200 Subject: [PATCH 2/2] review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Attila Mészáros --- .../operator/config/loader/ConfigLoader.java | 3 +++ .../config/loader/ConfigLoaderTest.java | 26 +++++-------------- 2 files changed, 10 insertions(+), 19 deletions(-) diff --git a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java index 8f2e4240a1..3d0eb3abfe 100644 --- a/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java +++ b/operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java @@ -331,6 +331,9 @@ Consumer> buildNamespacesConsumer(String pre if (value.isEmpty()) { return null; } + if (value.get().isBlank()) { + return null; + } final var namespaces = Arrays.stream(value.get().split(NAMESPACES_SEPARATOR)) diff --git a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java index eb926fc664..e3e3d51be8 100644 --- a/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java +++ b/operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java @@ -434,12 +434,9 @@ private static class DummyReconciler private static io.javaoperatorsdk.operator.processing.retry.GenericRetry applyAndGetRetry( java.util.function.Consumer< - io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider< - io.fabric8.kubernetes.api.model.ConfigMap>> + ControllerConfigurationOverrider> consumer) { - var overrider = - io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider.override( - baseControllerConfig()); + var overrider = ControllerConfigurationOverrider.override(baseControllerConfig()); consumer.accept(overrider); return (io.javaoperatorsdk.operator.processing.retry.GenericRetry) overrider.build().getRetry(); } @@ -448,9 +445,7 @@ private static io.javaoperatorsdk.operator.processing.retry.GenericRetry applyAn void retryIsNotConfiguredWhenNoRetryPropertiesPresent() { var loader = new ConfigLoader(mapProvider(Map.of())); var consumer = loader.applyControllerConfigs("ctrl"); - var overrider = - io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider.override( - baseControllerConfig()); + var overrider = ControllerConfigurationOverrider.override(baseControllerConfig()); consumer.accept(overrider); // no retry property set → retry stays at the controller's default (null or unchanged) var result = overrider.build(); @@ -564,12 +559,9 @@ void retryIsIsolatedPerControllerName() { private static Set applyAndGetNamespaces( java.util.function.Consumer< - io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider< - io.fabric8.kubernetes.api.model.ConfigMap>> + ControllerConfigurationOverrider> consumer) { - var overrider = - io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider.override( - baseControllerConfig()); + var overrider = ControllerConfigurationOverrider.override(baseControllerConfig()); consumer.accept(overrider); return overrider.build().getInformerConfig().getNamespaces(); } @@ -610,9 +602,7 @@ void watchAllNamespacesCanBeRequestedExplicitly() { new ConfigLoader( mapProvider( Map.of("josdk.controller.ctrl.namespaces", Constants.WATCH_ALL_NAMESPACES))); - var overrider = - io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider.override( - baseControllerConfig()); + var overrider = ControllerConfigurationOverrider.override(baseControllerConfig()); loader .applyControllerConfigs("ctrl") .accept(overrider); @@ -625,9 +615,7 @@ void watchCurrentNamespaceCanBeRequested() { new ConfigLoader( mapProvider( Map.of("josdk.controller.ctrl.namespaces", Constants.WATCH_CURRENT_NAMESPACE))); - var overrider = - io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider.override( - baseControllerConfig()); + var overrider = ControllerConfigurationOverrider.override(baseControllerConfig()); loader .applyControllerConfigs("ctrl") .accept(overrider);