diff --git a/operator-framework-quarkus-extension/deployment/src/main/java/io/javaoperatorsdk/quarkus/extension/deployment/QuarkusExtensionProcessor.java b/operator-framework-quarkus-extension/deployment/src/main/java/io/javaoperatorsdk/quarkus/extension/deployment/QuarkusExtensionProcessor.java index 02082de02e..446519417b 100644 --- a/operator-framework-quarkus-extension/deployment/src/main/java/io/javaoperatorsdk/quarkus/extension/deployment/QuarkusExtensionProcessor.java +++ b/operator-framework-quarkus-extension/deployment/src/main/java/io/javaoperatorsdk/quarkus/extension/deployment/QuarkusExtensionProcessor.java @@ -6,7 +6,10 @@ import io.javaoperatorsdk.operator.api.ResourceController; import io.javaoperatorsdk.operator.api.config.ConfigurationService; import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; +import io.javaoperatorsdk.operator.api.config.RetryConfiguration; import io.javaoperatorsdk.quarkus.extension.ConfigurationServiceRecorder; +import io.javaoperatorsdk.quarkus.extension.ExternalConfiguration; +import io.javaoperatorsdk.quarkus.extension.ExternalControllerConfiguration; import io.javaoperatorsdk.quarkus.extension.OperatorProducer; import io.javaoperatorsdk.quarkus.extension.QuarkusConfigurationService; import io.javaoperatorsdk.quarkus.extension.QuarkusControllerConfiguration; @@ -49,6 +52,8 @@ class QuarkusExtensionProcessor { throw new IllegalArgumentException(); }; + private ExternalConfiguration externalConfiguration; + @BuildStep void indexSDKDependencies( BuildProducer indexDependency, @@ -110,16 +115,6 @@ private ControllerConfiguration createControllerConfiguration( .setDefaultScope(APPLICATION_SCOPED) .build()); - // generate configuration - final var controllerAnnotation = info.classAnnotation(CONTROLLER); - if (controllerAnnotation == null) { - throw new IllegalArgumentException( - resourceControllerClassName - + " is missing the @" - + Controller.class.getCanonicalName() - + " annotation"); - } - // load CR class final Class crClass = (Class) loadClass(crType); @@ -137,12 +132,20 @@ private ControllerConfiguration createControllerConfiguration( // register CR class for introspection reflectionClasses.produce(new ReflectiveClassBuildItem(true, true, crClass)); + // retrieve the Controller annotation if it exists + final var controllerAnnotation = info.classAnnotation(CONTROLLER); + + // retrieve the controller's name + final var defaultControllerName = + ControllerUtils.getDefaultResourceControllerName(resourceControllerClassName); final var name = - valueOrDefault( - controllerAnnotation, - "name", - AnnotationValue::asString, - () -> ControllerUtils.getDefaultResourceControllerName(resourceControllerClassName)); + annotationValueOrDefault( + controllerAnnotation, "name", AnnotationValue::asString, () -> defaultControllerName); + + // check if we have externalized configuration to provide values + final var extContConfig = externalConfiguration.controllers.get(name); + + final var extractor = new ValueExtractor(controllerAnnotation, extContConfig); // create the configuration final var configuration = @@ -150,25 +153,24 @@ private ControllerConfiguration createControllerConfiguration( resourceControllerClassName, name, crdName, - valueOrDefault( - controllerAnnotation, + extractor.extract( + c -> c.finalizer, "finalizerName", AnnotationValue::asString, () -> ControllerUtils.getDefaultFinalizerName(crdName)), - valueOrDefault( - controllerAnnotation, + extractor.extract( + c -> c.generationAware, "generationAwareEventProcessing", AnnotationValue::asBoolean, () -> true), QuarkusControllerConfiguration.asSet( - valueOrDefault( - controllerAnnotation, + extractor.extract( + c -> c.namespaces.map(l -> l.toArray(new String[0])), "namespaces", AnnotationValue::asStringArray, () -> new String[] {})), crType, - null // todo: fix-me - ); + retryConfiguration(extContConfig)); log.infov( "Processed ''{0}'' controller named ''{1}'' for ''{2}'' CR (version ''{3}'')", @@ -177,12 +179,72 @@ private ControllerConfiguration createControllerConfiguration( return configuration; } - private T valueOrDefault( + private RetryConfiguration retryConfiguration(ExternalControllerConfiguration extConfig) { + return extConfig == null ? null : RetryConfigurationResolver.resolve(extConfig.retry); + } + + private static class ValueExtractor { + + private final AnnotationInstance controllerAnnotation; + private final ExternalControllerConfiguration extContConfig; + + ValueExtractor( + AnnotationInstance controllerAnnotation, ExternalControllerConfiguration extContConfig) { + this.controllerAnnotation = controllerAnnotation; + this.extContConfig = extContConfig; + } + + /** + * Extracts the appropriate configuration value for the controller checking first any annotation + * configuration, then potentially overriding it by a properties-provided value or returning a + * default value if neither is provided. + * + * @param extractor a Function extracting the optional value we're interested in from the + * external configuration + * @param annotationField the name of the {@link Controller} annotation we're want to retrieve + * if present + * @param converter a Function converting the annotation value to the type we're expecting + * @param defaultValue a Supplier that computes/retrieve a default value when needed + * @param the expected type of the configuration value we're trying to extract + * @return the extracted configuration value + */ + T extract( + Function> extractor, + String annotationField, + Function converter, + Supplier defaultValue) { + // first check if we have an external configuration + if (extContConfig != null) { + // extract value from config if present + return extractor + .apply(extContConfig) + // or get from the annotation or default + .orElse(annotationValueOrDefault(annotationField, converter, defaultValue)); + } else { + // get from annotation or default + return annotationValueOrDefault(annotationField, converter, defaultValue); + } + } + + private T annotationValueOrDefault( + String name, Function converter, Supplier defaultValue) { + return QuarkusExtensionProcessor.annotationValueOrDefault( + controllerAnnotation, name, converter, defaultValue); + } + } + + private static T annotationValueOrDefault( AnnotationInstance annotation, String name, Function converter, Supplier defaultValue) { - return Optional.ofNullable(annotation.value(name)).map(converter).orElseGet(defaultValue); + return annotation != null + ? + // get converted annotation value of get default + Optional.ofNullable(annotation.value(name)).map(converter).orElseGet(defaultValue) + : + // get default + defaultValue.get(); } private Class loadClass(String className) { diff --git a/operator-framework-quarkus-extension/deployment/src/main/java/io/javaoperatorsdk/quarkus/extension/deployment/RetryConfigurationResolver.java b/operator-framework-quarkus-extension/deployment/src/main/java/io/javaoperatorsdk/quarkus/extension/deployment/RetryConfigurationResolver.java new file mode 100644 index 0000000000..e94e89a127 --- /dev/null +++ b/operator-framework-quarkus-extension/deployment/src/main/java/io/javaoperatorsdk/quarkus/extension/deployment/RetryConfigurationResolver.java @@ -0,0 +1,102 @@ +package io.javaoperatorsdk.quarkus.extension.deployment; + +import io.javaoperatorsdk.operator.api.config.RetryConfiguration; +import io.javaoperatorsdk.quarkus.extension.ExternalIntervalConfiguration; +import io.javaoperatorsdk.quarkus.extension.ExternalRetryConfiguration; +import io.javaoperatorsdk.quarkus.extension.PlainRetryConfiguration; +import java.util.Optional; + +class RetryConfigurationResolver implements RetryConfiguration { + + private final RetryConfiguration delegate; + + private RetryConfigurationResolver(Optional retry) { + delegate = + retry + .map(ExternalRetryConfigurationAdapter::new) + .orElse(RetryConfiguration.DEFAULT); + } + + public static RetryConfiguration resolve(Optional retry) { + final var delegate = new RetryConfigurationResolver(retry); + return new PlainRetryConfiguration( + delegate.getMaxAttempts(), + delegate.getInitialInterval(), + delegate.getIntervalMultiplier(), + delegate.getMaxInterval()); + } + + @Override + public int getMaxAttempts() { + return delegate.getMaxAttempts(); + } + + @Override + public long getInitialInterval() { + return delegate.getInitialInterval(); + } + + @Override + public double getIntervalMultiplier() { + return delegate.getIntervalMultiplier(); + } + + @Override + public long getMaxInterval() { + return delegate.getMaxInterval(); + } + + private static class ExternalRetryConfigurationAdapter implements RetryConfiguration { + + private final int maxAttempts; + private final IntervalConfigurationAdapter interval; + + public ExternalRetryConfigurationAdapter(ExternalRetryConfiguration config) { + maxAttempts = config.maxAttempts.orElse(RetryConfiguration.DEFAULT.getMaxAttempts()); + interval = + config + .interval + .map(IntervalConfigurationAdapter::new) + .orElse(new IntervalConfigurationAdapter()); + } + + @Override + public int getMaxAttempts() { + return maxAttempts; + } + + @Override + public long getInitialInterval() { + return interval.initial; + } + + @Override + public double getIntervalMultiplier() { + return interval.multiplier; + } + + @Override + public long getMaxInterval() { + return interval.max; + } + } + + private static class IntervalConfigurationAdapter { + + private final long initial; + private final double multiplier; + private final long max; + + IntervalConfigurationAdapter(ExternalIntervalConfiguration config) { + initial = config.initial.orElse(RetryConfiguration.DEFAULT.getInitialInterval()); + multiplier = config.multiplier.orElse(RetryConfiguration.DEFAULT.getIntervalMultiplier()); + max = config.max.orElse(RetryConfiguration.DEFAULT.getMaxInterval()); + } + + IntervalConfigurationAdapter() { + this.initial = RetryConfiguration.DEFAULT.getInitialInterval(); + this.multiplier = RetryConfiguration.DEFAULT.getIntervalMultiplier(); + this.max = RetryConfiguration.DEFAULT.getMaxInterval(); + } + } +} diff --git a/operator-framework-quarkus-extension/runtime/src/main/java/io/javaoperatorsdk/quarkus/extension/ExternalConfiguration.java b/operator-framework-quarkus-extension/runtime/src/main/java/io/javaoperatorsdk/quarkus/extension/ExternalConfiguration.java new file mode 100644 index 0000000000..c5eeba4da7 --- /dev/null +++ b/operator-framework-quarkus-extension/runtime/src/main/java/io/javaoperatorsdk/quarkus/extension/ExternalConfiguration.java @@ -0,0 +1,13 @@ +package io.javaoperatorsdk.quarkus.extension; + +import io.quarkus.runtime.annotations.ConfigItem; +import io.quarkus.runtime.annotations.ConfigPhase; +import io.quarkus.runtime.annotations.ConfigRoot; +import java.util.Map; + +@ConfigRoot(name = "operator-sdk", phase = ConfigPhase.BUILD_AND_RUN_TIME_FIXED) +public class ExternalConfiguration { + + /** Maps a controller name to its configuration. */ + @ConfigItem public Map controllers; +} diff --git a/operator-framework-quarkus-extension/runtime/src/main/java/io/javaoperatorsdk/quarkus/extension/ExternalControllerConfiguration.java b/operator-framework-quarkus-extension/runtime/src/main/java/io/javaoperatorsdk/quarkus/extension/ExternalControllerConfiguration.java new file mode 100644 index 0000000000..b1de70f962 --- /dev/null +++ b/operator-framework-quarkus-extension/runtime/src/main/java/io/javaoperatorsdk/quarkus/extension/ExternalControllerConfiguration.java @@ -0,0 +1,34 @@ +package io.javaoperatorsdk.quarkus.extension; + +import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; +import io.quarkus.runtime.annotations.ConfigGroup; +import io.quarkus.runtime.annotations.ConfigItem; +import java.util.List; +import java.util.Optional; + +@ConfigGroup +public class ExternalControllerConfiguration { + + /** + * An optional list of comma-separated namespace names the controller should watch. If the list + * contains {@link ControllerConfiguration#WATCH_ALL_NAMESPACES_MARKER} then the controller will + * watch all namespaces. + */ + @ConfigItem public Optional> namespaces; + + /** + * The optional name of the finalizer for the controller. If none is provided, one will be + * automatically generated. + */ + @ConfigItem public Optional finalizer; + + /** + * Whether the controller should only process events if the associated resource generation has + * increased since last reconciliation, otherwise will process all events. + */ + @ConfigItem(defaultValue = "true") + public Optional generationAware; + + /** The optional controller retry configuration */ + public Optional retry; +} diff --git a/operator-framework-quarkus-extension/runtime/src/main/java/io/javaoperatorsdk/quarkus/extension/ExternalIntervalConfiguration.java b/operator-framework-quarkus-extension/runtime/src/main/java/io/javaoperatorsdk/quarkus/extension/ExternalIntervalConfiguration.java new file mode 100644 index 0000000000..c983a79b07 --- /dev/null +++ b/operator-framework-quarkus-extension/runtime/src/main/java/io/javaoperatorsdk/quarkus/extension/ExternalIntervalConfiguration.java @@ -0,0 +1,21 @@ +package io.javaoperatorsdk.quarkus.extension; + +import io.quarkus.runtime.annotations.ConfigGroup; +import io.quarkus.runtime.annotations.ConfigItem; +import java.util.Optional; + +@ConfigGroup +public class ExternalIntervalConfiguration { + + /** The initial interval that the controller waits for before attempting the first retry */ + @ConfigItem public Optional initial; + + /** The value by which the initial interval is multiplied by for each retry */ + @ConfigItem public Optional multiplier; + + /** + * The maximum interval that the controller will wait for before attempting a retry, regardless of + * all other configuration + */ + @ConfigItem public Optional max; +} diff --git a/operator-framework-quarkus-extension/runtime/src/main/java/io/javaoperatorsdk/quarkus/extension/ExternalRetryConfiguration.java b/operator-framework-quarkus-extension/runtime/src/main/java/io/javaoperatorsdk/quarkus/extension/ExternalRetryConfiguration.java new file mode 100644 index 0000000000..3e800d2598 --- /dev/null +++ b/operator-framework-quarkus-extension/runtime/src/main/java/io/javaoperatorsdk/quarkus/extension/ExternalRetryConfiguration.java @@ -0,0 +1,15 @@ +package io.javaoperatorsdk.quarkus.extension; + +import io.quarkus.runtime.annotations.ConfigGroup; +import io.quarkus.runtime.annotations.ConfigItem; +import java.util.Optional; + +@ConfigGroup +public class ExternalRetryConfiguration { + + /** How many times an operation should be retried before giving up */ + @ConfigItem public Optional maxAttempts; + + /** The configuration of the retry interval. */ + @ConfigItem public Optional interval; +} diff --git a/operator-framework-quarkus-extension/runtime/src/main/java/io/javaoperatorsdk/quarkus/extension/PlainRetryConfiguration.java b/operator-framework-quarkus-extension/runtime/src/main/java/io/javaoperatorsdk/quarkus/extension/PlainRetryConfiguration.java new file mode 100644 index 0000000000..ca534614a2 --- /dev/null +++ b/operator-framework-quarkus-extension/runtime/src/main/java/io/javaoperatorsdk/quarkus/extension/PlainRetryConfiguration.java @@ -0,0 +1,41 @@ +package io.javaoperatorsdk.quarkus.extension; + +import io.javaoperatorsdk.operator.api.config.RetryConfiguration; +import io.quarkus.runtime.annotations.RecordableConstructor; + +public class PlainRetryConfiguration implements RetryConfiguration { + + private final int max; + private final long initial; + private final double multiplier; + private final long maxInterval; + + @RecordableConstructor + public PlainRetryConfiguration( + int maxAttempts, long initialInterval, double intervalMultiplier, long maxInterval) { + this.max = maxAttempts; + this.initial = initialInterval; + this.multiplier = intervalMultiplier; + this.maxInterval = maxInterval; + } + + @Override + public int getMaxAttempts() { + return max; + } + + @Override + public long getInitialInterval() { + return initial; + } + + @Override + public double getIntervalMultiplier() { + return multiplier; + } + + @Override + public long getMaxInterval() { + return maxInterval; + } +} diff --git a/operator-framework-quarkus-extension/tests/pom.xml b/operator-framework-quarkus-extension/tests/pom.xml index 252cc8ee0b..1beb637c00 100644 --- a/operator-framework-quarkus-extension/tests/pom.xml +++ b/operator-framework-quarkus-extension/tests/pom.xml @@ -59,18 +59,6 @@ true - - io.quarkus - quarkus-maven-plugin - ${quarkus.version} - - - - build - - - - org.apache.maven.plugins maven-surefire-plugin @@ -98,6 +86,18 @@ + + io.quarkus + quarkus-maven-plugin + ${quarkus.version} + + + + build + + + + org.apache.maven.plugins maven-failsafe-plugin diff --git a/operator-framework-quarkus-extension/tests/src/main/java/io/javaoperatorsdk/quarkus/it/ConfiguredController.java b/operator-framework-quarkus-extension/tests/src/main/java/io/javaoperatorsdk/quarkus/it/ConfiguredController.java new file mode 100644 index 0000000000..7e9097bb8f --- /dev/null +++ b/operator-framework-quarkus-extension/tests/src/main/java/io/javaoperatorsdk/quarkus/it/ConfiguredController.java @@ -0,0 +1,24 @@ +package io.javaoperatorsdk.quarkus.it; + +import io.javaoperatorsdk.operator.api.Context; +import io.javaoperatorsdk.operator.api.Controller; +import io.javaoperatorsdk.operator.api.DeleteControl; +import io.javaoperatorsdk.operator.api.ResourceController; +import io.javaoperatorsdk.operator.api.UpdateControl; + +@Controller(name = ConfiguredController.NAME, namespaces = "foo") +public class ConfiguredController implements ResourceController { + + public static final String NAME = "annotation"; + + @Override + public DeleteControl deleteResource(TestResource resource, Context context) { + return null; + } + + @Override + public UpdateControl createOrUpdateResource( + TestResource resource, Context context) { + return null; + } +} diff --git a/operator-framework-quarkus-extension/tests/src/main/java/io/javaoperatorsdk/quarkus/it/TestOperatorApp.java b/operator-framework-quarkus-extension/tests/src/main/java/io/javaoperatorsdk/quarkus/it/TestOperatorApp.java index 9f5538ed32..e65a7a17cf 100644 --- a/operator-framework-quarkus-extension/tests/src/main/java/io/javaoperatorsdk/quarkus/it/TestOperatorApp.java +++ b/operator-framework-quarkus-extension/tests/src/main/java/io/javaoperatorsdk/quarkus/it/TestOperatorApp.java @@ -1,10 +1,12 @@ package io.javaoperatorsdk.quarkus.it; import com.fasterxml.jackson.annotation.JsonProperty; +import io.fabric8.kubernetes.client.CustomResource; +import io.javaoperatorsdk.operator.api.ResourceController; import io.javaoperatorsdk.operator.api.config.ConfigurationService; import io.javaoperatorsdk.operator.api.config.ControllerConfiguration; import io.javaoperatorsdk.operator.api.config.RetryConfiguration; -import java.util.Set; +import javax.enterprise.inject.Instance; import javax.inject.Inject; import javax.ws.rs.GET; import javax.ws.rs.Path; @@ -13,24 +15,26 @@ @Path("/operator") public class TestOperatorApp { - @Inject TestController controller; + @Inject Instance> controllers; @Inject ConfigurationService configurationService; @GET @Path("{name}") - // @Produces(MediaType.TEXT_PLAIN) public boolean getController(@PathParam("name") String name) { - return name.equals(configurationService.getConfigurationFor(controller).getName()); + return configurationService.getKnownControllerNames().contains(name); } @GET @Path("{name}/config") public JSONControllerConfiguration getConfig(@PathParam("name") String name) { - var config = configurationService.getConfigurationFor(controller); - if (config == null) { - return null; - } - return name.equals(config.getName()) ? new JSONControllerConfiguration(config) : null; + final var configuration = + controllers.stream() + .map(c -> configurationService.getConfigurationFor(c)) + .filter(c -> c.getName().equals(name)) + .findFirst() + .map(JSONControllerConfiguration::new) + .orElse(null); + return configuration; } static class JSONControllerConfiguration { @@ -65,8 +69,8 @@ public String getAssociatedControllerClassName() { return conf.getAssociatedControllerClassName(); } - public Set getNamespaces() { - return conf.getNamespaces(); + public String[] getNamespaces() { + return (String[]) conf.getNamespaces().toArray(new String[0]); } public boolean watchAllNamespaces() { diff --git a/operator-framework-quarkus-extension/tests/src/main/resources/application.properties b/operator-framework-quarkus-extension/tests/src/main/resources/application.properties new file mode 100644 index 0000000000..3fa3154e7a --- /dev/null +++ b/operator-framework-quarkus-extension/tests/src/main/resources/application.properties @@ -0,0 +1,2 @@ +quarkus.operator-sdk.controllers.annotation.finalizer=from-property/finalizer +quarkus.operator-sdk.controllers.annotation.namespaces=bar \ No newline at end of file diff --git a/operator-framework-quarkus-extension/tests/src/test/java/io/javaoperatorsdk/quarkus/it/QuarkusExtensionProcessorTest.java b/operator-framework-quarkus-extension/tests/src/test/java/io/javaoperatorsdk/quarkus/it/QuarkusExtensionProcessorTest.java index f75706c829..e01b3d754b 100644 --- a/operator-framework-quarkus-extension/tests/src/test/java/io/javaoperatorsdk/quarkus/it/QuarkusExtensionProcessorTest.java +++ b/operator-framework-quarkus-extension/tests/src/test/java/io/javaoperatorsdk/quarkus/it/QuarkusExtensionProcessorTest.java @@ -2,6 +2,7 @@ import static io.restassured.RestAssured.given; import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasItem; import static org.hamcrest.Matchers.is; import io.quarkus.test.QuarkusProdModeTest; @@ -22,7 +23,11 @@ public class QuarkusExtensionProcessorTest { .setArchiveProducer( () -> ShrinkWrap.create(JavaArchive.class) - .addClasses(TestOperatorApp.class, TestController.class, TestResource.class)) + .addClasses( + TestOperatorApp.class, + TestController.class, + ConfiguredController.class, + TestResource.class)) .setApplicationName("basic-app") .setApplicationVersion("0.1-SNAPSHOT") .setRun(true); @@ -51,4 +56,16 @@ void configurationForControllerShouldExist() { "customResourceClass", equalTo(resourceName), "name", equalTo(TestController.NAME)); } + + @Test + void applicationPropertiesShouldOverrideDefaultAndAnnotation() { + given() + .when() + .get("/operator/" + ConfiguredController.NAME + "/config") + .then() + .statusCode(200) + .body( + "finalizer", equalTo("from-property/finalizer"), + "namespaces", hasItem("bar")); + } }