Skip to content
Merged
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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,14 @@ Reconciler properties [here](./starter/src/main/java/io/javaoperatorsdk/operator
You can provide own implementation instead of the by default provided beans,
life for the [Fabric8 client](https://github.com/operator-framework/josdk-spring-boot-starter/blob/main/starter/src/main/java/io/javaoperatorsdk/operator/springboot/starter/OperatorAutoConfiguration.java#L50)
but also the [Operator instance](https://github.com/operator-framework/josdk-spring-boot-starter/blob/main/starter/src/main/java/io/javaoperatorsdk/operator/springboot/starter/OperatorAutoConfiguration.java#L94).

By default, managed dependent resources are created through Spring's `AutowireCapableBeanFactory`, so they can
receive Spring-managed dependencies (e.g. via constructor injection) instead of requiring a no-arg constructor.
Because of this, dependent resources also go through the same lifecycle as any other Spring bean: fields annotated
with `@Autowired`/`@Value` are populated, `@PostConstruct` methods run, and matching AOP advice wraps the instance in
a proxy - this differs from a plain no-arg-constructed instance, so double-check any existing dependent resource that
relies on annotation-driven injection being a no-op, or on being constructed without side effects.

You can provide your own [DependentResourceFactory](https://github.com/operator-framework/josdk-spring-boot-starter/blob/main/starter/src/main/java/io/javaoperatorsdk/operator/springboot/starter/OperatorAutoConfiguration.java#L173)
bean to override this default behavior, or set `javaoperatorsdk.dependent-resources.spring-managed=false` to fall
back to Java Operator SDK's own no-arg-constructor default.
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.config.AutowireCapableBeanFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
Expand All @@ -34,6 +37,7 @@
import io.javaoperatorsdk.operator.api.config.ResourceClassResolver;
import io.javaoperatorsdk.operator.api.monitoring.Metrics;
import io.javaoperatorsdk.operator.api.reconciler.Reconciler;
import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResourceFactory;
import io.javaoperatorsdk.operator.processing.retry.GenericRetry;
import io.javaoperatorsdk.operator.springboot.starter.CRDApplier.CRDTransformer;
import io.javaoperatorsdk.operator.springboot.starter.CRDApplier.DefaultCRDApplier;
Expand Down Expand Up @@ -166,6 +170,30 @@ public Consumer<ConfigurationServiceOverrider> defaultConfigServiceOverrider(
};
}

@Bean
@ConditionalOnMissingBean(DependentResourceFactory.class)
@ConditionalOnProperty(
prefix = "javaoperatorsdk.dependent-resources",
name = "spring-managed",
havingValue = "true",
matchIfMissing = true)
public DependentResourceFactory<?, ?> dependentResourceFactory(
AutowireCapableBeanFactory beanFactory) {
return new SpringDependentResourceFactory(beanFactory);
}

@Bean
@Order(1)
@ConditionalOnBean(DependentResourceFactory.class)
public Consumer<ConfigurationServiceOverrider> dependentResourceFactoryConfigServiceOverrider(
ObjectProvider<DependentResourceFactory<?, ?>> dependentResourceFactory) {
// ifAvailable() still throws NoUniqueBeanDefinitionException when multiple non-primary
// factories are present (same as a direct injection would); ifUnique() degrades gracefully
// instead, leaving JOSDK's own default factory in place if the user's beans are ambiguous.
return overrider -> dependentResourceFactory
.ifUnique(overrider::withDependentResourceFactory);
}

private void overrideFromProps(ControllerConfigurationOverrider<?> overrider,
ReconcilerProperties props) {
if (props != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public class OperatorConfigurationProperties {
private Boolean ssaBasedCreateUpdateMatchForDependentResources;
private Boolean useSSAToPatchPrimaryResource;
private Boolean cloneSecondaryResourcesWhenGettingFromCache;
private DependentResourcesProperties dependentResources = new DependentResourcesProperties();

public KubernetesClientProperties getClient() {
return client;
Expand Down Expand Up @@ -152,6 +153,14 @@ public void setCrd(CrdProperties crd) {
this.crd = crd;
}

public DependentResourcesProperties getDependentResources() {
return dependentResources;
}

public void setDependentResources(DependentResourcesProperties dependentResources) {
this.dependentResources = dependentResources;
}

public static class CrdProperties {

private boolean applyOnStartup;
Expand Down Expand Up @@ -188,4 +197,24 @@ public void setSuffix(String suffix) {
this.suffix = suffix;
}
}

public static class DependentResourcesProperties {

/**
* Whether managed dependent resources should be created through Spring's
* {@code AutowireCapableBeanFactory} (default), so that they can receive Spring-managed
* dependencies. Set to {@code false} to fall back to Java Operator SDK's default behavior,
* which only requires a no-arg constructor and does not run Spring's dependency injection or
* bean lifecycle callbacks (e.g. {@code @PostConstruct}) on dependent resource instances.
*/
private boolean springManaged = true;

public boolean isSpringManaged() {
return springManaged;
}

public void setSpringManaged(boolean springManaged) {
this.springManaged = springManaged;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package io.javaoperatorsdk.operator.springboot.starter;

import org.springframework.beans.factory.config.AutowireCapableBeanFactory;

import io.javaoperatorsdk.operator.api.config.ControllerConfiguration;
import io.javaoperatorsdk.operator.api.config.dependent.DependentResourceSpec;
import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource;
import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResourceFactory;

/**
* {@link DependentResourceFactory} that creates managed dependent resource instances through
* Spring's {@link AutowireCapableBeanFactory}, instead of relying on a no-arg constructor. This
* allows managed dependent resources to receive Spring-managed dependencies, including via
* constructor injection, while still going through standard bean post-processing.
* <p>
* The created instances are not registered as named beans in the application context: each managed
* dependent resource keeps the lifecycle expected by Java Operator SDK instead of becoming an
* application-wide singleton.
*/
public class SpringDependentResourceFactory
implements DependentResourceFactory<ControllerConfiguration<?>, DependentResourceSpec> {

private final AutowireCapableBeanFactory beanFactory;

public SpringDependentResourceFactory(AutowireCapableBeanFactory beanFactory) {
this.beanFactory = beanFactory;
}

@Override
public DependentResource createFrom(DependentResourceSpec spec,
ControllerConfiguration<?> controllerConfiguration) {
final DependentResource instance =
(DependentResource) beanFactory.createBean(spec.getDependentResourceClass());
configure(instance, spec, controllerConfiguration);
return instance;
}

@Override
public Class<?> associatedResourceType(DependentResourceSpec spec) {
// Falling back to the reflection-based default (DependentResourceFactory.super) isn't an
// option here: it requires a no-arg constructor, which constructor-injected dependent
// resources - the whole point of this factory - don't have. So a throwaway instance still
// has to be created through Spring, but unlike AutowireCapableBeanFactory.createBean(Class),
// it must also be destroyed afterward, since the contract puts destruction on the caller and
// JOSDK never gets a reference to this instance to do so itself.
final DependentResource instance =
(DependentResource) beanFactory.createBean(spec.getDependentResourceClass());
try {
return instance.resourceType();
} finally {
beanFactory.destroyBean(instance);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@ public void beansCreated() {
assertNotNull(compositeConfigurationServiceOverrider);
}

@Test
public void createsSpringDependentResourceFactoryByDefault() {
assertThat(operator.getConfigurationService().dependentResourceFactory())
.isInstanceOf(SpringDependentResourceFactory.class);
}

@Test
public void reconcilersAreDiscovered() {
assertEquals(1, reconcilers.size());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package io.javaoperatorsdk.operator.springboot.starter;

import java.util.function.Consumer;

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;

import io.javaoperatorsdk.operator.Operator;
import io.javaoperatorsdk.operator.api.config.ConfigurationServiceOverrider;
import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResourceFactory;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;

class DependentResourceFactoryConfigurationTest {

private static final ApplicationContextRunner runner = new ApplicationContextRunner()
.withUserConfiguration(OperatorAutoConfiguration.class)
.withBean(Operator.class, () -> mock(Operator.class));

@Test
void createsSpringDependentResourceFactoryByDefault() {
runner.run(ctx -> assertThat(ctx)
.getBean(DependentResourceFactory.class)
.isInstanceOf(SpringDependentResourceFactory.class));
}

@Test
void userProvidedDependentResourceFactoryTakesPrecedence() {
DependentResourceFactory<?, ?> custom = mock(DependentResourceFactory.class);

runner.withBean("customDependentResourceFactory", DependentResourceFactory.class, () -> custom)
.run(ctx -> assertThat(ctx)
.getBean(DependentResourceFactory.class)
.isSameAs(custom));
}

@Test
void doesNotCreateSpringDependentResourceFactoryWhenDisabledByProperty() {
// with no factory bean to fall back on, JOSDK keeps using its own reflection-based default.
runner.withPropertyValues("javaoperatorsdk.dependent-resources.spring-managed=false")
.run(ctx -> assertThat(ctx).doesNotHaveBean(DependentResourceFactory.class));
}

@Test
void userProvidedDependentResourceFactoryStillAppliesWhenDisabledByProperty() {
DependentResourceFactory<?, ?> custom = mock(DependentResourceFactory.class);

runner.withPropertyValues("javaoperatorsdk.dependent-resources.spring-managed=false")
.withBean("customDependentResourceFactory", DependentResourceFactory.class, () -> custom)
.run(ctx -> assertThat(ctx)
.getBean(DependentResourceFactory.class)
.isSameAs(custom));
}

@Test
@SuppressWarnings("unchecked")
void ambiguousUserProvidedFactoriesAreLeftForJosdksOwnDefaultInsteadOfFailing() {
// two non-primary DependentResourceFactory beans are ambiguous; applying the overrider must
// not try to resolve one of them (that would throw NoUniqueBeanDefinitionException) and
// should instead leave JOSDK's own default factory in place.
DependentResourceFactory<?, ?> first = mock(DependentResourceFactory.class);
DependentResourceFactory<?, ?> second = mock(DependentResourceFactory.class);

runner.withBean("firstDependentResourceFactory", DependentResourceFactory.class, () -> first)
.withBean("secondDependentResourceFactory", DependentResourceFactory.class, () -> second)
.run(ctx -> {
Consumer<ConfigurationServiceOverrider> overriderConsumer =
(Consumer<ConfigurationServiceOverrider>) ctx
.getBean("dependentResourceFactoryConfigServiceOverrider");
ConfigurationServiceOverrider overrider = mock(ConfigurationServiceOverrider.class);

assertThatCode(() -> overriderConsumer.accept(overrider)).doesNotThrowAnyException();
verify(overrider, never()).withDependentResourceFactory(any());
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package io.javaoperatorsdk.operator.springboot.starter;

public interface GreetingService {

String greeting();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package io.javaoperatorsdk.operator.springboot.starter;

import io.fabric8.kubernetes.api.model.ConfigMap;
import io.fabric8.kubernetes.api.model.ConfigMapBuilder;
import io.javaoperatorsdk.operator.api.reconciler.Context;
import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource;
import io.javaoperatorsdk.operator.springboot.starter.model.TestResource;

public class NoArgConstructorDependentResource
extends CRUDKubernetesDependentResource<ConfigMap, TestResource> {

public NoArgConstructorDependentResource() {
super(ConfigMap.class);
}

@Override
protected ConfigMap desired(TestResource primary, Context<TestResource> context) {
return new ConfigMapBuilder()
.withNewMetadata()
.withName(primary.getMetadata().getName() + "-no-arg")
.withNamespace(primary.getMetadata().getNamespace())
.endMetadata()
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package io.javaoperatorsdk.operator.springboot.starter;

import org.junit.jupiter.api.Test;
import org.mockito.InOrder;
import org.springframework.beans.factory.config.AutowireCapableBeanFactory;

import io.javaoperatorsdk.operator.api.config.ControllerConfiguration;
import io.javaoperatorsdk.operator.api.config.dependent.DependentResourceSpec;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;

class SpringDependentResourceFactoryTest {

private final AutowireCapableBeanFactory beanFactory = mock(AutowireCapableBeanFactory.class);
private final SpringDependentResourceFactory factory =
new SpringDependentResourceFactory(beanFactory);

@Test
@SuppressWarnings("unchecked")
void associatedResourceTypeDestroysTheThrowawayInstanceItCreated() {
DependentResourceSpec spec = mock(DependentResourceSpec.class);
when(spec.getDependentResourceClass())
.thenReturn((Class) NoArgConstructorDependentResource.class);
NoArgConstructorDependentResource instance = new NoArgConstructorDependentResource();
when(beanFactory.createBean(NoArgConstructorDependentResource.class)).thenReturn(instance);

Class<?> resourceType = factory.associatedResourceType(spec);

assertThat(resourceType).isEqualTo(instance.resourceType());
InOrder inOrder = inOrder(beanFactory);
inOrder.verify(beanFactory).createBean(NoArgConstructorDependentResource.class);
inOrder.verify(beanFactory).destroyBean(instance);
verifyNoMoreInteractions(beanFactory);
}

@Test
@SuppressWarnings("unchecked")
void associatedResourceTypeDestroysTheThrowawayInstanceEvenOnFailure() {
DependentResourceSpec spec = mock(DependentResourceSpec.class);
when(spec.getDependentResourceClass())
.thenReturn((Class) NoArgConstructorDependentResource.class);
NoArgConstructorDependentResource instance = mock(NoArgConstructorDependentResource.class);
when(beanFactory.createBean(NoArgConstructorDependentResource.class)).thenReturn(instance);
when(instance.resourceType()).thenThrow(new IllegalStateException("boom"));

org.junit.jupiter.api.function.Executable call = () -> factory.associatedResourceType(spec);

org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, call);
verify(beanFactory).destroyBean(instance);
}

@Test
@SuppressWarnings("unchecked")
void createFromDoesNotDestroyTheReturnedInstance() {
DependentResourceSpec spec = mock(DependentResourceSpec.class);
when(spec.getDependentResourceClass())
.thenReturn((Class) NoArgConstructorDependentResource.class);
NoArgConstructorDependentResource instance = new NoArgConstructorDependentResource();
when(beanFactory.createBean(NoArgConstructorDependentResource.class)).thenReturn(instance);
ControllerConfiguration<?> controllerConfiguration = mock(ControllerConfiguration.class);

var created = factory.createFrom(spec, controllerConfiguration);

assertThat(created).isSameAs(instance);
verify(beanFactory).createBean(NoArgConstructorDependentResource.class);
verify(beanFactory, org.mockito.Mockito.never()).destroyBean(any());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package io.javaoperatorsdk.operator.springboot.starter;

import io.javaoperatorsdk.operator.api.reconciler.*;
import io.javaoperatorsdk.operator.api.reconciler.dependent.Dependent;
import io.javaoperatorsdk.operator.springboot.starter.model.TestResource;

@ControllerConfiguration
@Workflow(dependents = {
@Dependent(type = SpringManagedDependentResource.class),
@Dependent(type = NoArgConstructorDependentResource.class)
})
public class SpringManagedDependentReconciler implements Reconciler<TestResource> {

@Override
public UpdateControl<TestResource> reconcile(TestResource testResource,
Context<TestResource> context) {
return UpdateControl.noUpdate();
}
}
Loading