Skip to content

Commit 9080d2f

Browse files
committed
feat: config property to set watched namespaces
Adds `josdk.controller.<name>.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 <a_meszaros@apple.com>
1 parent 748f3a3 commit 9080d2f

4 files changed

Lines changed: 237 additions & 1 deletion

File tree

‎docs/content/en/docs/documentation/operations/configuration.md‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@ operator.register(reconciler, configOverrider ->
4040
## Dynamically Changing Target Namespaces
4141

4242
A controller can be configured to watch a specific set of namespaces in addition of the
43-
namespace in which it is currently deployed or the whole cluster. The framework supports
43+
namespace in which it is currently deployed or the whole cluster. The initial set can be provided
44+
programmatically, via the `@Informer` annotation, or read from an external configuration source (see
45+
[the `namespaces` property](#watched-namespaces)). The framework supports
4446
dynamically changing the list of these namespaces while the operator is running.
4547
When a reconciler is registered, an instance of
4648
[`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.<controller-name>.
349351
| `josdk.controller.<name>.field-manager` | `String` | Field manager name used for SSA operations |
350352
| `josdk.controller.<name>.trigger-reconciler-on-all-events` | `Boolean` | Trigger reconciliation on every event, not only meaningful changes |
351353

354+
#### Watched Namespaces
355+
356+
| Key | Type | Description |
357+
|---|---|---|
358+
| `josdk.controller.<name>.namespaces` | `String` | Comma-separated list of namespaces the controller watches |
359+
360+
Entries are trimmed and blank ones are ignored, so `ns1, ns2` and `ns1,ns2` are equivalent. Instead
361+
of a list of namespaces, the value can also be one of the two special values below, which have to be
362+
used on their own — combining them with a namespace name is an error:
363+
364+
| Value | Meaning |
365+
|---|---|
366+
| `JOSDK_ALL_NAMESPACES` | Watch the whole cluster (the default) |
367+
| `JOSDK_WATCH_CURRENT` | Watch only the namespace the operator is deployed in |
368+
369+
Setting this property is equivalent to calling `settingNamespaces` on
370+
`ControllerConfigurationOverrider` and therefore replaces, rather than extends, the namespaces
371+
configured via the `@Informer` annotation. The set of watched namespaces can still be changed while
372+
the operator is running, see
373+
[Dynamically Changing Target Namespaces](#dynamically-changing-target-namespaces).
374+
375+
```properties
376+
josdk.controller.mycontroller.namespaces=team-a,team-b
377+
```
378+
352379
#### Informer
353380

354381
| Key | Type | Description |

‎operator-framework/src/main/java/io/javaoperatorsdk/operator/config/loader/ConfigLoader.java‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,12 @@
1616
package io.javaoperatorsdk.operator.config.loader;
1717

1818
import java.time.Duration;
19+
import java.util.Arrays;
20+
import java.util.LinkedHashSet;
1921
import java.util.List;
2022
import java.util.Optional;
2123
import java.util.function.Consumer;
24+
import java.util.stream.Collectors;
2225

2326
import org.slf4j.Logger;
2427
import org.slf4j.LoggerFactory;
@@ -27,6 +30,7 @@
2730
import io.javaoperatorsdk.operator.api.config.ConfigurationServiceOverrider;
2831
import io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider;
2932
import io.javaoperatorsdk.operator.api.config.LeaderElectionConfigurationBuilder;
33+
import io.javaoperatorsdk.operator.api.config.informer.InformerConfiguration;
3034
import io.javaoperatorsdk.operator.config.loader.provider.AggregatePriorityListConfigProvider;
3135
import io.javaoperatorsdk.operator.config.loader.provider.EnvVarConfigProvider;
3236
import io.javaoperatorsdk.operator.config.loader.provider.PropertiesConfigProvider;
@@ -132,6 +136,14 @@ public static ConfigLoader getDefault() {
132136
static final String RATE_LIMITER_REFRESH_PERIOD_SUFFIX = "rate-limiter.refresh-period";
133137
static final String RATE_LIMITER_LIMIT_FOR_PERIOD_SUFFIX = "rate-limiter.limit-for-period";
134138

139+
// ---------------------------------------------------------------------------
140+
// Controller-level watched namespaces property suffix. Not a plain binding since the value is a
141+
// set of namespaces, expressed as a comma-separated list.
142+
// ---------------------------------------------------------------------------
143+
static final String NAMESPACES_SUFFIX = "namespaces";
144+
145+
private static final String NAMESPACES_SEPARATOR = ",";
146+
135147
// ---------------------------------------------------------------------------
136148
// Controller-level (ControllerConfigurationOverrider) bindings
137149
// The key used at runtime is built as:
@@ -226,6 +238,10 @@ Consumer<ControllerConfigurationOverrider<R>> applyControllerConfigs(String cont
226238
(List<ConfigBinding<ControllerConfigurationOverrider<R>, ?>>) (List<?>) CONTROLLER_BINDINGS;
227239
Consumer<ControllerConfigurationOverrider<R>> consumer = buildConsumer(bindings, prefix);
228240

241+
Consumer<ControllerConfigurationOverrider<R>> namespacesStep = buildNamespacesConsumer(prefix);
242+
if (namespacesStep != null) {
243+
consumer = consumer.andThen(namespacesStep);
244+
}
229245
Consumer<ControllerConfigurationOverrider<R>> retryStep = buildRetryConsumer(prefix);
230246
if (retryStep != null) {
231247
consumer = consumer == null ? retryStep : consumer.andThen(retryStep);
@@ -297,6 +313,45 @@ Consumer<ControllerConfigurationOverrider<R>> buildRateLimiterConsumer(String pr
297313
};
298314
}
299315

316+
/**
317+
* If the {@code namespaces} property is present, returns a {@link Consumer} that sets the
318+
* namespaces watched by the controller to the comma-separated list it holds. Entries are trimmed
319+
* and blank ones are ignored. The special values {@link
320+
* io.javaoperatorsdk.operator.api.reconciler.Constants#WATCH_ALL_NAMESPACES} and {@link
321+
* io.javaoperatorsdk.operator.api.reconciler.Constants#WATCH_CURRENT_NAMESPACE} are supported but
322+
* can only be used on their own. Returns {@code null} when the property is not present.
323+
*
324+
* @throws IllegalArgumentException if the property is present but does not resolve to a valid set
325+
* of namespaces
326+
*/
327+
private <R extends HasMetadata>
328+
Consumer<ControllerConfigurationOverrider<R>> buildNamespacesConsumer(String prefix) {
329+
final var key = prefix + NAMESPACES_SUFFIX;
330+
final var value = configProvider.getValue(key, String.class);
331+
if (value.isEmpty()) {
332+
return null;
333+
}
334+
335+
final var namespaces =
336+
Arrays.stream(value.get().split(NAMESPACES_SEPARATOR))
337+
.map(String::trim)
338+
.filter(namespace -> !namespace.isEmpty())
339+
.collect(Collectors.toCollection(LinkedHashSet::new));
340+
if (namespaces.isEmpty()) {
341+
throw new IllegalArgumentException(key + " must list at least one namespace");
342+
}
343+
try {
344+
InformerConfiguration.failIfNotValid(namespaces);
345+
} catch (IllegalArgumentException e) {
346+
throw new IllegalArgumentException("Invalid value for " + key + ": " + value.get(), e);
347+
}
348+
349+
return overrider -> {
350+
log.debug("Found config property: {} = {}", key, value.get());
351+
overrider.settingNamespaces(namespaces);
352+
};
353+
}
354+
300355
/**
301356
* If leader election is explicitly disabled via {@code leader-election.enabled=false}, returns
302357
* {@code null}. Otherwise, if at least one leader-election property is present (with {@code

‎operator-framework/src/test/java/io/javaoperatorsdk/operator/baseapi/configloader/ConfigLoaderIT.java‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,4 +143,49 @@ private ConfigLoaderTestCustomResource createResource(String id) {
143143
return resource;
144144
}
145145
}
146+
147+
// ---------------------------------------------------------------------------
148+
// Controller-level watched namespaces
149+
// ---------------------------------------------------------------------------
150+
151+
@Nested
152+
class ControllerNamespacesProperty {
153+
154+
// controller name is the lower-cased simple class name by default
155+
static final String CTRL_NAME = ConfigLoaderTestReconciler.class.getSimpleName().toLowerCase();
156+
157+
/**
158+
* Verifies that {@code josdk.controller.<name>.namespaces} read by {@link ConfigLoader}
159+
* replaces the default "watch all namespaces" setting of the registered controller.
160+
*/
161+
@RegisterExtension
162+
LocallyRunOperatorExtension operator =
163+
LocallyRunOperatorExtension.builder()
164+
.withReconciler(
165+
new ConfigLoaderTestReconciler(0),
166+
(Consumer<ControllerConfigurationOverrider>)
167+
(Consumer<?>)
168+
new ConfigLoader(
169+
mapProvider(
170+
Map.of(
171+
"josdk.controller." + CTRL_NAME + ".namespaces",
172+
"default, kube-public")))
173+
.applyControllerConfigs(CTRL_NAME))
174+
.build();
175+
176+
@Test
177+
void watchedNamespacesAreAppliedFromConfigLoader() {
178+
var informerConfig =
179+
operator
180+
.getOperator()
181+
.getRegisteredController(CTRL_NAME)
182+
.orElseThrow()
183+
.getConfiguration()
184+
.getInformerConfig();
185+
186+
assertThat(informerConfig.getNamespaces())
187+
.containsExactlyInAnyOrder("default", "kube-public");
188+
assertThat(informerConfig.watchAllNamespaces()).isFalse();
189+
}
190+
}
146191
}

‎operator-framework/src/test/java/io/javaoperatorsdk/operator/config/loader/ConfigLoaderTest.java‎

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import io.javaoperatorsdk.operator.api.config.ConfigurationService;
3131
import io.javaoperatorsdk.operator.api.config.ConfigurationServiceOverrider;
3232
import io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider;
33+
import io.javaoperatorsdk.operator.api.reconciler.Constants;
3334

3435
import static org.assertj.core.api.Assertions.assertThat;
3536
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
@@ -218,6 +219,7 @@ public <T> Optional<T> getValue(String key, Class<T> type) {
218219
"josdk.controller.ctrl.informer.label-selector",
219220
"josdk.controller.ctrl.informer.shard-selector",
220221
"josdk.controller.ctrl.informer.list-limit",
222+
"josdk.controller.ctrl.namespaces",
221223
"josdk.controller.ctrl.rate-limiter.refresh-period",
222224
"josdk.controller.ctrl.rate-limiter.limit-for-period");
223225
}
@@ -558,6 +560,113 @@ void retryIsIsolatedPerControllerName() {
558560
assertThat(betaRetry.getMaxAttempts()).isEqualTo(9);
559561
}
560562

563+
// -- watched namespaces -----------------------------------------------------
564+
565+
private static Set<String> applyAndGetNamespaces(
566+
java.util.function.Consumer<
567+
io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider<
568+
io.fabric8.kubernetes.api.model.ConfigMap>>
569+
consumer) {
570+
var overrider =
571+
io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider.override(
572+
baseControllerConfig());
573+
consumer.accept(overrider);
574+
return overrider.build().getInformerConfig().getNamespaces();
575+
}
576+
577+
@Test
578+
void namespacesAreLeftUntouchedWhenPropertyIsAbsent() {
579+
var loader = new ConfigLoader(mapProvider(Map.of()));
580+
assertThat(applyAndGetNamespaces(loader.applyControllerConfigs("ctrl")))
581+
.isEqualTo(Constants.DEFAULT_NAMESPACES_SET);
582+
}
583+
584+
@Test
585+
void singleNamespaceIsApplied() {
586+
var loader = new ConfigLoader(mapProvider(Map.of("josdk.controller.ctrl.namespaces", "foo")));
587+
assertThat(applyAndGetNamespaces(loader.applyControllerConfigs("ctrl")))
588+
.containsExactlyInAnyOrder("foo");
589+
}
590+
591+
@Test
592+
void commaSeparatedNamespacesAreApplied() {
593+
var loader =
594+
new ConfigLoader(mapProvider(Map.of("josdk.controller.ctrl.namespaces", "foo,bar,baz")));
595+
assertThat(applyAndGetNamespaces(loader.applyControllerConfigs("ctrl")))
596+
.containsExactlyInAnyOrder("foo", "bar", "baz");
597+
}
598+
599+
@Test
600+
void namespacesAreTrimmedAndBlankEntriesIgnored() {
601+
var loader =
602+
new ConfigLoader(mapProvider(Map.of("josdk.controller.ctrl.namespaces", " foo , ,bar ,")));
603+
assertThat(applyAndGetNamespaces(loader.applyControllerConfigs("ctrl")))
604+
.containsExactlyInAnyOrder("foo", "bar");
605+
}
606+
607+
@Test
608+
void watchAllNamespacesCanBeRequestedExplicitly() {
609+
var loader =
610+
new ConfigLoader(
611+
mapProvider(
612+
Map.of("josdk.controller.ctrl.namespaces", Constants.WATCH_ALL_NAMESPACES)));
613+
var overrider =
614+
io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider.override(
615+
baseControllerConfig());
616+
loader
617+
.<io.fabric8.kubernetes.api.model.ConfigMap>applyControllerConfigs("ctrl")
618+
.accept(overrider);
619+
assertThat(overrider.build().getInformerConfig().watchAllNamespaces()).isTrue();
620+
}
621+
622+
@Test
623+
void watchCurrentNamespaceCanBeRequested() {
624+
var loader =
625+
new ConfigLoader(
626+
mapProvider(
627+
Map.of("josdk.controller.ctrl.namespaces", Constants.WATCH_CURRENT_NAMESPACE)));
628+
var overrider =
629+
io.javaoperatorsdk.operator.api.config.ControllerConfigurationOverrider.override(
630+
baseControllerConfig());
631+
loader
632+
.<io.fabric8.kubernetes.api.model.ConfigMap>applyControllerConfigs("ctrl")
633+
.accept(overrider);
634+
assertThat(overrider.build().getInformerConfig().watchCurrentNamespace()).isTrue();
635+
}
636+
637+
@Test
638+
void specialNamespaceValueCannotBeCombinedWithOthers() {
639+
var loader =
640+
new ConfigLoader(
641+
mapProvider(
642+
Map.of(
643+
"josdk.controller.ctrl.namespaces", Constants.WATCH_ALL_NAMESPACES + ",foo")));
644+
assertThatExceptionOfType(IllegalArgumentException.class)
645+
.isThrownBy(() -> loader.applyControllerConfigs("ctrl"))
646+
.withMessageContaining("josdk.controller.ctrl.namespaces");
647+
}
648+
649+
@Test
650+
void blankNamespacesValueIsRejected() {
651+
var loader = new ConfigLoader(mapProvider(Map.of("josdk.controller.ctrl.namespaces", " , ")));
652+
assertThatExceptionOfType(IllegalArgumentException.class)
653+
.isThrownBy(() -> loader.applyControllerConfigs("ctrl"))
654+
.withMessageContaining("at least one namespace");
655+
}
656+
657+
@Test
658+
void namespacesAreIsolatedPerControllerName() {
659+
var values = new HashMap<String, Object>();
660+
values.put("josdk.controller.alpha.namespaces", "alpha-ns");
661+
values.put("josdk.controller.beta.namespaces", "beta-ns1,beta-ns2");
662+
var loader = new ConfigLoader(mapProvider(values));
663+
664+
assertThat(applyAndGetNamespaces(loader.applyControllerConfigs("alpha")))
665+
.containsExactlyInAnyOrder("alpha-ns");
666+
assertThat(applyAndGetNamespaces(loader.applyControllerConfigs("beta")))
667+
.containsExactlyInAnyOrder("beta-ns1", "beta-ns2");
668+
}
669+
561670
private static boolean isTypeCompatible(Class<?> methodParam, Class<?> bindingType) {
562671
if (methodParam == bindingType) return true;
563672
if (methodParam == boolean.class && bindingType == Boolean.class) return true;

0 commit comments

Comments
 (0)