Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion docs/content/en/docs/documentation/operations/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -349,6 +351,31 @@ All controller-level keys are prefixed with `josdk.controller.<controller-name>.
| `josdk.controller.<name>.field-manager` | `String` | Field manager name used for SSA operations |
| `josdk.controller.<name>.trigger-reconciler-on-all-events` | `Boolean` | Trigger reconciliation on every event, not only meaningful changes |

#### Watched Namespaces

| Key | Type | Description |
|---|---|---|
| `josdk.controller.<name>.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 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -226,6 +238,10 @@ Consumer<ControllerConfigurationOverrider<R>> applyControllerConfigs(String cont
(List<ConfigBinding<ControllerConfigurationOverrider<R>, ?>>) (List<?>) CONTROLLER_BINDINGS;
Consumer<ControllerConfigurationOverrider<R>> consumer = buildConsumer(bindings, prefix);

Consumer<ControllerConfigurationOverrider<R>> namespacesStep = buildNamespacesConsumer(prefix);
if (namespacesStep != null) {
consumer = consumer.andThen(namespacesStep);
}
Consumer<ControllerConfigurationOverrider<R>> retryStep = buildRetryConsumer(prefix);
if (retryStep != null) {
consumer = consumer == null ? retryStep : consumer.andThen(retryStep);
Expand Down Expand Up @@ -297,6 +313,48 @@ Consumer<ControllerConfigurationOverrider<R>> 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 <R extends HasMetadata>
Consumer<ControllerConfigurationOverrider<R>> buildNamespacesConsumer(String prefix) {
final var key = prefix + NAMESPACES_SUFFIX;
final var value = configProvider.getValue(key, String.class);
if (value.isEmpty()) {
Comment thread
csviri marked this conversation as resolved.
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>.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<ControllerConfigurationOverrider>)
(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();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -218,6 +219,7 @@ public <T> Optional<T> getValue(String key, Class<T> 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");
}
Expand Down Expand Up @@ -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<io.fabric8.kubernetes.api.model.ConfigMap>>
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();
}
Expand All @@ -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.<io.fabric8.kubernetes.api.model.ConfigMap>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();
Expand Down Expand Up @@ -558,6 +555,106 @@ void retryIsIsolatedPerControllerName() {
assertThat(betaRetry.getMaxAttempts()).isEqualTo(9);
}

// -- watched namespaces -----------------------------------------------------

private static Set<String> applyAndGetNamespaces(
java.util.function.Consumer<
ControllerConfigurationOverrider<io.fabric8.kubernetes.api.model.ConfigMap>>
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
.<io.fabric8.kubernetes.api.model.ConfigMap>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
.<io.fabric8.kubernetes.api.model.ConfigMap>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<String, Object>();
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;
Expand Down
Loading