This is an automated email from the ASF dual-hosted git repository.

exceptionfactory pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git


The following commit(s) were added to refs/heads/main by this push:
     new 5a6c47278d7 NIFI-16058 Treat external Controller Service reference 
changes in versioned flows as environmental (#11383)
5a6c47278d7 is described below

commit 5a6c47278d70a4c98a3b1403a80bc1ff748bf586
Author: Pierre Villard <[email protected]>
AuthorDate: Thu Jul 23 15:52:27 2026 +0200

    NIFI-16058 Treat external Controller Service reference changes in versioned 
flows as environmental (#11383)
    
    Signed-off-by: David Handermann <[email protected]>
---
 .../apache/nifi/groups/StandardProcessGroup.java   |  82 +---------------
 .../apache/nifi/util/FlowDifferenceFilters.java    | 100 +++++++++++++++++++-
 .../nifi/util/TestFlowDifferenceFilters.java       |  84 +++++++++++++++++
 .../apache/nifi/web/StandardNiFiServiceFacade.java |   5 -
 .../org/apache/nifi/web/api/dto/DtoFactory.java    |   2 +-
 .../ExternalControllerServiceVersioningIT.java     | 103 ++++++++++++++++-----
 6 files changed, 260 insertions(+), 116 deletions(-)

diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/groups/StandardProcessGroup.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/groups/StandardProcessGroup.java
index 1aa7d4c5959..b20a5787ece 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/groups/StandardProcessGroup.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/groups/StandardProcessGroup.java
@@ -68,13 +68,9 @@ import 
org.apache.nifi.controller.service.ControllerServiceState;
 import org.apache.nifi.controller.service.StandardConfigurationContext;
 import org.apache.nifi.encrypt.PropertyEncryptor;
 import org.apache.nifi.flow.ExecutionEngine;
-import org.apache.nifi.flow.ExternalControllerServiceReference;
 import org.apache.nifi.flow.VersionedComponent;
-import org.apache.nifi.flow.VersionedControllerService;
 import org.apache.nifi.flow.VersionedExternalFlow;
 import org.apache.nifi.flow.VersionedProcessGroup;
-import org.apache.nifi.flow.VersionedProcessor;
-import org.apache.nifi.flow.VersionedPropertyDescriptor;
 import 
org.apache.nifi.flow.synchronization.StandardVersionedComponentSynchronizer;
 import 
org.apache.nifi.flow.synchronization.VersionedFlowSynchronizationContext;
 import org.apache.nifi.lifecycle.ProcessorStopLifecycleMethods;
@@ -3867,7 +3863,6 @@ public final class StandardProcessGroup implements 
ProcessGroup {
                 final FlowSnapshotContainer registrySnapshotContainer = 
flowRegistry.getFlowContents(
                         
FlowRegistryClientContextFactory.getAnonymousContext(), flowVersionLocation, 
false);
                 final RegisteredFlowSnapshot registrySnapshot = 
registrySnapshotContainer.getFlowSnapshot();
-                resolveExternalServiceReferences(registrySnapshot);
                 final VersionedProcessGroup registryFlow = 
registrySnapshot.getFlowContents();
                 vci.setFlowSnapshot(registryFlow);
             } catch (final IOException | FlowRegistryException e) {
@@ -4094,81 +4089,6 @@ public final class StandardProcessGroup implements 
ProcessGroup {
         return uuid.toString();
     }
 
-    private void resolveExternalServiceReferences(final RegisteredFlowSnapshot 
snapshot) {
-        final Map<String, ExternalControllerServiceReference> externalRefs = 
snapshot.getExternalControllerServices();
-        if (externalRefs == null || externalRefs.isEmpty()) {
-            return;
-        }
-
-        final ProcessGroup parentGroup = getParent();
-        if (parentGroup == null) {
-            return;
-        }
-
-        final Map<String, String> serviceNameToVersionedId = new HashMap<>();
-        for (final ControllerServiceNode serviceNode : 
parentGroup.getControllerServices(true)) {
-            final String versionedId = 
serviceNode.getVersionedComponentId().orElse(
-                    
VersionedComponentFlowMapper.generateVersionedComponentId(serviceNode.getIdentifier()));
-            serviceNameToVersionedId.put(serviceNode.getName(), versionedId);
-        }
-
-        final Map<String, String> foreignToLocalId = new HashMap<>();
-        for (final Map.Entry<String, ExternalControllerServiceReference> entry 
: externalRefs.entrySet()) {
-            final String foreignId = entry.getKey();
-            final String serviceName = entry.getValue().getName();
-            final String localId = serviceNameToVersionedId.get(serviceName);
-            if (localId != null && !localId.equals(foreignId)) {
-                foreignToLocalId.put(foreignId, localId);
-            }
-        }
-
-        if (!foreignToLocalId.isEmpty()) {
-            replaceExternalServiceIds(snapshot.getFlowContents(), 
foreignToLocalId);
-        }
-    }
-
-    private void replaceExternalServiceIds(final VersionedProcessGroup group, 
final Map<String, String> foreignToLocalId) {
-        if (group.getProcessors() != null) {
-            for (final VersionedProcessor processor : group.getProcessors()) {
-                replaceServicePropertyIds(processor.getProperties(), 
processor.getPropertyDescriptors(), foreignToLocalId);
-            }
-        }
-
-        if (group.getControllerServices() != null) {
-            for (final VersionedControllerService service : 
group.getControllerServices()) {
-                replaceServicePropertyIds(service.getProperties(), 
service.getPropertyDescriptors(), foreignToLocalId);
-            }
-        }
-
-        if (group.getProcessGroups() != null) {
-            for (final VersionedProcessGroup child : group.getProcessGroups()) 
{
-                replaceExternalServiceIds(child, foreignToLocalId);
-            }
-        }
-    }
-
-    private void replaceServicePropertyIds(final Map<String, String> 
properties, final Map<String, VersionedPropertyDescriptor> descriptors,
-                                           final Map<String, String> 
foreignToLocalId) {
-        if (properties == null || descriptors == null) {
-            return;
-        }
-
-        for (final Map.Entry<String, String> entry : properties.entrySet()) {
-            final String propertyValue = entry.getValue();
-            if (propertyValue == null) {
-                continue;
-            }
-
-            final VersionedPropertyDescriptor descriptor = 
descriptors.get(entry.getKey());
-            if (descriptor != null && 
descriptor.getIdentifiesControllerService()) {
-                final String localId = foreignToLocalId.get(propertyValue);
-                if (localId != null) {
-                    entry.setValue(localId);
-                }
-            }
-        }
-    }
-
     private Set<FlowDifference> getModifications() {
         final StandardVersionControlInformation vci = versionControlInfo.get();
 
@@ -4202,7 +4122,7 @@ public final class StandardProcessGroup implements 
ProcessGroup {
             final FlowComparison comparison = flowComparator.compare();
             final Collection<FlowDifference> comparisonDifferences = 
comparison.getDifferences();
             final FlowDifferenceFilters.EnvironmentalChangeContext 
environmentalContext =
-                
FlowDifferenceFilters.buildEnvironmentalChangeContext(comparisonDifferences, 
flowManager);
+                
FlowDifferenceFilters.buildEnvironmentalChangeContext(comparisonDifferences, 
versionedGroup, flowManager);
 
             final Set<FlowDifference> differences = 
comparisonDifferences.stream()
                 .filter(difference -> 
!FlowDifferenceFilters.isEnvironmentalChange(difference, versionedGroup, 
flowManager, environmentalContext))
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/util/FlowDifferenceFilters.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/util/FlowDifferenceFilters.java
index 1da9653ae50..72fd48d7637 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/util/FlowDifferenceFilters.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/util/FlowDifferenceFilters.java
@@ -48,7 +48,9 @@ import org.apache.nifi.registry.flow.diff.FlowDifference;
 import org.apache.nifi.registry.flow.mapping.InstantiatedVersionedComponent;
 import org.apache.nifi.registry.flow.mapping.InstantiatedVersionedConnection;
 import 
org.apache.nifi.registry.flow.mapping.InstantiatedVersionedControllerService;
+import org.apache.nifi.registry.flow.mapping.InstantiatedVersionedProcessGroup;
 import org.apache.nifi.registry.flow.mapping.InstantiatedVersionedProcessor;
+import org.apache.nifi.registry.flow.mapping.VersionedComponentFlowMapper;
 
 import java.util.ArrayList;
 import java.util.Collection;
@@ -104,7 +106,8 @@ public class FlowDifferenceFilters {
             || isPropertyParameterizationRename(difference, evaluatedContext)
             || isPropertyRenameWithMatchingValue(difference, evaluatedContext)
             || isSelectedRelationshipChangeForNewRelationship(difference, 
flowManager)
-            || isPropertyAddedFromMigration(difference, flowManager);
+            || isPropertyAddedFromMigration(difference, flowManager)
+            || isExternalControllerServiceReferenceChange(difference, 
flowManager, evaluatedContext);
     }
 
     /**
@@ -705,10 +708,17 @@ public class FlowDifferenceFilters {
     }
 
     public static EnvironmentalChangeContext 
buildEnvironmentalChangeContext(final Collection<FlowDifference> differences, 
final FlowManager flowManager) {
+        return buildEnvironmentalChangeContext(differences, null, flowManager);
+    }
+
+    public static EnvironmentalChangeContext 
buildEnvironmentalChangeContext(final Collection<FlowDifference> differences, 
final VersionedProcessGroup localGroup,
+                                                                             
final FlowManager flowManager) {
         if (differences == null || differences.isEmpty() || flowManager == 
null) {
             return EnvironmentalChangeContext.empty();
         }
 
+        final Set<String> ancestorControllerServiceIds = 
computeAncestorControllerServiceIds(localGroup, flowManager);
+
         final Map<String, List<PropertyDiffInfo>> parameterizedAddsByComponent 
= new HashMap<>();
         final Map<String, List<PropertyDiffInfo>> 
parameterizationRemovalsByComponent = new HashMap<>();
         final Map<String, List<PropertyDiffInfo>> 
controllerServicePropertyAddsByValue = new HashMap<>();
@@ -854,11 +864,83 @@ public class FlowDifferenceFilters {
             }
         }
 
-        if (serviceIdsWithMatchingAdditions.isEmpty() && 
parameterizedPropertyRenameDifferences.isEmpty() && 
propertyRenamesWithMatchingValues.isEmpty()) {
+        if (serviceIdsWithMatchingAdditions.isEmpty() && 
parameterizedPropertyRenameDifferences.isEmpty() && 
propertyRenamesWithMatchingValues.isEmpty()
+                && ancestorControllerServiceIds.isEmpty()) {
             return EnvironmentalChangeContext.empty();
         }
 
-        return new EnvironmentalChangeContext(serviceIdsWithMatchingAdditions, 
parameterizedPropertyRenameDifferences, propertyRenamesWithMatchingValues);
+        return new EnvironmentalChangeContext(serviceIdsWithMatchingAdditions, 
parameterizedPropertyRenameDifferences, propertyRenamesWithMatchingValues,
+                ancestorControllerServiceIds);
+    }
+
+    /**
+     * Determines whether a Flow Difference represents a change to a property 
that references an <em>external</em> controller service
+     * (one defined outside the versioned Process Group, e.g. in an ancestor 
group). Such a reference is environment-specific: the same
+     * logical service typically has a different identifier in each 
environment, so pointing the property at a different external service
+     * is not treated as a local modification. The change is environmental 
only when the versioned-snapshot value does not resolve to a
+     * locally-accessible ancestor service while the local value does.
+     */
+    private static boolean isExternalControllerServiceReferenceChange(final 
FlowDifference difference, final FlowManager flowManager,
+                                                                      final 
EnvironmentalChangeContext context) {
+        if (difference.getDifferenceType() != DifferenceType.PROPERTY_CHANGED) 
{
+            return false;
+        }
+
+        final Set<String> ancestorServiceIds = 
context.ancestorControllerServiceIds();
+        if (ancestorServiceIds.isEmpty()) {
+            return false;
+        }
+
+        if (!isControllerServiceProperty(difference, flowManager)) {
+            return false;
+        }
+
+        final Optional<String> snapshotValue = getPropertyValue(difference, 
true);
+        final Optional<String> localValue = getPropertyValue(difference, 
false);
+        if (snapshotValue.isEmpty() || localValue.isEmpty()) {
+            return false;
+        }
+
+        // Parameter-referenced controller services are environment-portable 
through Parameter Contexts and are not evaluated here.
+        if (isParameterReference(snapshotValue.get()) || 
isParameterReference(localValue.get())) {
+            return false;
+        }
+
+        // Environmental only when the snapshot reference is not a 
locally-accessible ancestor controller service but the local reference
+        // is. A switch between two locally-accessible ancestor services 
remains a reported change, and a reference to a service defined
+        // inside the versioned Process Group is never in the ancestor set, so 
it always remains a reported change.
+        final boolean snapshotAccessible = 
ancestorServiceIds.contains(snapshotValue.get());
+        final boolean localAccessible = 
ancestorServiceIds.contains(localValue.get());
+        return !snapshotAccessible && localAccessible;
+    }
+
+    /**
+     * Computes the set of versioned component identifiers for all controller 
services accessible from the ancestors of the given (local)
+     * versioned Process Group -- i.e. services defined in the parent group 
and above. These identify references to external controller
+     * services. Returns an empty set when the group is not an instantiated 
(live-backed) group, so callers that pass a plain snapshot
+     * group get no suppression.
+     */
+    private static Set<String> computeAncestorControllerServiceIds(final 
VersionedProcessGroup localGroup, final FlowManager flowManager) {
+        if (flowManager == null || !(localGroup instanceof 
InstantiatedVersionedProcessGroup instantiatedGroup)) {
+            return Collections.emptySet();
+        }
+
+        final ProcessGroup liveGroup = 
flowManager.getGroup(instantiatedGroup.getInstanceIdentifier());
+        if (liveGroup == null) {
+            return Collections.emptySet();
+        }
+
+        final ProcessGroup parentGroup = liveGroup.getParent();
+        if (parentGroup == null) {
+            return Collections.emptySet();
+        }
+
+        final Set<String> ancestorServiceIds = new HashSet<>();
+        for (final ControllerServiceNode serviceNode : 
parentGroup.getControllerServices(true)) {
+            
ancestorServiceIds.add(serviceNode.getVersionedComponentId().orElseGet(
+                    () -> 
VersionedComponentFlowMapper.generateVersionedComponentId(serviceNode.getIdentifier())));
+        }
+        return ancestorServiceIds;
     }
 
     public static boolean isControllerServiceCreatedForNewProperty(final 
FlowDifference difference, final EnvironmentalChangeContext context) {
@@ -1211,18 +1293,22 @@ public class FlowDifferenceFilters {
     }
 
     public static final class EnvironmentalChangeContext {
-        private static final EnvironmentalChangeContext EMPTY = new 
EnvironmentalChangeContext(Collections.emptySet(), Collections.emptySet(), 
Collections.emptySet());
+        private static final EnvironmentalChangeContext EMPTY =
+                new EnvironmentalChangeContext(Collections.emptySet(), 
Collections.emptySet(), Collections.emptySet(), Collections.emptySet());
 
         private final Set<String> serviceIdsCreatedForNewProperties;
         private final Set<FlowDifference> parameterizedPropertyRenames;
         private final Set<FlowDifference> propertyRenamesWithMatchingValues;
+        private final Set<String> ancestorControllerServiceIds;
 
         private EnvironmentalChangeContext(final Set<String> 
serviceIdsCreatedForNewProperties,
                                            final Set<FlowDifference> 
parameterizedPropertyRenames,
-                                           final Set<FlowDifference> 
propertyRenamesWithMatchingValues) {
+                                           final Set<FlowDifference> 
propertyRenamesWithMatchingValues,
+                                           final Set<String> 
ancestorControllerServiceIds) {
             this.serviceIdsCreatedForNewProperties = 
Collections.unmodifiableSet(new HashSet<>(serviceIdsCreatedForNewProperties));
             this.parameterizedPropertyRenames = 
Collections.unmodifiableSet(new HashSet<>(parameterizedPropertyRenames));
             this.propertyRenamesWithMatchingValues = 
Collections.unmodifiableSet(new HashSet<>(propertyRenamesWithMatchingValues));
+            this.ancestorControllerServiceIds = 
Collections.unmodifiableSet(new HashSet<>(ancestorControllerServiceIds));
         }
 
         static EnvironmentalChangeContext empty() {
@@ -1240,5 +1326,9 @@ public class FlowDifferenceFilters {
         Set<FlowDifference> propertyRenamesWithMatchingValues() {
             return propertyRenamesWithMatchingValues;
         }
+
+        Set<String> ancestorControllerServiceIds() {
+            return ancestorControllerServiceIds;
+        }
     }
 }
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/test/java/org/apache/nifi/util/TestFlowDifferenceFilters.java
 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/test/java/org/apache/nifi/util/TestFlowDifferenceFilters.java
index 81c45668eef..cbb5489ef13 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/test/java/org/apache/nifi/util/TestFlowDifferenceFilters.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/test/java/org/apache/nifi/util/TestFlowDifferenceFilters.java
@@ -44,6 +44,7 @@ import org.apache.nifi.registry.flow.diff.FlowDifference;
 import org.apache.nifi.registry.flow.diff.StandardFlowDifference;
 import org.apache.nifi.registry.flow.mapping.InstantiatedVersionedConnection;
 import 
org.apache.nifi.registry.flow.mapping.InstantiatedVersionedControllerService;
+import org.apache.nifi.registry.flow.mapping.InstantiatedVersionedProcessGroup;
 import org.apache.nifi.registry.flow.mapping.InstantiatedVersionedProcessor;
 import org.junit.jupiter.api.Test;
 import org.mockito.Mockito;
@@ -51,6 +52,7 @@ import org.mockito.Mockito;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 
 import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -393,6 +395,88 @@ public class TestFlowDifferenceFilters {
         
assertTrue(FlowDifferenceFilters.isEnvironmentalChange(controllerServiceDifference,
 null, flowManager, context));
     }
 
+    /**
+     * Changing a processor property that references an external (ancestor) 
controller service is not reported as a local
+     * modification, because the same logical service typically has a 
different identifier in each environment. This exercises the
+     * directional rule over the set of accessible ancestor controller service 
ids: suppression happens only when the versioned-snapshot
+     * reference is not a locally-accessible ancestor service while the local 
reference is.
+     */
+    @Test
+    public void testExternalControllerServiceReferenceChangeDirectionalRule() {
+        final String pgInstanceId = "pg-instance";
+        final String parentGroupId = "parent-group";
+        final String propertyName = "SSL Context Service";
+
+        final String ancestorServiceX = "ancestor-service-x";
+        final String ancestorServiceY = "ancestor-service-y";
+        final String foreignServiceId = "foreign-service-id";
+        final String internalServiceId = "internal-service-id";
+
+        // Two controller services defined in the parent (ancestor) group, 
i.e. external to the versioned Process Group.
+        final ControllerServiceNode serviceX = 
Mockito.mock(ControllerServiceNode.class);
+        
Mockito.when(serviceX.getVersionedComponentId()).thenReturn(Optional.of(ancestorServiceX));
+        final ControllerServiceNode serviceY = 
Mockito.mock(ControllerServiceNode.class);
+        
Mockito.when(serviceY.getVersionedComponentId()).thenReturn(Optional.of(ancestorServiceY));
+
+        final ProcessGroup parentGroup = Mockito.mock(ProcessGroup.class);
+        
Mockito.when(parentGroup.getControllerServices(true)).thenReturn(Set.of(serviceX,
 serviceY));
+
+        final ProcessGroup liveGroup = Mockito.mock(ProcessGroup.class);
+        Mockito.when(liveGroup.getParent()).thenReturn(parentGroup);
+
+        final FlowManager flowManager = Mockito.mock(FlowManager.class);
+        Mockito.when(flowManager.getGroup(pgInstanceId)).thenReturn(liveGroup);
+
+        final InstantiatedVersionedProcessGroup localGroup = new 
InstantiatedVersionedProcessGroup(pgInstanceId, parentGroupId);
+
+        assertExternalServiceChange(localGroup, flowManager, parentGroupId, 
propertyName, foreignServiceId, ancestorServiceX, true,
+                "Switching from an unavailable external service to a local 
ancestor service must be treated as environmental");
+
+        assertExternalServiceChange(localGroup, flowManager, parentGroupId, 
propertyName, ancestorServiceY, ancestorServiceX, false,
+                "Switching between two locally-accessible ancestor services 
must remain a reported change");
+
+        assertExternalServiceChange(localGroup, flowManager, parentGroupId, 
propertyName, ancestorServiceX, internalServiceId, false,
+                "Switching to a service that is not an accessible ancestor 
(e.g. internal to the group) must remain a reported change");
+
+        assertExternalServiceChange(localGroup, flowManager, parentGroupId, 
propertyName, "foreign-1", "foreign-2", false,
+                "A change between two non-ancestor services must remain a 
reported change");
+    }
+
+    private void assertExternalServiceChange(final 
InstantiatedVersionedProcessGroup localGroup, final FlowManager flowManager,
+                                             final String groupId, final 
String propertyName, final String snapshotValue,
+                                             final String localValue, final 
boolean expectedEnvironmental, final String message) {
+        final VersionedPropertyDescriptor descriptor = new 
VersionedPropertyDescriptor();
+        descriptor.setName(propertyName);
+        descriptor.setDisplayName(propertyName);
+        descriptor.setDynamic(false);
+        descriptor.setIdentifiesControllerService(true);
+
+        final InstantiatedVersionedProcessor snapshotProcessor = new 
InstantiatedVersionedProcessor("processor-instance", groupId);
+        snapshotProcessor.setComponentType(ComponentType.PROCESSOR);
+        snapshotProcessor.setIdentifier("processor-instance");
+        snapshotProcessor.setProperties(Map.of(propertyName, snapshotValue));
+        snapshotProcessor.setPropertyDescriptors(Map.of(propertyName, 
descriptor));
+
+        final InstantiatedVersionedProcessor localProcessor = new 
InstantiatedVersionedProcessor("processor-instance", groupId);
+        localProcessor.setComponentType(ComponentType.PROCESSOR);
+        localProcessor.setIdentifier("processor-instance");
+        localProcessor.setProperties(Map.of(propertyName, localValue));
+        localProcessor.setPropertyDescriptors(Map.of(propertyName, 
descriptor));
+
+        final FlowDifference difference = new StandardFlowDifference(
+                DifferenceType.PROPERTY_CHANGED, snapshotProcessor, 
localProcessor, propertyName, snapshotValue, localValue,
+                "Property '" + propertyName + "' changed");
+
+        final FlowDifferenceFilters.EnvironmentalChangeContext context =
+                
FlowDifferenceFilters.buildEnvironmentalChangeContext(List.of(difference), 
localGroup, flowManager);
+
+        if (expectedEnvironmental) {
+            assertTrue(FlowDifferenceFilters.isEnvironmentalChange(difference, 
localGroup, flowManager, context), message);
+        } else {
+            
assertFalse(FlowDifferenceFilters.isEnvironmentalChange(difference, localGroup, 
flowManager, context), message);
+        }
+    }
+
     @Test
     public void 
testPropertyRenameWithParameterizationObservedAsEnvironmentalChange() {
         final FlowManager flowManager = Mockito.mock(FlowManager.class);
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/StandardNiFiServiceFacade.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/StandardNiFiServiceFacade.java
index aca6949c908..ad6f1568e7c 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/StandardNiFiServiceFacade.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/StandardNiFiServiceFacade.java
@@ -6597,11 +6597,6 @@ public class StandardNiFiServiceFacade implements 
NiFiServiceFacade {
             final FlowSnapshotContainer flowSnapshotContainer = 
flowRegistry.getFlowContents(FlowRegistryClientContextFactory.getContextForUser(NiFiUserUtils.getNiFiUser()),
                     flowVersionLocation, true);
 
-            // Resolve external controller service references by name so that 
cross-instance
-            // ID differences do not appear as phantom local modifications.
-            final String parentGroupId = processGroup.getParent() == null ? 
processGroup.getIdentifier() : processGroup.getParent().getIdentifier();
-            
controllerFacade.getControllerServiceResolver().resolveInheritedControllerServices(flowSnapshotContainer,
 parentGroupId, NiFiUserUtils.getNiFiUser());
-
             final RegisteredFlowSnapshot versionedFlowSnapshot = 
flowSnapshotContainer.getFlowSnapshot();
             registryGroup = versionedFlowSnapshot.getFlowContents();
         } catch (final IOException | FlowRegistryException e) {
diff --git 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/dto/DtoFactory.java
 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/dto/DtoFactory.java
index 1df40ad87db..5c2a35e18b7 100644
--- 
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/dto/DtoFactory.java
+++ 
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/dto/DtoFactory.java
@@ -2856,7 +2856,7 @@ public final class DtoFactory {
 
         final Collection<FlowDifference> comparisonDifferences = 
comparison.getDifferences();
         final FlowDifferenceFilters.EnvironmentalChangeContext 
environmentalContext =
-                
FlowDifferenceFilters.buildEnvironmentalChangeContext(comparisonDifferences, 
flowManager);
+                
FlowDifferenceFilters.buildEnvironmentalChangeContext(comparisonDifferences, 
localGroup, flowManager);
 
         for (final FlowDifference difference : comparisonDifferences) {
             // capture bundle differences and dedupe those differences
diff --git 
a/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/registry/ExternalControllerServiceVersioningIT.java
 
b/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/registry/ExternalControllerServiceVersioningIT.java
index d74000f7935..1ee3a7a029a 100644
--- 
a/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/registry/ExternalControllerServiceVersioningIT.java
+++ 
b/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/registry/ExternalControllerServiceVersioningIT.java
@@ -61,9 +61,9 @@ public class ExternalControllerServiceVersioningIT extends 
NiFiSystemIT {
      * then the original PG and service are removed and a new service with the 
same name (but
      * different ID) is created before re-importing from the registry.
      *
-     * After import, the flow should be UP_TO_DATE because the external 
service was resolved
-     * by name during import and the cached snapshot should also be resolved.
-     * Both the state badge and the "Show Local Changes" dialog should agree.
+     * After import, the flow should be UP_TO_DATE: the committed reference 
points at the (now-removed) dev service id, which
+     * is not a locally-accessible ancestor service, while the local reference 
points at the same-named prod service, so the
+     * external-service reference change is treated as environment-specific. 
Both the badge and the dialog should agree.
      */
     @Test
     public void testCrossInstanceImportWithExternalServiceShowsUpToDate() 
throws NiFiClientException, IOException, InterruptedException {
@@ -106,6 +106,57 @@ public class ExternalControllerServiceVersioningIT extends 
NiFiSystemIT {
                 "After cross-instance import, Show Local Changes should report 
no differences");
     }
 
+    /**
+     * A flow committed against one external service is imported into an 
instance whose equivalent external service has a
+     * DIFFERENT name (and a different id). After pointing the processor at 
the local service, the flow is UP_TO_DATE with no
+     * local modifications, because an external controller service reference 
is environment-specific regardless of its name or id.
+     */
+    @Test
+    public void 
testCrossInstanceImportWithDifferentlyNamedExternalServiceShowsUpToDate() 
throws NiFiClientException, IOException, InterruptedException {
+        final FlowRegistryClientEntity registryClient = registerClient();
+        final NiFiClientUtil util = getClientUtil();
+
+        final ControllerServiceEntity devService = 
util.createControllerService(COUNT_SERVICE_TYPE, "root");
+        util.enableControllerService(devService);
+
+        final ProcessGroupEntity child = util.createProcessGroup("Child", 
"root");
+        final ProcessorEntity counter = util.createProcessor("CountFlowFiles", 
child.getId());
+        util.updateProcessorProperties(counter, 
Collections.singletonMap("Count Service", devService.getComponent().getId()));
+        final ProcessorEntity terminate = 
util.createProcessor("TerminateFlowFile", child.getId());
+        util.createConnection(counter, terminate, "success");
+
+        final VersionControlInformationEntity vci = 
util.startVersionControl(child, registryClient, TEST_FLOWS_BUCKET, 
"cross-instance-diff-name-flow");
+        util.assertFlowUpToDate(child.getId());
+        final VersionControlInformationDTO vciDto = 
vci.getVersionControlInformation();
+
+        getNifiClient().getVersionsClient().stopVersionControl(
+                
getNifiClient().getProcessGroupClient().getProcessGroup(child.getId()));
+        deleteProcessGroupContents(child.getId());
+        getNifiClient().getProcessGroupClient().deleteProcessGroup(
+                
getNifiClient().getProcessGroupClient().getProcessGroup(child.getId()));
+
+        deleteControllerService(devService);
+
+        ControllerServiceEntity prodService = 
util.createControllerService(COUNT_SERVICE_TYPE, "root");
+        prodService = renameControllerService(prodService, 
"DifferentlyNamedCountService");
+        assertNotEquals(devService.getComponent().getId(), 
prodService.getComponent().getId(),
+                "Prod service should have a different ID than dev service");
+        util.enableControllerService(prodService);
+
+        final ProcessGroupEntity imported = 
util.importFlowFromRegistry("root", vciDto.getRegistryId(),
+                vciDto.getBucketId(), vciDto.getFlowId(), vciDto.getVersion());
+
+        // Point the imported processor at the differently-named local 
external service.
+        final ProcessorEntity importedCounter = 
findProcessorByType(imported.getId(), "CountFlowFiles");
+        util.updateProcessorProperties(importedCounter, 
Collections.singletonMap("Count Service", prodService.getComponent().getId()));
+
+        waitForVersionedFlowState(imported.getId(), "root", "UP_TO_DATE");
+
+        final FlowComparisonEntity localMods = 
getNifiClient().getProcessGroupClient().getLocalModifications(imported.getId());
+        assertTrue(localMods.getComponentDifferences().isEmpty(),
+                "After pointing at a differently-named external service, Show 
Local Changes should report no differences");
+    }
+
     /**
      * Simulates a cross-instance upgrade where v1 and v2 both reference the 
same external
      * service but differ in a non-service property (scheduling period). The 
original PG and
@@ -167,17 +218,16 @@ public class ExternalControllerServiceVersioningIT 
extends NiFiSystemIT {
     }
 
     /**
-     * Reproduces the NIFI-15697 scenario on a single instance:
-     *
-     * 1. Create an external service "StandardCountService" and a child PG 
referencing it, commit.
-     * 2. Create a second external service "AlternateCountService", switch the 
processor to it.
-     * 3. Delete the original service.
-     * 4. Verify that both the state badge (LOCALLY_MODIFIED) and the dialog 
(shows the change) agree.
+     * Switching a processor's reference from one external service to another 
and then deleting the originally-referenced service
+     * keeps the state badge and the "Show Local Changes" dialog consistent at 
every step.
      *
-     * The two services have different names so the name-based resolver cannot 
falsely reconcile them.
+     * While both services exist, the switch is a genuine, reported local 
change (both are locally-accessible ancestor services).
+     * Once the originally-referenced service is removed, the committed 
reference no longer resolves to a local service, so the
+     * change becomes environment-specific and the flow returns to UP_TO_DATE. 
The badge and the dialog agree throughout -- there
+     * is never a badge that reports LOCALLY_MODIFIED while the dialog reports 
no changes.
      */
     @Test
-    public void 
testSwitchExternalServiceAndDeleteOriginalShowsLocalModification() throws 
NiFiClientException, IOException, InterruptedException {
+    public void 
testSwitchExternalServiceAndDeleteOriginalKeepsBadgeAndDialogConsistent() 
throws NiFiClientException, IOException, InterruptedException {
         final FlowRegistryClientEntity registryClient = registerClient();
         final NiFiClientUtil util = getClientUtil();
 
@@ -199,22 +249,19 @@ public class ExternalControllerServiceVersioningIT 
extends NiFiSystemIT {
 
         util.updateProcessorProperties(counter, 
Collections.singletonMap("Count Service", serviceB.getComponent().getId()));
 
-        String state = util.getVersionedFlowState(child.getId(), "root");
-        assertEquals("LOCALLY_MODIFIED", state, "After switching external 
service reference, PG should be LOCALLY_MODIFIED");
+        // While both external services exist, switching between them is a 
genuine local change; badge and dialog must agree.
+        final String stateAfterSwitch = 
util.getVersionedFlowState(child.getId(), "root");
+        assertEquals("LOCALLY_MODIFIED", stateAfterSwitch, "While both 
external services exist, switching the reference should be LOCALLY_MODIFIED");
+        
assertFalse(getNifiClient().getProcessGroupClient().getLocalModifications(child.getId()).getComponentDifferences().isEmpty(),
+                "Badge is LOCALLY_MODIFIED, so the dialog must also report 
differences");
 
         deleteControllerService(serviceA);
 
-        state = util.getVersionedFlowState(child.getId(), "root");
-        assertEquals("LOCALLY_MODIFIED", state, "After deleting original 
service, PG should still be LOCALLY_MODIFIED");
-
-        final FlowComparisonEntity localMods = 
getNifiClient().getProcessGroupClient().getLocalModifications(child.getId());
-        assertFalse(localMods.getComponentDifferences().isEmpty(), "Show Local 
Changes should report differences");
-
-        final boolean hasPropertyValueChange = 
localMods.getComponentDifferences().stream()
-                .flatMap(dto -> dto.getDifferences().stream())
-                .map(DifferenceDTO::getDifferenceType)
-                .anyMatch(type -> type.contains("Property Value Changed"));
-        assertTrue(hasPropertyValueChange, "Differences should include a 
Property Value Changed difference");
+        // Once the originally-referenced external service is gone, the 
reference change is environment-specific: the flow
+        // returns to UP_TO_DATE and the dialog reports no differences. Badge 
and dialog agree (no stuck LOCALLY_MODIFIED).
+        waitForVersionedFlowState(child.getId(), "root", "UP_TO_DATE");
+        
assertTrue(getNifiClient().getProcessGroupClient().getLocalModifications(child.getId()).getComponentDifferences().isEmpty(),
+                "After removing the original external service, the dialog must 
report no differences (consistent with the UP_TO_DATE badge)");
     }
 
     /**
@@ -262,6 +309,14 @@ public class ExternalControllerServiceVersioningIT extends 
NiFiSystemIT {
         }
     }
 
+    private ProcessorEntity findProcessorByType(final String groupId, final 
String simpleType) throws NiFiClientException, IOException {
+        final ProcessGroupFlowEntity flowEntity = 
getNifiClient().getFlowClient().getProcessGroup(groupId);
+        return 
flowEntity.getProcessGroupFlow().getFlow().getProcessors().stream()
+                .filter(processor -> 
processor.getComponent().getType().endsWith(simpleType))
+                .findFirst()
+                .orElseThrow(() -> new AssertionError("Could not find 
processor of type " + simpleType + " in group " + groupId));
+    }
+
     private ControllerServiceEntity renameControllerService(final 
ControllerServiceEntity service, final String newName)
             throws NiFiClientException, IOException {
         final ControllerServiceDTO dto = new ControllerServiceDTO();


Reply via email to