awelless commented on code in PR #11813:
URL: https://github.com/apache/nifi/pull/11813#discussion_r4563950557


##########
nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/migration/StandardControllerServiceFactory.java:
##########
@@ -123,6 +145,22 @@ public ControllerServiceNode create(final 
ControllerServiceCreationDetails creat
         return serviceNode;
     }
 
+    private @Nullable String generateVersionedId(final 
ControllerServiceCreationDetails creationDetails, final ControllerServiceNode 
serviceNode) {
+        if (owningGroupIdentity == null) {
+            return null;
+        }
+
+        final Map<String, String> sortedProperties = new TreeMap<>();
+        for (final Map.Entry<String, String> property : 
creationDetails.serviceProperties().entrySet()) {
+            if 
(!serviceNode.getPropertyDescriptor(property.getKey()).isSensitive()) {

Review Comment:
   Moved to the synchronizer, which reads properties after the service's own 
migration, so renamed secrets are recognized. Instead of skipping sensitive 
values, a Parameter reference is hashed and an inline secret leaves the service 
unversioned (see the top-level comment).



##########
nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/flow/synchronization/StandardVersionedComponentSynchronizer.java:
##########
@@ -418,12 +425,15 @@ public void synchronize(final ProcessGroup group, final 
VersionedExternalFlow ve
                 }
             });
 
+            final VersionedExternalFlowMetadata metadata = 
versionedExternalFlow.getMetadata();
+            final String flowIdentifier = metadata == null ? null : 
metadata.getFlowIdentifier();
             for (final CreatedOrModifiedExtension createdOrModifiedExtension : 
createdAndModifiedExtensions) {
                 final ComponentNode extension = 
createdOrModifiedExtension.extension();
                 final Map<String, String> originalPropertyValues = 
createdOrModifiedExtension.propertyValues();
 
+                final String owningGroupIdentity = 
getOwningProcessGroupIdentity(extension, group, flowIdentifier);
                 final ControllerServiceFactory serviceFactory = new 
StandardControllerServiceFactory(context.getExtensionManager(), 
context.getFlowManager(),
-                    context.getControllerServiceProvider(), extension);
+                    context.getControllerServiceProvider(), extension, 
owningGroupIdentity);
 
                 if (extension instanceof final ProcessorNode processor) {
                     processor.migrateConfiguration(originalPropertyValues, 
serviceFactory);

Review Comment:
   There is now one shared method for synchronize and paste. It differs from 
your outline in three ways:
   - referenced IDs replace referrer IDs (see the top-level comment);
   - instead of a counter, created services that would share an ID within a 
group stay unversioned;
   - missing services are removed before IDs are assigned, so a created service 
can take the ID of the one it replaces.



##########
nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/migration/StandardControllerServiceFactory.java:
##########
@@ -104,7 +113,20 @@ public ControllerServiceNode create(final 
ControllerServiceCreationDetails creat
 
         serviceNode.setProperties(creationDetails.serviceProperties());
 
-        final ControllerServiceFactory serviceFactory = new 
StandardControllerServiceFactory(extensionManager, flowManager, 
serviceProvider, serviceNode);
+        final String versionedId = generateVersionedId(creationDetails, 
serviceNode);
+        if (versionedId != null) {
+            final Set<ControllerServiceNode> groupControllerServices = 
group.map(processGroup -> 
processGroup.getControllerServices(false)).orElse(Collections.emptySet());
+            final boolean occupied = groupControllerServices.stream()
+                    .map(ControllerServiceNode::getVersionedComponentId)
+                    .flatMap(Optional::stream)
+                    .anyMatch(versionedId::equals);
+
+            if (!occupied) {
+                serviceNode.setVersionedComponentId(versionedId);
+            }
+        }

Review Comment:
   The factory now only records created services, the nested factory shares the 
collection, and the ID code is gone. One difference: the service is recorded 
right after creation rather than before its migration specifically, since the 
order no longer matters once IDs are computed after all migrations.



##########
nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/flow/synchronization/StandardVersionedComponentSynchronizer.java:
##########
@@ -308,12 +313,14 @@ public ComponentAdditions 
addVersionedComponentsToProcessGroup(final ProcessGrou
         // update ports to final names
         updatePortsToFinalNames(proposedPortFinalNames);
 
+        final String groupIdentity = determineProcessGroupIdentity(group);
         for (final CreatedOrModifiedExtension createdOrModifiedExtension : 
createdAndModifiedExtensions) {
             final ComponentNode extension = 
createdOrModifiedExtension.extension();
             final Map<String, String> originalPropertyValues = 
createdOrModifiedExtension.propertyValues();
 
+            final String owningGroupIdentity = 
getOwningProcessGroupIdentity(extension, group, groupIdentity);
             final ControllerServiceFactory serviceFactory = new 
StandardControllerServiceFactory(context.getExtensionManager(), 
context.getFlowManager(),
-                    context.getControllerServiceProvider(), extension);
+                    context.getControllerServiceProvider(), extension, 
owningGroupIdentity);
 
             if (extension instanceof final ProcessorNode processor) {
                 processor.migrateConfiguration(originalPropertyValues, 
serviceFactory);

Review Comment:
   Paste now uses the shared method. It also migrates reporting tasks, as 
synchronize does.



##########
nifi-system-tests/nifi-alternate-config-extensions-bundle/nifi-alternate-config-extensions/src/main/java/org/apache/nifi/processors/tests/system/MigrateToControllerService.java:
##########
@@ -0,0 +1,90 @@
+/*
+ * 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.processors.tests.system;
+
+import org.apache.nifi.annotation.behavior.InputRequirement;
+import org.apache.nifi.annotation.behavior.InputRequirement.Requirement;
+import org.apache.nifi.annotation.documentation.CapabilityDescription;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.cs.tests.system.StateBackedStoreService;
+import org.apache.nifi.cs.tests.system.StoreService;
+import org.apache.nifi.migration.PropertyConfiguration;
+import org.apache.nifi.processor.AbstractProcessor;
+import org.apache.nifi.processor.ProcessContext;
+import org.apache.nifi.processor.ProcessSession;
+import org.apache.nifi.processor.Relationship;
+import org.apache.nifi.processor.exception.ProcessException;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicLong;
+
+@CapabilityDescription("""
+        Post-upgrade shape of a processor whose property migration creates the 
store Controller Service that the pre-upgrade shape did not have.
+        Each execution appends a row to the store so that tests can observe 
whether store contents survive flow and runtime upgrades.
+        """)
+@InputRequirement(Requirement.INPUT_FORBIDDEN)
+public class MigrateToControllerService extends AbstractProcessor {
+
+    static final PropertyDescriptor STORE_SERVICE = new 
PropertyDescriptor.Builder()
+            .name("Store Service")
+            .required(false)
+            .identifiesControllerService(StoreService.class)
+            .build();
+
+    static final Relationship REL_SUCCESS = new 
Relationship.Builder().name("success").build();
+
+    private static final List<PropertyDescriptor> PROPERTIES = 
List.of(STORE_SERVICE);
+    private static final Set<Relationship> RELATIONSHIPS = Set.of(REL_SUCCESS);
+
+    private final AtomicLong rowCounter = new AtomicLong(0L);
+
+    @Override
+    protected List<PropertyDescriptor> getSupportedPropertyDescriptors() {
+        return PROPERTIES;
+    }
+
+    @Override
+    public Set<Relationship> getRelationships() {
+        return RELATIONSHIPS;
+    }
+
+    @Override
+    public void migrateProperties(final PropertyConfiguration config) {
+        final String storeName = config.getPropertyValue("Store 
Name").orElse(null);
+        config.removeProperty("Store Name");
+
+        if (storeName != null) {
+            final String serviceId = 
config.createControllerService(StateBackedStoreService.class.getName(), 
Map.of("Store Name", storeName));
+            config.setProperty(STORE_SERVICE, serviceId);
+        }
+    }
+
+    @Override
+    public void onTrigger(final ProcessContext context, final ProcessSession 
session) throws ProcessException {
+        final StoreService storeService = 
context.getProperty(STORE_SERVICE).asControllerService(StoreService.class);
+        if (storeService == null) {
+            context.yield();
+            return;
+        }
+
+        storeService.append("row-" + rowCounter.getAndIncrement());

Review Comment:
   Row counting removed; `onTrigger` is a no-op.



##########
nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/flow/synchronization/StandardVersionedComponentSynchronizer.java:
##########
@@ -433,6 +443,8 @@ public void synchronize(final ProcessGroup group, final 
VersionedExternalFlow ve
                     task.migrateConfiguration(originalPropertyValues, 
serviceFactory);
                 }
             }
+
+            removeMissingControllerServices(group, 
versionedExternalFlow.getFlowContents());

Review Comment:
   Implemented as you suggested, with your loop. One addition: the lookup by 
versioned ID skips services recorded as missing, so a reference resolves to the 
service that replaces them.



##########
nifi-framework-bundle/nifi-framework/nifi-framework-components/src/test/java/org/apache/nifi/flow/synchronization/StandardVersionedComponentSynchronizerTest.java:
##########
@@ -552,6 +571,352 @@ public void testAddProcessorWithServiceAndMigration() {
         assertEquals(controllerServiceNode.getIdentifier(), 
migratedProperties.get("cs"));
     }
 
+    @Nested
+    class MigrationCreatedControllerService {
+
+        private static final String SNAPSHOT_ROOT_ID = "snapshot-root-id";
+        private static final String LOCAL_CHILD_ID = "local-child";
+        private static final String CHILD_GROUP_ID = "child-group-id";
+        private static final String PARENT_GROUP_ID = "parent-group-id";
+        private static final String CHILD_PLACEMENT_ID = "child-placement-id";
+        private static final String DESTINATION_GROUP_ID = 
"destination-group-id";
+
+        private static final String INCOMING_FLOW_ID = "incoming-flow-id";
+        private static final String DESTINATION_FLOW_ID = 
"destination-flow-id";
+        private static final String PARENT_FLOW_ID = "parent-flow-id";
+        private static final String CHILD_FLOW_ID = "child-flow-id";
+
+        private static final String REGISTRY_ID = "registry-id";
+        private static final String SERVICE_TYPE = 
TestControllerService.class.getName();
+        private static final Map<String, String> SERVICE_PROPERTIES = 
Map.of("mode", "read");
+
+        @Test
+        void 
migrationInSynchronizedGroupUsesIncomingFlowIdAfterVersionControlIsCleared() {
+            final ProcessGroup targetGroup = createMockProcessGroup();
+            final VersionControlInformation oldVersionControl = 
mock(VersionControlInformation.class);
+            
when(oldVersionControl.getFlowIdentifier()).thenReturn("old-flow-id");
+
+            final AtomicReference<VersionControlInformation> 
liveVersionControl = new AtomicReference<>(oldVersionControl);
+            
when(targetGroup.getVersionControlInformation()).thenAnswer(invocation -> 
liveVersionControl.get());
+            doAnswer(invocation -> {
+                liveVersionControl.set(null);
+                return null;
+            }).when(targetGroup).disconnectVersionControl();
+
+            final VersionedProcessGroup proposed = new VersionedProcessGroup();
+            proposed.setIdentifier(SNAPSHOT_ROOT_ID);
+            proposed.setProcessors(Set.of(createMinimalVersionedProcessor()));
+
+            final VersionedExternalFlow incoming = new VersionedExternalFlow();
+            incoming.setFlowContents(proposed);
+
+            final VersionedExternalFlowMetadata metadata = new 
VersionedExternalFlowMetadata();
+            metadata.setFlowIdentifier(INCOMING_FLOW_ID);
+            incoming.setMetadata(metadata);
+
+            mockMigratingProcessorIn(targetGroup);
+
+            synchronizer.synchronize(targetGroup, incoming, 
synchronizationOptions);
+
+            assertNull(targetGroup.getVersionControlInformation());
+            final String expectedId = 
expectedMigrationServiceId(INCOMING_FLOW_ID);
+            verify(controllerServiceNode).setVersionedComponentId(expectedId);
+        }
+
+        @Test
+        void migrationInChildWithoutOwnFlowUsesChildVersionedId() {
+            final ProcessGroup parentGroup = createMockProcessGroup();
+            final ProcessGroup childGroup = createMockProcessGroup();
+            when(childGroup.getIdentifier()).thenReturn(LOCAL_CHILD_ID);
+            when(childGroup.getParent()).thenReturn(parentGroup);
+            
when(childGroup.getVersionedComponentId()).thenReturn(Optional.of(CHILD_GROUP_ID));
+            
when(parentGroup.getProcessGroups()).thenReturn(Set.of(childGroup));
+
+            final VersionedProcessGroup proposedChild = new 
VersionedProcessGroup();
+            proposedChild.setIdentifier(CHILD_GROUP_ID);
+            
proposedChild.setProcessors(Set.of(createMinimalVersionedProcessor()));
+
+            final VersionedProcessGroup proposedParent = new 
VersionedProcessGroup();
+            proposedParent.setIdentifier(PARENT_GROUP_ID);
+            proposedParent.setProcessGroups(Set.of(proposedChild));
+
+            final VersionedExternalFlow incoming = new VersionedExternalFlow();
+            incoming.setFlowContents(proposedParent);
+
+            final VersionedExternalFlowMetadata metadata = new 
VersionedExternalFlowMetadata();
+            metadata.setFlowIdentifier(PARENT_FLOW_ID);
+            incoming.setMetadata(metadata);
+
+            mockMigratingProcessorIn(childGroup);
+
+            synchronizer.synchronize(parentGroup, incoming, 
synchronizationOptions);
+
+            final String expectedId = 
expectedMigrationServiceId(CHILD_GROUP_ID);
+            verify(controllerServiceNode).setVersionedComponentId(expectedId);
+        }
+
+        @Test
+        void migrationInIndependentlyVersionedChildUsesChildFlowId() {
+            final ProcessGroup parentGroup = createMockProcessGroup();
+            final ProcessGroup childGroup = createMockProcessGroup();
+            when(childGroup.getIdentifier()).thenReturn(LOCAL_CHILD_ID);
+            when(childGroup.getParent()).thenReturn(parentGroup);
+            
when(childGroup.getVersionedComponentId()).thenReturn(Optional.of(CHILD_PLACEMENT_ID));
+            
when(parentGroup.getProcessGroups()).thenReturn(Set.of(childGroup));
+
+            final AtomicReference<VersionControlInformation> 
childVersionControl = new AtomicReference<>();
+            
when(childGroup.getVersionControlInformation()).thenAnswer(invocation -> 
childVersionControl.get());
+            doAnswer(invocation -> {
+                childVersionControl.set(invocation.getArgument(0));
+                return null;
+            }).when(childGroup).setVersionControlInformation(any(), anyMap());
+
+            
when(flowManager.getFlowRegistryClient(REGISTRY_ID)).thenReturn(mock(FlowRegistryClientNode.class));
+            final VersionedFlowCoordinates coordinates = new 
VersionedFlowCoordinates();
+            coordinates.setRegistryId(REGISTRY_ID);
+            coordinates.setBucketId("bucket");
+            coordinates.setFlowId(CHILD_FLOW_ID);
+            coordinates.setVersion("1");
+
+            final VersionedProcessGroup proposedChild = new 
VersionedProcessGroup();
+            proposedChild.setIdentifier(CHILD_PLACEMENT_ID);
+            proposedChild.setVersionedFlowCoordinates(coordinates);
+            
proposedChild.setProcessors(Set.of(createMinimalVersionedProcessor()));
+
+            final VersionedProcessGroup proposedParent = new 
VersionedProcessGroup();
+            proposedParent.setIdentifier(PARENT_GROUP_ID);
+            proposedParent.setProcessGroups(Set.of(proposedChild));
+
+            final VersionedExternalFlow incoming = new VersionedExternalFlow();
+            incoming.setFlowContents(proposedParent);
+
+            final VersionedExternalFlowMetadata metadata = new 
VersionedExternalFlowMetadata();
+            metadata.setFlowIdentifier(PARENT_FLOW_ID);
+            incoming.setMetadata(metadata);
+
+            mockMigratingProcessorIn(childGroup);
+
+            synchronizer.synchronize(parentGroup, incoming, 
synchronizationOptions);
+
+            assertNotNull(childGroup.getVersionControlInformation());
+            assertEquals(CHILD_FLOW_ID, 
childGroup.getVersionControlInformation().getFlowIdentifier());
+            final String expectedId = 
expectedMigrationServiceId(CHILD_FLOW_ID);
+            verify(controllerServiceNode).setVersionedComponentId(expectedId);
+        }
+
+        @Test
+        void pasteIntoVersionControlledGroupUsesDestinationFlowId() {
+            final ProcessGroup destination = createMockProcessGroup();
+            
when(destination.getVersionedComponentId()).thenReturn(Optional.of(DESTINATION_GROUP_ID));
+
+            final VersionControlInformation versionControl = 
mock(VersionControlInformation.class);
+            
when(versionControl.getFlowIdentifier()).thenReturn(DESTINATION_FLOW_ID);
+            
when(destination.getVersionControlInformation()).thenReturn(versionControl);
+
+            mockMigratingProcessorIn(destination);
+
+            synchronizer.addVersionedComponentsToProcessGroup(destination, 
legacyProcessorAdditions(), synchronizationOptions);
+
+            final String expectedId = 
expectedMigrationServiceId(DESTINATION_FLOW_ID);
+            verify(controllerServiceNode).setVersionedComponentId(expectedId);
+        }
+
+        @Test
+        void pasteIntoGroupWithoutOwnFlowUsesDestinationVersionedId() {
+            final ProcessGroup destination = createMockProcessGroup();
+            
when(destination.getVersionedComponentId()).thenReturn(Optional.of(DESTINATION_GROUP_ID));
+            mockMigratingProcessorIn(destination);
+
+            synchronizer.addVersionedComponentsToProcessGroup(destination, 
legacyProcessorAdditions(), synchronizationOptions);
+
+            final String expectedId = 
expectedMigrationServiceId(DESTINATION_GROUP_ID);
+            verify(controllerServiceNode).setVersionedComponentId(expectedId);
+        }
+
+        @Test
+        void pasteWithoutDestinationIdentityLeavesServiceUnversioned() {
+            final ProcessGroup destination = createMockProcessGroup();
+            mockMigratingProcessorIn(destination);
+
+            synchronizer.addVersionedComponentsToProcessGroup(destination, 
legacyProcessorAdditions(), synchronizationOptions);
+
+            verify(destination).addControllerService(controllerServiceNode);
+            verify(controllerServiceNode, 
never()).setVersionedComponentId(any());
+        }
+
+        private void mockMigratingProcessorIn(final ProcessGroup owner) {
+            final ProcessorNode processor = createMockProcessor();
+            
when(processor.getParentProcessGroup()).thenReturn(Optional.of(owner));
+            when(flowManager.createProcessor(any(), any(), any(), 
eq(true))).thenReturn(processor);
+
+            final ControllerServiceCreationDetails details = 
mock(ControllerServiceCreationDetails.class);
+            when(details.serviceIdentifier()).thenReturn("new-service");
+            when(details.type()).thenReturn(SERVICE_TYPE);
+            
when(details.serviceBundleCoordinate()).thenReturn(bundleCoordinate);
+            when(details.serviceProperties()).thenReturn(SERVICE_PROPERTIES);
+            
when(controllerServiceNode.getPropertyDescriptor("mode")).thenReturn(new 
PropertyDescriptor.Builder().name("mode").build());
+
+            doAnswer(invocation -> {
+                final ControllerServiceFactory serviceFactory = 
invocation.getArgument(1);
+                serviceFactory.create(details);
+                return null;
+            }).when(processor).migrateConfiguration(anyMap(), 
any(ControllerServiceFactory.class));
+        }
+
+        private VersionedComponentAdditions legacyProcessorAdditions() {
+            final VersionedProcessor processor = 
createMinimalVersionedProcessor();
+            processor.setGroupIdentifier("source-group-not-destination");
+            return new 
VersionedComponentAdditions.Builder().setProcessors(Set.of(processor)).build();
+        }
+
+        private String expectedMigrationServiceId(final String 
owningGroupIdentity) {
+            final FlowManager expectedFlowManager = mock(FlowManager.class);
+            final ControllerServiceNode expectedService = 
mock(ControllerServiceNode.class);
+            when(expectedService.getPropertyDescriptor("mode")).thenReturn(new 
PropertyDescriptor.Builder().name("mode").build());
+            when(expectedFlowManager.createControllerService(eq(SERVICE_TYPE), 
anyString(), any(), anySet(), eq(true), eq(true), 
eq(null))).thenReturn(expectedService);
+
+            final ComponentNode creator = mock(ComponentNode.class);
+            
when(creator.getParentProcessGroup()).thenReturn(Optional.of(mock(ProcessGroup.class)));
+            final StandardControllerServiceFactory factory = new 
StandardControllerServiceFactory(
+                    mock(ExtensionManager.class), expectedFlowManager, 
mock(ControllerServiceProvider.class), creator, owningGroupIdentity);
+            final ControllerServiceCreationDetails details = 
mock(ControllerServiceCreationDetails.class);
+            when(details.serviceIdentifier()).thenReturn("expected-service");
+            when(details.type()).thenReturn(SERVICE_TYPE);
+            
when(details.serviceBundleCoordinate()).thenReturn(bundleCoordinate);
+            when(details.serviceProperties()).thenReturn(SERVICE_PROPERTIES);
+
+            factory.create(details);
+
+            final ArgumentCaptor<String> versionedId = 
ArgumentCaptor.forClass(String.class);
+            
verify(expectedService).setVersionedComponentId(versionedId.capture());
+            return versionedId.getValue();
+        }
+    }

Review Comment:
   The tests compare IDs between runs, and `expectedMigrationServiceId` is 
gone. The order test runs in both directions with a `LinkedHashSet`. It uses 
Parameter-referenced secrets plus one inline secret rather than an old property 
name, because the rename happens in the service's own migration, which the 
system test covers.



##########
nifi-framework-bundle/nifi-framework/nifi-framework-components/src/test/java/org/apache/nifi/flow/synchronization/StandardVersionedComponentSynchronizerTest.java:
##########
@@ -552,6 +571,352 @@ public void testAddProcessorWithServiceAndMigration() {
         assertEquals(controllerServiceNode.getIdentifier(), 
migratedProperties.get("cs"));
     }
 
+    @Nested
+    class MigrationCreatedControllerService {
+
+        private static final String SNAPSHOT_ROOT_ID = "snapshot-root-id";
+        private static final String LOCAL_CHILD_ID = "local-child";
+        private static final String CHILD_GROUP_ID = "child-group-id";
+        private static final String PARENT_GROUP_ID = "parent-group-id";
+        private static final String CHILD_PLACEMENT_ID = "child-placement-id";
+        private static final String DESTINATION_GROUP_ID = 
"destination-group-id";
+
+        private static final String INCOMING_FLOW_ID = "incoming-flow-id";
+        private static final String DESTINATION_FLOW_ID = 
"destination-flow-id";
+        private static final String PARENT_FLOW_ID = "parent-flow-id";
+        private static final String CHILD_FLOW_ID = "child-flow-id";
+
+        private static final String REGISTRY_ID = "registry-id";
+        private static final String SERVICE_TYPE = 
TestControllerService.class.getName();
+        private static final Map<String, String> SERVICE_PROPERTIES = 
Map.of("mode", "read");
+
+        @Test
+        void 
migrationInSynchronizedGroupUsesIncomingFlowIdAfterVersionControlIsCleared() {
+            final ProcessGroup targetGroup = createMockProcessGroup();
+            final VersionControlInformation oldVersionControl = 
mock(VersionControlInformation.class);
+            
when(oldVersionControl.getFlowIdentifier()).thenReturn("old-flow-id");
+
+            final AtomicReference<VersionControlInformation> 
liveVersionControl = new AtomicReference<>(oldVersionControl);
+            
when(targetGroup.getVersionControlInformation()).thenAnswer(invocation -> 
liveVersionControl.get());
+            doAnswer(invocation -> {
+                liveVersionControl.set(null);
+                return null;
+            }).when(targetGroup).disconnectVersionControl();
+
+            final VersionedProcessGroup proposed = new VersionedProcessGroup();
+            proposed.setIdentifier(SNAPSHOT_ROOT_ID);
+            proposed.setProcessors(Set.of(createMinimalVersionedProcessor()));
+
+            final VersionedExternalFlow incoming = new VersionedExternalFlow();
+            incoming.setFlowContents(proposed);
+
+            final VersionedExternalFlowMetadata metadata = new 
VersionedExternalFlowMetadata();
+            metadata.setFlowIdentifier(INCOMING_FLOW_ID);
+            incoming.setMetadata(metadata);
+
+            mockMigratingProcessorIn(targetGroup);
+
+            synchronizer.synchronize(targetGroup, incoming, 
synchronizationOptions);
+
+            assertNull(targetGroup.getVersionControlInformation());
+            final String expectedId = 
expectedMigrationServiceId(INCOMING_FLOW_ID);
+            verify(controllerServiceNode).setVersionedComponentId(expectedId);
+        }
+
+        @Test
+        void migrationInChildWithoutOwnFlowUsesChildVersionedId() {
+            final ProcessGroup parentGroup = createMockProcessGroup();
+            final ProcessGroup childGroup = createMockProcessGroup();
+            when(childGroup.getIdentifier()).thenReturn(LOCAL_CHILD_ID);
+            when(childGroup.getParent()).thenReturn(parentGroup);
+            
when(childGroup.getVersionedComponentId()).thenReturn(Optional.of(CHILD_GROUP_ID));
+            
when(parentGroup.getProcessGroups()).thenReturn(Set.of(childGroup));
+
+            final VersionedProcessGroup proposedChild = new 
VersionedProcessGroup();
+            proposedChild.setIdentifier(CHILD_GROUP_ID);
+            
proposedChild.setProcessors(Set.of(createMinimalVersionedProcessor()));
+
+            final VersionedProcessGroup proposedParent = new 
VersionedProcessGroup();
+            proposedParent.setIdentifier(PARENT_GROUP_ID);
+            proposedParent.setProcessGroups(Set.of(proposedChild));
+
+            final VersionedExternalFlow incoming = new VersionedExternalFlow();
+            incoming.setFlowContents(proposedParent);
+
+            final VersionedExternalFlowMetadata metadata = new 
VersionedExternalFlowMetadata();
+            metadata.setFlowIdentifier(PARENT_FLOW_ID);
+            incoming.setMetadata(metadata);
+
+            mockMigratingProcessorIn(childGroup);
+
+            synchronizer.synchronize(parentGroup, incoming, 
synchronizationOptions);
+
+            final String expectedId = 
expectedMigrationServiceId(CHILD_GROUP_ID);
+            verify(controllerServiceNode).setVersionedComponentId(expectedId);
+        }
+
+        @Test
+        void migrationInIndependentlyVersionedChildUsesChildFlowId() {
+            final ProcessGroup parentGroup = createMockProcessGroup();
+            final ProcessGroup childGroup = createMockProcessGroup();
+            when(childGroup.getIdentifier()).thenReturn(LOCAL_CHILD_ID);
+            when(childGroup.getParent()).thenReturn(parentGroup);
+            
when(childGroup.getVersionedComponentId()).thenReturn(Optional.of(CHILD_PLACEMENT_ID));
+            
when(parentGroup.getProcessGroups()).thenReturn(Set.of(childGroup));
+
+            final AtomicReference<VersionControlInformation> 
childVersionControl = new AtomicReference<>();
+            
when(childGroup.getVersionControlInformation()).thenAnswer(invocation -> 
childVersionControl.get());
+            doAnswer(invocation -> {
+                childVersionControl.set(invocation.getArgument(0));
+                return null;
+            }).when(childGroup).setVersionControlInformation(any(), anyMap());
+
+            
when(flowManager.getFlowRegistryClient(REGISTRY_ID)).thenReturn(mock(FlowRegistryClientNode.class));
+            final VersionedFlowCoordinates coordinates = new 
VersionedFlowCoordinates();
+            coordinates.setRegistryId(REGISTRY_ID);
+            coordinates.setBucketId("bucket");
+            coordinates.setFlowId(CHILD_FLOW_ID);
+            coordinates.setVersion("1");
+
+            final VersionedProcessGroup proposedChild = new 
VersionedProcessGroup();
+            proposedChild.setIdentifier(CHILD_PLACEMENT_ID);
+            proposedChild.setVersionedFlowCoordinates(coordinates);
+            
proposedChild.setProcessors(Set.of(createMinimalVersionedProcessor()));
+
+            final VersionedProcessGroup proposedParent = new 
VersionedProcessGroup();
+            proposedParent.setIdentifier(PARENT_GROUP_ID);
+            proposedParent.setProcessGroups(Set.of(proposedChild));
+
+            final VersionedExternalFlow incoming = new VersionedExternalFlow();
+            incoming.setFlowContents(proposedParent);
+
+            final VersionedExternalFlowMetadata metadata = new 
VersionedExternalFlowMetadata();
+            metadata.setFlowIdentifier(PARENT_FLOW_ID);
+            incoming.setMetadata(metadata);
+
+            mockMigratingProcessorIn(childGroup);
+
+            synchronizer.synchronize(parentGroup, incoming, 
synchronizationOptions);
+
+            assertNotNull(childGroup.getVersionControlInformation());
+            assertEquals(CHILD_FLOW_ID, 
childGroup.getVersionControlInformation().getFlowIdentifier());
+            final String expectedId = 
expectedMigrationServiceId(CHILD_FLOW_ID);
+            verify(controllerServiceNode).setVersionedComponentId(expectedId);
+        }
+
+        @Test
+        void pasteIntoVersionControlledGroupUsesDestinationFlowId() {
+            final ProcessGroup destination = createMockProcessGroup();
+            
when(destination.getVersionedComponentId()).thenReturn(Optional.of(DESTINATION_GROUP_ID));
+
+            final VersionControlInformation versionControl = 
mock(VersionControlInformation.class);
+            
when(versionControl.getFlowIdentifier()).thenReturn(DESTINATION_FLOW_ID);
+            
when(destination.getVersionControlInformation()).thenReturn(versionControl);
+
+            mockMigratingProcessorIn(destination);
+
+            synchronizer.addVersionedComponentsToProcessGroup(destination, 
legacyProcessorAdditions(), synchronizationOptions);
+
+            final String expectedId = 
expectedMigrationServiceId(DESTINATION_FLOW_ID);
+            verify(controllerServiceNode).setVersionedComponentId(expectedId);
+        }
+
+        @Test
+        void pasteIntoGroupWithoutOwnFlowUsesDestinationVersionedId() {
+            final ProcessGroup destination = createMockProcessGroup();
+            
when(destination.getVersionedComponentId()).thenReturn(Optional.of(DESTINATION_GROUP_ID));
+            mockMigratingProcessorIn(destination);
+
+            synchronizer.addVersionedComponentsToProcessGroup(destination, 
legacyProcessorAdditions(), synchronizationOptions);
+
+            final String expectedId = 
expectedMigrationServiceId(DESTINATION_GROUP_ID);
+            verify(controllerServiceNode).setVersionedComponentId(expectedId);
+        }
+
+        @Test
+        void pasteWithoutDestinationIdentityLeavesServiceUnversioned() {
+            final ProcessGroup destination = createMockProcessGroup();
+            mockMigratingProcessorIn(destination);
+
+            synchronizer.addVersionedComponentsToProcessGroup(destination, 
legacyProcessorAdditions(), synchronizationOptions);
+
+            verify(destination).addControllerService(controllerServiceNode);
+            verify(controllerServiceNode, 
never()).setVersionedComponentId(any());
+        }
+
+        private void mockMigratingProcessorIn(final ProcessGroup owner) {
+            final ProcessorNode processor = createMockProcessor();
+            
when(processor.getParentProcessGroup()).thenReturn(Optional.of(owner));
+            when(flowManager.createProcessor(any(), any(), any(), 
eq(true))).thenReturn(processor);
+
+            final ControllerServiceCreationDetails details = 
mock(ControllerServiceCreationDetails.class);
+            when(details.serviceIdentifier()).thenReturn("new-service");
+            when(details.type()).thenReturn(SERVICE_TYPE);
+            
when(details.serviceBundleCoordinate()).thenReturn(bundleCoordinate);
+            when(details.serviceProperties()).thenReturn(SERVICE_PROPERTIES);
+            
when(controllerServiceNode.getPropertyDescriptor("mode")).thenReturn(new 
PropertyDescriptor.Builder().name("mode").build());
+
+            doAnswer(invocation -> {
+                final ControllerServiceFactory serviceFactory = 
invocation.getArgument(1);
+                serviceFactory.create(details);
+                return null;
+            }).when(processor).migrateConfiguration(anyMap(), 
any(ControllerServiceFactory.class));
+        }
+
+        private VersionedComponentAdditions legacyProcessorAdditions() {
+            final VersionedProcessor processor = 
createMinimalVersionedProcessor();
+            processor.setGroupIdentifier("source-group-not-destination");
+            return new 
VersionedComponentAdditions.Builder().setProcessors(Set.of(processor)).build();
+        }
+
+        private String expectedMigrationServiceId(final String 
owningGroupIdentity) {
+            final FlowManager expectedFlowManager = mock(FlowManager.class);
+            final ControllerServiceNode expectedService = 
mock(ControllerServiceNode.class);
+            when(expectedService.getPropertyDescriptor("mode")).thenReturn(new 
PropertyDescriptor.Builder().name("mode").build());
+            when(expectedFlowManager.createControllerService(eq(SERVICE_TYPE), 
anyString(), any(), anySet(), eq(true), eq(true), 
eq(null))).thenReturn(expectedService);
+
+            final ComponentNode creator = mock(ComponentNode.class);
+            
when(creator.getParentProcessGroup()).thenReturn(Optional.of(mock(ProcessGroup.class)));
+            final StandardControllerServiceFactory factory = new 
StandardControllerServiceFactory(
+                    mock(ExtensionManager.class), expectedFlowManager, 
mock(ControllerServiceProvider.class), creator, owningGroupIdentity);
+            final ControllerServiceCreationDetails details = 
mock(ControllerServiceCreationDetails.class);
+            when(details.serviceIdentifier()).thenReturn("expected-service");
+            when(details.type()).thenReturn(SERVICE_TYPE);
+            
when(details.serviceBundleCoordinate()).thenReturn(bundleCoordinate);
+            when(details.serviceProperties()).thenReturn(SERVICE_PROPERTIES);
+
+            factory.create(details);
+
+            final ArgumentCaptor<String> versionedId = 
ArgumentCaptor.forClass(String.class);
+            
verify(expectedService).setVersionedComponentId(versionedId.capture());
+            return versionedId.getValue();
+        }
+    }
+
+    @Nested
+    class MissingControllerServiceRemoval {
+
+        private static final String ROOT_GROUP_ID = "root-group-id";
+        private static final String PARENT_GROUP_ID = "parent-group-id";
+        private static final String CHILD_GROUP_ID = "child-group-id";
+        private static final String VERSIONED_CHILD_GROUP_ID = 
"versioned-child-group-id";
+
+        @Test
+        void 
synchronizationKeepsMissingServicesReachableFromRemainingProcessor() {
+            final ProcessGroup targetGroup = createMockProcessGroup();
+            final ControllerServiceNode directlyReferenced = 
createMockControllerService();
+            final ControllerServiceNode referencedByDirect = 
createMockControllerService();
+            final ControllerServiceNode referencedByIndirect = 
createMockControllerService();
+            setReferences(directlyReferenced, createMockProcessor());
+            setReferences(referencedByDirect, directlyReferenced);
+            setReferences(referencedByIndirect, referencedByDirect);
+            
when(targetGroup.getControllerServices(false)).thenReturn(Set.of(directlyReferenced,
 referencedByDirect, referencedByIndirect));
+
+            synchronizer.synchronize(targetGroup, 
incomingFlowWithoutServices(), synchronizationOptions);
+
+            verify(controllerServiceProvider, 
never()).removeControllerService(directlyReferenced);
+            verify(controllerServiceProvider, 
never()).removeControllerService(referencedByDirect);
+            verify(controllerServiceProvider, 
never()).removeControllerService(referencedByIndirect);
+        }
+
+        @Test
+        void 
synchronizationRemovesMissingServicesReferencedOnlyByOtherMissingServices() {
+            final ProcessGroup targetGroup = createMockProcessGroup();
+            final ControllerServiceNode cycleFirst = 
createMockControllerService();
+            final ControllerServiceNode cycleSecond = 
createMockControllerService();
+            final ControllerServiceNode unreferenced = 
createMockControllerService();
+            setReferences(cycleFirst, cycleSecond);
+            setReferences(cycleSecond, cycleFirst);
+            
when(targetGroup.getControllerServices(false)).thenReturn(Set.of(cycleFirst, 
cycleSecond, unreferenced));
+
+            synchronizer.synchronize(targetGroup, 
incomingFlowWithoutServices(), synchronizationOptions);
+
+            
verify(controllerServiceProvider).removeControllerService(cycleFirst);
+            
verify(controllerServiceProvider).removeControllerService(cycleSecond);
+            
verify(controllerServiceProvider).removeControllerService(unreferenced);
+        }
+
+        @Test
+        void 
synchronizationKeepsMissingServiceReferencedAfterPropertyMigration() {
+            final ProcessGroup targetGroup = createMockProcessGroup();
+            final ControllerServiceNode service = 
createMockControllerService();
+            
when(targetGroup.getControllerServices(false)).thenReturn(Set.of(service));
+
+            final ProcessorNode processor = createMockProcessor();
+            
when(processor.getParentProcessGroup()).thenReturn(Optional.of(targetGroup));
+            when(flowManager.createProcessor(any(), any(), any(), 
eq(true))).thenReturn(processor);
+            doAnswer(invocation -> 
service.getReferences().getReferencingComponents().add(processor))
+                .when(processor).migrateConfiguration(anyMap(), 
any(ControllerServiceFactory.class));
+
+            final VersionedProcessGroup proposed = new VersionedProcessGroup();
+            proposed.setIdentifier(ROOT_GROUP_ID);
+            proposed.setProcessors(Set.of(createMinimalVersionedProcessor()));
+            final VersionedExternalFlow incoming = new VersionedExternalFlow();
+            incoming.setFlowContents(proposed);
+
+            synchronizer.synchronize(targetGroup, incoming, 
synchronizationOptions);
+
+            verify(controllerServiceProvider, 
never()).removeControllerService(service);
+        }
+
+        @Test
+        void 
synchronizationRemovesMissingServicesOnlyInSynchronizedChildGroups() {
+            final ProcessGroup parentGroup = createMockProcessGroup();
+            final ProcessGroup childGroup = createMockProcessGroup();
+            
when(childGroup.getVersionedComponentId()).thenReturn(Optional.of(CHILD_GROUP_ID));
+            final ProcessGroup versionedChildGroup = createMockProcessGroup();
+            
when(versionedChildGroup.getVersionedComponentId()).thenReturn(Optional.of(VERSIONED_CHILD_GROUP_ID));
+            when(parentGroup.getProcessGroups()).thenReturn(Set.of(childGroup, 
versionedChildGroup));
+
+            final ControllerServiceNode childService = 
createMockControllerService();
+            
when(childGroup.getControllerServices(false)).thenReturn(Set.of(childService));
+            final ControllerServiceNode versionedChildService = 
createMockControllerService();
+            
when(versionedChildGroup.getControllerServices(false)).thenReturn(Set.of(versionedChildService));
+
+            final ProcessorNode versionedChildProcessor = 
createMappableProcessor(versionedChildGroup);
+            
when(versionedChildGroup.getProcessors()).thenReturn(Set.of(versionedChildProcessor));
+            final ControllerServiceNode parentService = 
createMockControllerService();
+            setReferences(parentService, versionedChildProcessor);
+            
when(parentGroup.getControllerServices(false)).thenReturn(Set.of(parentService));
+
+            final VersionedFlowCoordinates coordinates = new 
VersionedFlowCoordinates();
+            coordinates.setRegistryId("registry-id");
+            coordinates.setBucketId("bucket");
+            coordinates.setFlowId("child-flow-id");
+            coordinates.setVersion("1");
+
+            final VersionedProcessGroup proposedChild = new 
VersionedProcessGroup();
+            proposedChild.setIdentifier(CHILD_GROUP_ID);
+            final VersionedProcessGroup proposedVersionedChild = new 
VersionedProcessGroup();
+            proposedVersionedChild.setIdentifier(VERSIONED_CHILD_GROUP_ID);
+            proposedVersionedChild.setVersionedFlowCoordinates(coordinates);
+            final VersionedProcessGroup proposedParent = new 
VersionedProcessGroup();
+            proposedParent.setIdentifier(PARENT_GROUP_ID);
+            proposedParent.setProcessGroups(Set.of(proposedChild, 
proposedVersionedChild));
+            final VersionedExternalFlow incoming = new VersionedExternalFlow();
+            incoming.setFlowContents(proposedParent);
+
+            final FlowSynchronizationOptions parentOnlyOptions = 
FlowSynchronizationOptions.Builder.from(synchronizationOptions)
+                .updateDescendantVersionedFlows(false)
+                .build();
+
+            synchronizer.synchronize(parentGroup, incoming, parentOnlyOptions);
+
+            
verify(controllerServiceProvider).removeControllerService(childService);
+            verify(controllerServiceProvider, 
never()).removeControllerService(versionedChildService);
+            verify(controllerServiceProvider, 
never()).removeControllerService(parentService);
+        }
+
+        private VersionedExternalFlow incomingFlowWithoutServices() {
+            final VersionedProcessGroup proposed = new VersionedProcessGroup();
+            proposed.setIdentifier(ROOT_GROUP_ID);
+
+            final VersionedExternalFlow incoming = new VersionedExternalFlow();
+            incoming.setFlowContents(proposed);
+            return incoming;
+        }
+    }

Review Comment:
   Folded and strengthened as suggested; the child-group test is removed.



##########
nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/migration/MigrationCreatedControllerServiceVersioningIT.java:
##########
@@ -0,0 +1,323 @@
+/*
+ * 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.tests.system.migration;
+
+import org.apache.nifi.tests.system.AbstractNarSwapMigrationIT;
+import org.apache.nifi.toolkit.client.NiFiClientException;
+import org.apache.nifi.web.api.dto.ComponentStateDTO;
+import org.apache.nifi.web.api.dto.ProcessorDTO;
+import org.apache.nifi.web.api.dto.StateEntryDTO;
+import org.apache.nifi.web.api.entity.ComponentStateEntity;
+import org.apache.nifi.web.api.entity.ControllerServiceEntity;
+import org.apache.nifi.web.api.entity.FlowRegistryClientEntity;
+import org.apache.nifi.web.api.entity.ProcessGroupEntity;
+import org.apache.nifi.web.api.entity.ProcessorEntity;
+import org.apache.nifi.web.api.entity.VersionedFlowUpdateRequestEntity;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Verifies that startup and import migration create matching Controller 
Service identities and that
+ * flow version changes preserve the service instance, references, and state.
+ */
+class MigrationCreatedControllerServiceVersioningIT extends 
AbstractNarSwapMigrationIT {
+
+    private static final String TEST_FLOWS_BUCKET = "test-flows";
+    private static final String FLOW_ID = 
"0831859c-f4a1-4ff6-99b3-58aeb35cb17f";
+    private static final String FLOW_DEFINITION_FILENAME = "snapshot.json";
+    private static final String VERSION_1 = "1";
+    private static final String VERSION_2 = "2";
+    private static final String VERSION_3 = "3";
+    private static final Path FLOW_SNAPSHOT = 
Path.of("src/test/resources/versioned-flows", TEST_FLOWS_BUCKET, FLOW_ID, 
VERSION_1, FLOW_DEFINITION_FILENAME);
+
+    private static final String STORE_SERVICE_PROPERTY = "Store Service";
+    private static final String STORE_SERVICE_TYPE = TEST_CS_PACKAGE + 
".StateBackedStoreService";
+
+    private static final String MIGRATING_PROCESSOR_NAME = 
"MigrateToControllerService";
+    private static final String PROCESSOR_TYPE = TEST_PROCESSORS_PACKAGE + "." 
+ MIGRATING_PROCESSOR_NAME;
+    private static final String ADDED_PROCESSOR_NAME = "Added Processor";
+
+    private static final String ROOT_PG = "root";
+
+    private static final String CREATED_STATE_KEY = "created";
+    private static final String ROW_COUNT_STATE_KEY = "rowCount";
+    private static final long CONDITION_POLL_MILLIS = 100L;
+
+    @TempDir
+    Path registryDirectory;
+
+    @Test
+    void testStartupAndImportMigratedServicesSurviveFlowVersionUpdates() 
throws NiFiClientException, IOException, InterruptedException {

Review Comment:
   Strengthened in the same single method: a required password renamed by the 
service, a second processor with shared and separate services, and a consumer 
that skipped a version. One difference: instead of a different inline password 
per instance, the legacy snapshot references `#{password}`, because a service 
with an inline secret stays unversioned. Secret separation is covered by the 
flows where processors use different secrets.



##########
nifi-framework-bundle/nifi-framework/nifi-framework-components/src/test/java/org/apache/nifi/migration/TestStandardControllerServiceFactory.java:
##########
@@ -142,6 +155,144 @@ public void testServiceIdDeterministic() {
         assertEquals(4, Set.of(initialServiceId, secondGroupId, thirdId, 
fourthId).size());
     }
 
+    @Nested
+    class VersionedIdAssignment {
+        private static final String ENDPOINT_PROPERTY = "Endpoint";
+        private static final String ENDPOINT_PARAMETER = "#{endpoint}";
+        private static final String MODE_PROPERTY = "Mode";
+        private static final String READ_MODE = "read";
+        private static final String PASSWORD_PROPERTY = "Password";
+
+        private static final String OWNING_GROUP_IDENTITY = "shared-version";
+
+        private static final String CONSUMER_GROUP_ID = "consumer-instance";
+
+        @Test
+        void testVersionedIdentityDependsOnSuppliedIdentityTypeAndProperties() 
{
+            final Map<String, String> properties = new LinkedHashMap<>();
+            properties.put(ENDPOINT_PROPERTY, ENDPOINT_PARAMETER);
+            properties.put(MODE_PROPERTY, READ_MODE);
+            final String first = 
createServiceAndGetVersionedId("author-instance", OWNING_GROUP_IDENTITY, 
IMPL_CLASS, properties);
+
+            final Map<String, String> reordered = new LinkedHashMap<>();
+            reordered.put(MODE_PROPERTY, READ_MODE);
+            reordered.put(ENDPOINT_PROPERTY, ENDPOINT_PARAMETER);
+            final String second = 
createServiceAndGetVersionedId(CONSUMER_GROUP_ID, OWNING_GROUP_IDENTITY, 
IMPL_CLASS, reordered);
+
+            assertNotNull(first);
+            assertEquals(first, second);
+            assertNotEquals(first, 
createServiceAndGetVersionedId(CONSUMER_GROUP_ID, "different-version", 
IMPL_CLASS, properties));
+            assertNotEquals(first, 
createServiceAndGetVersionedId(CONSUMER_GROUP_ID, OWNING_GROUP_IDENTITY, 
"AnotherService", properties));
+            assertNotEquals(first, 
createServiceAndGetVersionedId(CONSUMER_GROUP_ID, OWNING_GROUP_IDENTITY, 
IMPL_CLASS, Map.of()));
+        }
+
+        @Test
+        void testSensitiveValuesDoNotAffectVersionedIdentity() {
+            final Map<String, String> authorProperties = 
Map.of(ENDPOINT_PROPERTY, ENDPOINT_PARAMETER, PASSWORD_PROPERTY, 
"author-password");
+            final Map<String, String> consumerProperties = 
Map.of(ENDPOINT_PROPERTY, ENDPOINT_PARAMETER, PASSWORD_PROPERTY, 
"consumer-password");
+            final String authorId = 
createServiceAndGetVersionedId("author-instance", OWNING_GROUP_IDENTITY, 
IMPL_CLASS, authorProperties);
+            final String consumerId = 
createServiceAndGetVersionedId(CONSUMER_GROUP_ID, OWNING_GROUP_IDENTITY, 
IMPL_CLASS, consumerProperties);
+
+            assertEquals(authorId, consumerId);
+            assertEquals(authorId, 
createServiceAndGetVersionedId(CONSUMER_GROUP_ID, OWNING_GROUP_IDENTITY, 
IMPL_CLASS, Map.of(ENDPOINT_PROPERTY, ENDPOINT_PARAMETER)));
+            assertNotEquals(authorId, 
createServiceAndGetVersionedId(CONSUMER_GROUP_ID, OWNING_GROUP_IDENTITY, 
IMPL_CLASS,
+                    Map.of(ENDPOINT_PROPERTY, "another-endpoint", 
PASSWORD_PROPERTY, "consumer-password")));
+            assertNotEquals(factory.determineServiceId(IMPL_CLASS, 
authorProperties), factory.determineServiceId(IMPL_CLASS, consumerProperties));
+        }
+
+        @Test
+        void testOccupiedVersionedIdentityLeavesNewServiceUnversioned() {
+            final Map<String, String> properties = Map.of(ENDPOINT_PROPERTY, 
ENDPOINT_PARAMETER);
+            final String occupiedId = 
createServiceAndGetVersionedId("author-instance", OWNING_GROUP_IDENTITY, 
IMPL_CLASS, properties);
+            final ControllerServiceNode existingService = 
mock(ControllerServiceNode.class);
+            
when(existingService.getVersionedComponentId()).thenReturn(Optional.of(occupiedId));
+
+            final ControllerServiceNode newService = 
createService(CONSUMER_GROUP_ID, OWNING_GROUP_IDENTITY, IMPL_CLASS, properties, 
Set.of(existingService));
+
+            verify(newService, never()).setVersionedComponentId(any());
+            verify(existingService, never()).setVersionedComponentId(any());
+            verify(newService).migrateConfiguration(eq(properties), any());
+        }
+
+        @Test
+        void testUnversionedAndDifferentVersionedServicesDoNotOccupyIdentity() 
{
+            final Map<String, String> properties = Map.of(ENDPOINT_PROPERTY, 
ENDPOINT_PARAMETER);
+            final String expectedId = 
createServiceAndGetVersionedId("author-instance", OWNING_GROUP_IDENTITY, 
IMPL_CLASS, properties);
+            final ControllerServiceNode unversionedService = 
mock(ControllerServiceNode.class);
+            
when(unversionedService.getVersionedComponentId()).thenReturn(Optional.empty());
+            final ControllerServiceNode differentlyVersionedService = 
mock(ControllerServiceNode.class);
+            
when(differentlyVersionedService.getVersionedComponentId()).thenReturn(Optional.of("another-versioned-id"));
+
+            final ControllerServiceNode newService = 
createService(CONSUMER_GROUP_ID, OWNING_GROUP_IDENTITY, IMPL_CLASS, properties,
+                    Set.of(unversionedService, differentlyVersionedService));
+
+            verify(newService).setVersionedComponentId(expectedId);
+            verify(unversionedService, never()).setVersionedComponentId(any());
+            verify(differentlyVersionedService, 
never()).setVersionedComponentId(any());
+        }
+
+        @Test
+        void testCreationWithoutOwningGroupIdentity() {
+            final ComponentNode creator = mock(ComponentNode.class);
+            final FlowManager flowManager = mock(FlowManager.class);
+            final StandardControllerServiceFactory factory = new 
StandardControllerServiceFactory(
+                    mock(ExtensionManager.class), flowManager, 
mock(ControllerServiceProvider.class), creator);
+            final ControllerServiceNode service = 
mock(ControllerServiceNode.class);
+            final ControllerServiceCreationDetails details = new 
ControllerServiceCreationDetails("service-id", IMPL_CLASS,
+                    createCoordinate(LONE_BUNDLE, VERSION_2), Map.of(), 
ControllerServiceCreationDetails.CreationState.SERVICE_TO_BE_CREATED);
+            when(flowManager.createControllerService(eq(IMPL_CLASS), 
anyString(), any(), any(), eq(true), eq(true), eq(null))).thenReturn(service);
+            when(creator.getParentProcessGroup()).thenReturn(Optional.empty());
+
+            factory.create(details);
+
+            verify(flowManager).addRootControllerService(service);
+            verify(service, never()).setVersionedComponentId(any());
+
+            final ProcessGroup group = mock(ProcessGroup.class);
+            
when(creator.getParentProcessGroup()).thenReturn(Optional.of(group));
+
+            factory.create(details);
+
+            verify(group).addControllerService(service);
+            verify(service, never()).setVersionedComponentId(any());
+        }
+
+        private String createServiceAndGetVersionedId(final String 
localGroupId, final String owningGroupIdentity, final String type, final 
Map<String, String> properties) {
+            final ControllerServiceNode service = createService(localGroupId, 
owningGroupIdentity, type, properties, Set.of());
+            final ArgumentCaptor<String> versionedId = 
ArgumentCaptor.forClass(String.class);
+            verify(service).setVersionedComponentId(versionedId.capture());
+            return versionedId.getValue();
+        }
+
+        private ControllerServiceNode createService(final String localGroupId, 
final String owningGroupIdentity, final String type,
+                                                   final Map<String, String> 
properties, final Set<ControllerServiceNode> existingServices) {
+            final ComponentNode component = mock(ComponentNode.class);
+            final ProcessGroup group = mock(ProcessGroup.class);
+            
when(component.getParentProcessGroup()).thenReturn(Optional.of(group));
+            
when(component.getProcessGroupIdentifier()).thenReturn(localGroupId);
+            final FlowManager manager = mock(FlowManager.class);
+            final ControllerServiceNode service = 
mock(ControllerServiceNode.class);
+            
when(service.getPropertyDescriptor(anyString())).thenAnswer(invocation -> {
+                final String name = invocation.getArgument(0);
+                return new 
PropertyDescriptor.Builder().name(name).sensitive(PASSWORD_PROPERTY.equals(name)).build();
+            });
+            final Set<ControllerServiceNode> groupServices = new 
HashSet<>(existingServices);
+            groupServices.add(service);
+            when(group.getControllerServices(false)).thenReturn(groupServices);
+            when(manager.createControllerService(eq(type), anyString(), any(), 
any(), eq(true), eq(true), eq(null))).thenReturn(service);
+
+            final StandardControllerServiceFactory serviceFactory = new 
StandardControllerServiceFactory(
+                    mock(ExtensionManager.class), manager, 
mock(ControllerServiceProvider.class), component, owningGroupIdentity);
+            final String instanceId = serviceFactory.determineServiceId(type, 
properties);
+            final ControllerServiceCreationDetails details = new 
ControllerServiceCreationDetails(instanceId, type, 
createCoordinate(LONE_BUNDLE, VERSION_2), properties,
+                    
ControllerServiceCreationDetails.CreationState.SERVICE_TO_BE_CREATED);
+            serviceFactory.create(details);
+
+            verify(service).setProperties(properties);
+            return service;
+        }
+    }

Review Comment:
   Removed; the file matches `main`.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to