This is an automated email from the ASF dual-hosted git repository.
bbende pushed a commit to branch NIFI-15258
in repository https://gitbox.apache.org/repos/asf/nifi.git
The following commit(s) were added to refs/heads/NIFI-15258 by this push:
new 0d7d9c8856 NIFI-15598: When enabling referenced controller services in
a connector, skip references from properties with unsatisfied dependencies
(#10897)
0d7d9c8856 is described below
commit 0d7d9c8856e5cbf941514dbd088c51ed500ab2c1
Author: Mark Payne <[email protected]>
AuthorDate: Wed Feb 18 10:25:51 2026 -0500
NIFI-15598: When enabling referenced controller services in a connector,
skip references from properties with unsatisfied dependencies (#10897)
---
.../StandaloneProcessGroupLifecycle.java | 47 ++--
.../TestStandaloneProcessGroupLifecycle.java | 292 +++++++++++++++++++++
2 files changed, 322 insertions(+), 17 deletions(-)
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/facades/standalone/StandaloneProcessGroupLifecycle.java
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/facades/standalone/StandaloneProcessGroupLifecycle.java
index 7567f486e8..004ae629d3 100644
---
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/facades/standalone/StandaloneProcessGroupLifecycle.java
+++
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/facades/standalone/StandaloneProcessGroupLifecycle.java
@@ -18,12 +18,14 @@
package org.apache.nifi.components.connector.facades.standalone;
import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.ValidationContext;
import
org.apache.nifi.components.connector.components.ControllerServiceReferenceHierarchy;
import
org.apache.nifi.components.connector.components.ControllerServiceReferenceScope;
import org.apache.nifi.components.connector.components.ProcessGroupLifecycle;
import org.apache.nifi.components.connector.components.StatelessGroupLifecycle;
import org.apache.nifi.components.validation.ValidationStatus;
import org.apache.nifi.connectable.Port;
+import org.apache.nifi.controller.ComponentNode;
import org.apache.nifi.controller.ProcessorNode;
import org.apache.nifi.controller.ScheduledState;
import org.apache.nifi.controller.service.ControllerServiceNode;
@@ -37,7 +39,9 @@ import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
+import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.function.Function;
@@ -75,16 +79,25 @@ public class StandaloneProcessGroupLifecycle implements
ProcessGroupLifecycle {
private void collectReferencedServices(final ProcessGroup group, final
Set<ControllerServiceNode> referencedServices, final boolean recursive) {
for (final ProcessorNode processor : group.getProcessors()) {
+ final ValidationContext validationContext =
createValidationContext(processor);
+
for (final PropertyDescriptor descriptor :
processor.getPropertyDescriptors()) {
if (descriptor.getControllerServiceDefinition() == null) {
continue;
}
- final String serviceId =
processor.getProperty(descriptor).getEffectiveValue(group.getParameterContext());
+ final String serviceId =
processor.getEffectivePropertyValue(descriptor);
if (serviceId == null) {
continue;
}
+ // Skip properties whose dependencies are not satisfied, as
the property is not relevant to the component's functionality
+ if (!validationContext.isDependencySatisfied(descriptor,
processor::getPropertyDescriptor)) {
+ logger.debug("Not enabling Controller Service {} because
it is referenced by {} property of {} whose dependencies are not satisfied",
+ serviceId, descriptor.getName(), processor);
+ continue;
+ }
+
final ControllerServiceNode serviceNode =
controllerServiceProvider.getControllerServiceNode(serviceId);
if (serviceNode == null) {
continue;
@@ -96,25 +109,16 @@ public class StandaloneProcessGroupLifecycle implements
ProcessGroupLifecycle {
}
}
+ // Transitively collect services required by the already-referenced
services.
+ // ControllerServiceNode.getRequiredControllerServices() already
filters based on satisfied property dependencies.
while (true) {
final Set<ControllerServiceNode> newlyAddedServices = new
HashSet<>();
for (final ControllerServiceNode service : referencedServices) {
- for (final PropertyDescriptor descriptor :
service.getPropertyDescriptors()) {
- if (descriptor.getControllerServiceDefinition() == null) {
- continue;
- }
-
- final String serviceId =
service.getProperty(descriptor).getEffectiveValue(group.getParameterContext());
- if (serviceId == null) {
- continue;
- }
-
- final ControllerServiceNode referencedService =
controllerServiceProvider.getControllerServiceNode(serviceId);
- if (referencedService != null &&
!referencedServices.contains(referencedService)) {
- logger.debug("Marking {} as a Referenced Controller
Service because it is referenced by {} property of {}",
- referencedService, descriptor.getName(), service);
-
- newlyAddedServices.add(referencedService);
+ for (final ControllerServiceNode requiredService :
service.getRequiredControllerServices()) {
+ if (!referencedServices.contains(requiredService)) {
+ logger.debug("Marking {} as a Referenced Controller
Service because it is required by {}",
+ requiredService, service);
+ newlyAddedServices.add(requiredService);
}
}
}
@@ -132,6 +136,15 @@ public class StandaloneProcessGroupLifecycle implements
ProcessGroupLifecycle {
}
}
+ private ValidationContext createValidationContext(final ComponentNode
component) {
+ final Map<String, String> effectivePropertyValues = new
LinkedHashMap<>();
+ for (final Map.Entry<PropertyDescriptor, String> entry :
component.getEffectivePropertyValues().entrySet()) {
+ effectivePropertyValues.put(entry.getKey().getName(),
entry.getValue());
+ }
+
+ return component.createValidationContext(effectivePropertyValues,
component.getAnnotationData(), component.getParameterLookup(), false);
+ }
+
@Override
public CompletableFuture<Void> enableControllerServices(final
Collection<String> collection) {
final Set<ControllerServiceNode> serviceNodes =
findControllerServices(collection);
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/facades/standalone/TestStandaloneProcessGroupLifecycle.java
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/facades/standalone/TestStandaloneProcessGroupLifecycle.java
new file mode 100644
index 0000000000..6aa4670aa5
--- /dev/null
+++
b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/facades/standalone/TestStandaloneProcessGroupLifecycle.java
@@ -0,0 +1,292 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.nifi.components.connector.facades.standalone;
+
+import org.apache.nifi.components.AllowableValue;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.components.PropertyValue;
+import org.apache.nifi.components.ValidationContext;
+import org.apache.nifi.components.connector.components.StatelessGroupLifecycle;
+import org.apache.nifi.controller.ControllerService;
+import org.apache.nifi.controller.ProcessorNode;
+import org.apache.nifi.controller.service.ControllerServiceNode;
+import org.apache.nifi.controller.service.ControllerServiceProvider;
+import org.apache.nifi.groups.ProcessGroup;
+import org.apache.nifi.parameter.ParameterLookup;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class TestStandaloneProcessGroupLifecycle {
+
+ private static final String SERVICE_ID = "service-1";
+ private static final String SERVICE_ID_2 = "service-2";
+
+ private static final AllowableValue VALUE_A = new AllowableValue("A",
"Value A");
+ private static final AllowableValue VALUE_B = new AllowableValue("B",
"Value B");
+
+ private static final PropertyDescriptor MODE_PROPERTY = new
PropertyDescriptor.Builder()
+ .name("mode")
+ .displayName("Mode")
+ .allowableValues(VALUE_A, VALUE_B)
+ .defaultValue(VALUE_A.getValue())
+ .required(true)
+ .build();
+
+ private static final PropertyDescriptor SERVICE_PROPERTY_NO_DEPENDENCY =
new PropertyDescriptor.Builder()
+ .name("service-no-dep")
+ .displayName("Service (No Dependency)")
+ .identifiesControllerService(ControllerService.class)
+ .build();
+
+ private static final PropertyDescriptor SERVICE_PROPERTY_WITH_DEPENDENCY =
new PropertyDescriptor.Builder()
+ .name("service-with-dep")
+ .displayName("Service (With Dependency)")
+ .identifiesControllerService(ControllerService.class)
+ .dependsOn(MODE_PROPERTY, VALUE_B)
+ .build();
+
+ @Mock
+ private ProcessGroup processGroup;
+
+ @Mock
+ private ControllerServiceProvider controllerServiceProvider;
+
+ @Mock
+ private StatelessGroupLifecycle statelessGroupLifecycle;
+
+ private StandaloneProcessGroupLifecycle lifecycle;
+
+ @BeforeEach
+ void setUp() {
+ lifecycle = new StandaloneProcessGroupLifecycle(processGroup,
controllerServiceProvider, statelessGroupLifecycle, id -> null);
+
lenient().when(processGroup.getProcessGroups()).thenReturn(Collections.emptySet());
+ }
+
+ @Test
+ void testFindReferencedServicesIncludesServiceWithNoDependency() {
+ final ControllerServiceNode serviceNode =
mock(ControllerServiceNode.class);
+
when(serviceNode.getRequiredControllerServices()).thenReturn(Collections.emptyList());
+
+ final ProcessorNode processor = createProcessorNode(
+ List.of(SERVICE_PROPERTY_NO_DEPENDENCY),
+ Map.of(SERVICE_PROPERTY_NO_DEPENDENCY, SERVICE_ID)
+ );
+
+ when(processGroup.getProcessors()).thenReturn(List.of(processor));
+
when(controllerServiceProvider.getControllerServiceNode(SERVICE_ID)).thenReturn(serviceNode);
+
+ final Set<ControllerServiceNode> result =
lifecycle.findReferencedServices(false);
+
+ assertTrue(result.contains(serviceNode), "Service with no property
dependency should be included");
+ assertEquals(1, result.size());
+ }
+
+ @Test
+ void testFindReferencedServicesExcludesServiceWhenDependencyNotSatisfied()
{
+ final ProcessorNode processor = createProcessorNode(
+ List.of(MODE_PROPERTY, SERVICE_PROPERTY_WITH_DEPENDENCY),
+ Map.of(MODE_PROPERTY, VALUE_A.getValue(),
SERVICE_PROPERTY_WITH_DEPENDENCY, SERVICE_ID)
+ );
+
+ when(processGroup.getProcessors()).thenReturn(List.of(processor));
+
+ final Set<ControllerServiceNode> result =
lifecycle.findReferencedServices(false);
+
+ assertTrue(result.isEmpty(), "Service should not be included when
property dependency is not satisfied");
+ }
+
+ @Test
+ void testFindReferencedServicesIncludesServiceWhenDependencySatisfied() {
+ final ControllerServiceNode serviceNode =
mock(ControllerServiceNode.class);
+
when(serviceNode.getRequiredControllerServices()).thenReturn(Collections.emptyList());
+
+ final ProcessorNode processor = createProcessorNode(
+ List.of(MODE_PROPERTY, SERVICE_PROPERTY_WITH_DEPENDENCY),
+ Map.of(MODE_PROPERTY, VALUE_B.getValue(),
SERVICE_PROPERTY_WITH_DEPENDENCY, SERVICE_ID)
+ );
+
+ when(processGroup.getProcessors()).thenReturn(List.of(processor));
+
when(controllerServiceProvider.getControllerServiceNode(SERVICE_ID)).thenReturn(serviceNode);
+
+ final Set<ControllerServiceNode> result =
lifecycle.findReferencedServices(false);
+
+ assertTrue(result.contains(serviceNode), "Service should be included
when property dependency is satisfied");
+ assertEquals(1, result.size());
+ }
+
+ @Test
+ void testFindReferencedServicesUsesDefaultValueForDependencyCheck() {
+ // A property that depends on MODE_PROPERTY having VALUE_A (which is
the default)
+ final PropertyDescriptor servicePropertyDependsOnDefault = new
PropertyDescriptor.Builder()
+ .name("service-dep-default")
+ .displayName("Service (Depends on Default)")
+ .identifiesControllerService(ControllerService.class)
+ .dependsOn(MODE_PROPERTY, VALUE_A)
+ .build();
+
+ final ControllerServiceNode serviceNode =
mock(ControllerServiceNode.class);
+
when(serviceNode.getRequiredControllerServices()).thenReturn(Collections.emptyList());
+
+ // Mode property not explicitly set; the default value "A" should
satisfy the dependency
+ final ProcessorNode processor = createProcessorNode(
+ List.of(MODE_PROPERTY, servicePropertyDependsOnDefault),
+ Map.of(servicePropertyDependsOnDefault, SERVICE_ID)
+ );
+
+ when(processGroup.getProcessors()).thenReturn(List.of(processor));
+
when(controllerServiceProvider.getControllerServiceNode(SERVICE_ID)).thenReturn(serviceNode);
+
+ final Set<ControllerServiceNode> result =
lifecycle.findReferencedServices(false);
+
+ assertTrue(result.contains(serviceNode), "Service should be included
when dependency property uses default value that satisfies dependency");
+ assertEquals(1, result.size());
+ }
+
+ @Test
+ void
testFindReferencedServicesTransitiveServiceExcludedWhenDependencyNotSatisfied()
{
+ // Service 1 is directly referenced by the processor (no dependency on
service property)
+ final ControllerServiceNode serviceNode1 =
mock(ControllerServiceNode.class);
+ // getRequiredControllerServices() already filters based on
dependencies, so it returns empty
+
when(serviceNode1.getRequiredControllerServices()).thenReturn(Collections.emptyList());
+
+ final ProcessorNode processor = createProcessorNode(
+ List.of(SERVICE_PROPERTY_NO_DEPENDENCY),
+ Map.of(SERVICE_PROPERTY_NO_DEPENDENCY, SERVICE_ID)
+ );
+
+ when(processGroup.getProcessors()).thenReturn(List.of(processor));
+
when(controllerServiceProvider.getControllerServiceNode(SERVICE_ID)).thenReturn(serviceNode1);
+
+ final Set<ControllerServiceNode> result =
lifecycle.findReferencedServices(false);
+
+ assertTrue(result.contains(serviceNode1), "Directly referenced service
should be included");
+ assertEquals(1, result.size(), "Only the directly referenced service
should be in the result");
+ }
+
+ @Test
+ void
testFindReferencedServicesTransitiveServiceIncludedWhenDependencySatisfied() {
+ final ControllerServiceNode serviceNode1 =
mock(ControllerServiceNode.class);
+ final ControllerServiceNode serviceNode2 =
mock(ControllerServiceNode.class);
+ // Service 1 requires Service 2 (dependency is satisfied)
+
when(serviceNode1.getRequiredControllerServices()).thenReturn(List.of(serviceNode2));
+
when(serviceNode2.getRequiredControllerServices()).thenReturn(Collections.emptyList());
+
+ final ProcessorNode processor = createProcessorNode(
+ List.of(SERVICE_PROPERTY_NO_DEPENDENCY),
+ Map.of(SERVICE_PROPERTY_NO_DEPENDENCY, SERVICE_ID)
+ );
+
+ when(processGroup.getProcessors()).thenReturn(List.of(processor));
+
when(controllerServiceProvider.getControllerServiceNode(SERVICE_ID)).thenReturn(serviceNode1);
+
+ final Set<ControllerServiceNode> result =
lifecycle.findReferencedServices(false);
+
+ assertTrue(result.contains(serviceNode1), "Directly referenced service
should be included");
+ assertTrue(result.contains(serviceNode2), "Transitively required
service should be included");
+ assertEquals(2, result.size());
+ }
+
+ @Test
+ void testFindReferencedServicesMultiplePropertiesMixedDependencies() {
+ final ControllerServiceNode serviceNode1 =
mock(ControllerServiceNode.class);
+
when(serviceNode1.getRequiredControllerServices()).thenReturn(Collections.emptyList());
+ final ControllerServiceNode serviceNode2 =
mock(ControllerServiceNode.class);
+
+ // Processor with two service properties: one with no dependency
(service1) and one with unsatisfied dependency (service2)
+ // Mode is "A", which does NOT satisfy the dependency on VALUE_B for
SERVICE_PROPERTY_WITH_DEPENDENCY
+ final ProcessorNode processor = createProcessorNode(
+ List.of(MODE_PROPERTY, SERVICE_PROPERTY_NO_DEPENDENCY,
SERVICE_PROPERTY_WITH_DEPENDENCY),
+ Map.of(MODE_PROPERTY, VALUE_A.getValue(),
SERVICE_PROPERTY_NO_DEPENDENCY, SERVICE_ID, SERVICE_PROPERTY_WITH_DEPENDENCY,
SERVICE_ID_2)
+ );
+
+ when(processGroup.getProcessors()).thenReturn(List.of(processor));
+
when(controllerServiceProvider.getControllerServiceNode(SERVICE_ID)).thenReturn(serviceNode1);
+
+ final Set<ControllerServiceNode> result =
lifecycle.findReferencedServices(false);
+
+ assertTrue(result.contains(serviceNode1), "Service from property with
no dependency should be included");
+ assertFalse(result.contains(serviceNode2), "Service from property with
unsatisfied dependency should not be included");
+ assertEquals(1, result.size());
+ }
+
+ /**
+ * Creates a mock ProcessorNode with the given property descriptors and
effective property values.
+ * The mock is configured with a ValidationContext whose
isDependencySatisfied calls the real default
+ * implementation, using property values derived from the provided
effective values.
+ */
+ private ProcessorNode createProcessorNode(final List<PropertyDescriptor>
descriptors,
+ final Map<PropertyDescriptor, String> effectiveValues) {
+ final ProcessorNode processor = mock(ProcessorNode.class);
+ when(processor.getPropertyDescriptors()).thenReturn(descriptors);
+
+ for (final PropertyDescriptor descriptor : descriptors) {
+
lenient().when(processor.getPropertyDescriptor(descriptor.getName())).thenReturn(descriptor);
+
lenient().when(processor.getEffectivePropertyValue(descriptor)).thenReturn(effectiveValues.get(descriptor));
+ }
+
+ // Build the effective property value map for creating the validation
context
+ final Map<PropertyDescriptor, String> effectivePropertyValues = new
LinkedHashMap<>();
+ for (final PropertyDescriptor descriptor : descriptors) {
+ final String value = effectiveValues.get(descriptor);
+ if (value != null) {
+ effectivePropertyValues.put(descriptor, value);
+ }
+ }
+
when(processor.getEffectivePropertyValues()).thenReturn(effectivePropertyValues);
+
+ // Set up a ValidationContext that delegates isDependencySatisfied to
the real default implementation
+ final ValidationContext validationContext =
mock(ValidationContext.class);
+
when(validationContext.isDependencySatisfied(any(PropertyDescriptor.class),
any(Function.class))).thenCallRealMethod();
+
+ // Mock getProperty() so the real isDependencySatisfied default method
can resolve property values
+ for (final PropertyDescriptor descriptor : descriptors) {
+ final String value = effectiveValues.get(descriptor);
+ if (value != null) {
+ final PropertyValue propertyValue = mock(PropertyValue.class);
+ lenient().when(propertyValue.getValue()).thenReturn(value);
+
lenient().when(validationContext.getProperty(descriptor)).thenReturn(propertyValue);
+ }
+ }
+
+ lenient().when(processor.getAnnotationData()).thenReturn(null);
+
lenient().when(processor.getParameterLookup()).thenReturn(ParameterLookup.EMPTY);
+ when(processor.createValidationContext(any(Map.class), any(),
any(ParameterLookup.class), anyBoolean())).thenReturn(validationContext);
+
+ return processor;
+ }
+}