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..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 @@ -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,48 @@ 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; + } + if (value.get().isBlank()) { + 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..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 @@ -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"); } @@ -432,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(); } @@ -446,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(); @@ -558,6 +555,106 @@ void retryIsIsolatedPerControllerName() { assertThat(betaRetry.getMaxAttempts()).isEqualTo(9); } + // -- watched namespaces ----------------------------------------------------- + + private static Set applyAndGetNamespaces( + java.util.function.Consumer< + ControllerConfigurationOverrider> + consumer) { + var overrider = 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 = 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 = 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;