attributes) {
void loadInitialFlow() throws FlowUpdateException;
+ boolean isMigrationSupported(ConnectorMigrationContext context);
+
+ /**
+ * Reports whether the Connector has been modified since it was created. The result is used to gate the
+ * {@code MIGRATE} allowable action and to verify migration eligibility at migrate time so that an incoming
+ * Versioned flow cannot silently overwrite user modifications.
+ *
+ * A Connector derives its managed flow entirely from its configuration, so this is determined from durable
+ * state that is reproduced on every NiFi restart: the Connector's Active and Working configurations and the
+ * component state accumulated by its managed flow. A Connector is considered modified when any configured
+ * property differs from the property's declared default value, or when any Processor or Controller Service in
+ * the managed flow has stored component state. Simply starting and stopping the Connector without configuring
+ * it or accumulating state does not make it modified.
+ *
+ * @return {@code true} when the Connector's configuration has diverged from its defaults or its managed flow has
+ * accumulated component state; {@code false} when the Connector is still in the state it was created in
+ */
+ boolean isModified();
+
+ /**
+ * Discards the working flow context, if any, and rebuilds it from the active flow context's configuration.
+ * The framework calls this on the success path of a Connector migration so that any subsequent configure-step
+ * interactions begin from the migrated flow rather than from the working state that existed before migration.
+ */
+ void recreateWorkingFlowContext();
+
/**
*
* Pause triggering asynchronous validation to occur when the connector is updated. Often times, it is necessary
@@ -317,6 +344,54 @@ default void setCustomLoggingAttributes(final Map attributes) {
void inheritConfiguration(List activeFlowConfiguration, List workingFlowConfiguration,
Bundle flowContextBundle) throws FlowUpdateException;
+ /**
+ * Rebuilds this Connector's managed Process Group from the merged configuration a {@code MigratableConnector}
+ * produced during {@code migrateConfiguration(...)}, and drives
+ * {@link Connector#applyUpdate(org.apache.nifi.components.connector.components.FlowContext,
+ * org.apache.nifi.components.connector.components.FlowContext) Connector.applyUpdate(workingContext, activeContext)}
+ * so the managed Process Group is rebuilt from it. Uses the same {@code applyUpdate(...)} path the framework uses
+ * on restart, so a single Connector code path rebuilds the managed flow in both cases.
+ *
+ *
+ * The supplied {@code mergedConfiguration} is the working configuration the connector mutated through the
+ * migration context: the framework seeded it with a clone of this Connector's active configuration before
+ * {@code migrateConfiguration(...)} ran, and the connector's {@code setProperties(...)} and
+ * {@code replaceProperties(...)} calls were applied to it directly. The active configuration is intentionally not
+ * mutated here so that a failure in the state-migration phase that follows can be rolled back by simply restoring
+ * the initial flow; the merged configuration is returned and the framework writes it onto the active configuration
+ * via {@link #commitMigratedConfiguration} only after the state-migration phase has succeeded.
+ *
+ *
+ *
+ * This method is only invoked by the framework's migration manager. It does not require the Connector to be in
+ * any particular state-transition state and does not transition the Connector into {@code UPDATING}/{@code UPDATED}
+ * the way {@link #applyUpdate()} does; migration starts and ends with the Connector {@code STOPPED}.
+ *
+ *
+ * @param mergedConfiguration the merged working configuration produced by the connector during migration
+ * @return the merged configuration that the framework must later commit onto the active configuration via
+ * {@link #commitMigratedConfiguration}
+ * @throws FlowUpdateException when the merged configuration cannot be applied
+ */
+ ConnectorConfiguration applyMigratedConfiguration(MutableConnectorConfigurationContext mergedConfiguration) throws FlowUpdateException;
+
+ /**
+ * Commits the merged configuration returned by {@link #applyMigratedConfiguration} onto this Connector's active
+ * configuration so the migration outcome is persisted to {@code flow.json.gz}. Each step in {@code mergedConfiguration}
+ * is written via {@code replaceProperties} so the resulting active configuration matches the merged configuration
+ * exactly, including property removals.
+ *
+ *
+ * The framework calls this method only after the state-migration phase and all staged component-state writes have
+ * succeeded. Until this commit runs, the active configuration still holds the pre-migration values, so a failure
+ * in either migration phase can be rolled back by restoring the initial flow without having to revert active
+ * configuration mutations.
+ *
+ *
+ * @param mergedConfiguration the configuration produced by {@link #applyMigratedConfiguration} during this migration
+ */
+ void commitMigratedConfiguration(ConnectorConfiguration mergedConfiguration);
+
/**
* Marks the connector as invalid with the given subject and explanation. This is used when a flow update
* fails during initialization or flow synchronization to indicate the connector cannot operate.
diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/components/connector/ConnectorRepository.java b/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/components/connector/ConnectorRepository.java
index 29f7d8272f69..7e1bc3b99465 100644
--- a/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/components/connector/ConnectorRepository.java
+++ b/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/components/connector/ConnectorRepository.java
@@ -26,6 +26,7 @@
import java.io.IOException;
import java.io.InputStream;
+import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.Future;
@@ -260,6 +261,18 @@ void inheritConfiguration(ConnectorNode connector, List assetIdentifiers);
+
/**
* Ensures that asset binaries for the given connector are available locally by downloading
* any missing or changed assets from the external {@link ConnectorConfigurationProvider}.
@@ -269,4 +282,48 @@ void inheritConfiguration(ConnectorNode connector, List getCopiedAssetIds();
+}
diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/components/connector/MutableConnectorConfigurationContext.java b/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/components/connector/MutableConnectorConfigurationContext.java
index 22191633af01..ed7512e1c830 100644
--- a/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/components/connector/MutableConnectorConfigurationContext.java
+++ b/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/components/connector/MutableConnectorConfigurationContext.java
@@ -19,11 +19,18 @@
import java.util.Map;
+/**
+ * Framework-internal mutable view of a Connector's configuration. Extension NARs do not see this
+ * type at runtime because it is bundled inside the framework NAR; framework code uses it directly
+ * to mutate the active configuration.
+ */
public interface MutableConnectorConfigurationContext extends ConnectorConfigurationContext {
/**
* Sets the properties for the given step to the provided properties. Any existing properties
- * for the step that are not included in the provided configuration will remain unchanged.
+ * for the step that are not included in the provided configuration will remain unchanged. A property whose value
+ * reference in the provided configuration is {@code null} is removed from the step rather than stored with a null
+ * value.
*
* @param stepName the name of the configuration step
* @param configuration the configuration to set
@@ -33,7 +40,8 @@ public interface MutableConnectorConfigurationContext extends ConnectorConfigura
/**
* Replaces all of the properties for the given step with the provided properties. Any existing properties
- * for the step that are not included in the provided configuration will be removed.
+ * for the step that are not included in the provided configuration will be removed. A property whose value
+ * reference in the provided configuration is {@code null} is likewise omitted from the replaced step.
*
* @param stepName the name of the configuration step
* @param configuration the configuration to set
diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/ConnectorParameterLookup.java b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/ConnectorParameterLookup.java
index ada91a1d418f..eeca0c31e149 100644
--- a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/ConnectorParameterLookup.java
+++ b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/ConnectorParameterLookup.java
@@ -172,13 +172,14 @@ private void collectParameterValues(final VersionedParameterContext context, fin
parameterValues.removeIf(param -> param.getName().equals(parameterName));
}
+ final List referencedAssets = versionedParameter.getReferencedAssets();
final ParameterValue.Builder builder = new ParameterValue.Builder()
.name(parameterName)
- .value(versionedParameter.getValue())
+ .value(referencedAssets == null || referencedAssets.isEmpty() ? versionedParameter.getValue() : null)
.sensitive(versionedParameter.isSensitive());
- if (assetManager != null && versionedParameter.getReferencedAssets() != null) {
- for (final VersionedAsset versionedAsset : versionedParameter.getReferencedAssets()) {
+ if (assetManager != null && referencedAssets != null) {
+ for (final VersionedAsset versionedAsset : referencedAssets) {
assetManager.getAsset(versionedAsset.getIdentifier()).ifPresent(builder::addReferencedAsset);
}
}
diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/EligibilityConnectorMigrationContext.java b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/EligibilityConnectorMigrationContext.java
new file mode 100644
index 000000000000..33842daba730
--- /dev/null
+++ b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/EligibilityConnectorMigrationContext.java
@@ -0,0 +1,106 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.components.connector;
+
+import org.apache.nifi.asset.AssetManager;
+import org.apache.nifi.components.state.StateManagerProvider;
+import org.apache.nifi.controller.ClusterTopologyProvider;
+import org.apache.nifi.flow.VersionedComponentState;
+import org.apache.nifi.flow.VersionedExternalFlow;
+
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * Delegating {@link FrameworkConnectorMigrationContext} used on the listing/eligibility path. The
+ * {@link org.apache.nifi.components.connector.migration.MigratableConnector#isMigrationSupported(org.apache.nifi.components.connector.migration.ConnectorMigrationContext)}
+ * contract requires the call to be side-effect-free; in particular it must not invoke
+ * {@link #copyAssetFromSource(String)}. This wrapper enforces that contract by forwarding every other accessor
+ * to the delegate while throwing {@link IllegalStateException} from {@code copyAssetFromSource(String)}, even
+ * when the underlying context would otherwise allow the copy. Implementations that ignore the JavaDoc and try
+ * to copy assets during eligibility evaluation are caught here rather than silently mutating the Connector's
+ * asset namespace during a read-only listing.
+ */
+final class EligibilityConnectorMigrationContext implements FrameworkConnectorMigrationContext {
+
+ private final FrameworkConnectorMigrationContext delegate;
+
+ EligibilityConnectorMigrationContext(final FrameworkConnectorMigrationContext delegate) {
+ this.delegate = Objects.requireNonNull(delegate, "Delegate FrameworkConnectorMigrationContext is required");
+ }
+
+ @Override
+ public VersionedExternalFlow getSourceFlow() {
+ return delegate.getSourceFlow();
+ }
+
+ @Override
+ public boolean isLocalMigration() {
+ return delegate.isLocalMigration();
+ }
+
+ @Override
+ public FrameworkFlowContext getActiveFlowContext() {
+ return delegate.getActiveFlowContext();
+ }
+
+ @Override
+ public AssetReference copyAssetFromSource(final String sourceAssetId) {
+ throw new IllegalStateException("copyAssetFromSource() must not be invoked from isMigrationSupported(); the listing path is read-only.");
+ }
+
+ @Override
+ public void setProperties(final String stepName, final Map propertyValues) {
+ throw new IllegalStateException("setProperties() must not be invoked from isMigrationSupported(); the listing path is read-only.");
+ }
+
+ @Override
+ public void replaceProperties(final String stepName, final Map propertyValues) {
+ throw new IllegalStateException("replaceProperties() must not be invoked from isMigrationSupported(); the listing path is read-only.");
+ }
+
+ @Override
+ public void setComponentState(final String managedComponentId, final VersionedComponentState state) {
+ throw new IllegalStateException("setComponentState() must not be invoked from isMigrationSupported(); the listing path is read-only.");
+ }
+
+ @Override
+ public AssetManager getSourceAssetManager() {
+ return delegate.getSourceAssetManager();
+ }
+
+ @Override
+ public ConnectorRepository getConnectorRepository() {
+ return delegate.getConnectorRepository();
+ }
+
+ @Override
+ public StateManagerProvider getStateManagerProvider() {
+ return delegate.getStateManagerProvider();
+ }
+
+ @Override
+ public ClusterTopologyProvider getClusterTopologyProvider() {
+ return delegate.getClusterTopologyProvider();
+ }
+
+ @Override
+ public Set getCopiedAssetIds() {
+ return delegate.getCopiedAssetIds();
+ }
+}
diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/MigrationFlowContext.java b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/MigrationFlowContext.java
new file mode 100644
index 000000000000..87e6b9570b94
--- /dev/null
+++ b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/MigrationFlowContext.java
@@ -0,0 +1,112 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.components.connector;
+
+import org.apache.nifi.asset.AssetManager;
+import org.apache.nifi.components.connector.components.FlowContextType;
+import org.apache.nifi.components.connector.components.ParameterContextFacade;
+import org.apache.nifi.components.connector.components.ProcessGroupFacade;
+import org.apache.nifi.flow.Bundle;
+import org.apache.nifi.flow.VersionedExternalFlow;
+import org.apache.nifi.flow.VersionedProcessGroup;
+import org.apache.nifi.groups.ProcessGroup;
+
+import java.util.Objects;
+
+/**
+ * Read-only {@link FrameworkFlowContext} wrapper handed to a {@code MigratableConnector} via
+ * {@link org.apache.nifi.components.connector.migration.ConnectorMigrationContext#getActiveFlowContext()
+ * ConnectorMigrationContext.getActiveFlowContext()}. Every accessor delegates straight to the underlying
+ * framework context, but {@link #updateFlow(VersionedExternalFlow, AssetManager)} always throws.
+ *
+ *
+ * This is the single chokepoint that prevents a Connector from installing a new managed Process Group from inside
+ * either {@code migrateConfiguration(...)} or {@code migrateState(...)}. The framework owns the
+ * {@code updateFlow(...)} call site during migration: it drives the rebuild through the Connector's own
+ * {@link Connector#applyUpdate(org.apache.nifi.components.connector.components.FlowContext,
+ * org.apache.nifi.components.connector.components.FlowContext) applyUpdate(...)} between the two phases, using
+ * the configuration deltas the Connector recorded via
+ * {@link org.apache.nifi.components.connector.migration.ConnectorMigrationContext#setProperties(String, java.util.Map)
+ * setProperties(...)} /
+ * {@link org.apache.nifi.components.connector.migration.ConnectorMigrationContext#replaceProperties(String, java.util.Map)
+ * replaceProperties(...)}.
+ *
+ *
+ *
+ * {@link StandardConnectorInitializationContext#updateFlow(org.apache.nifi.components.connector.components.FlowContext,
+ * VersionedExternalFlow, BundleCompatibility) StandardConnectorInitializationContext.updateFlow(...)} delegates to
+ * {@link FrameworkFlowContext#updateFlow(VersionedExternalFlow, AssetManager)}, so wrapping the
+ * {@link FrameworkFlowContext} here is sufficient to refuse every {@code updateFlow(...)} entry point.
+ *
+ */
+final class MigrationFlowContext implements FrameworkFlowContext {
+
+ private final FrameworkFlowContext delegate;
+
+ MigrationFlowContext(final FrameworkFlowContext delegate) {
+ this.delegate = Objects.requireNonNull(delegate, "Delegate FrameworkFlowContext is required");
+ }
+
+ @Override
+ public ProcessGroup getManagedProcessGroup() {
+ return delegate.getManagedProcessGroup();
+ }
+
+ @Override
+ public MutableConnectorConfigurationContext getConfigurationContext() {
+ return delegate.getConfigurationContext();
+ }
+
+ @Override
+ public ProcessGroupFacade getRootGroup() {
+ return delegate.getRootGroup();
+ }
+
+ @Override
+ public ParameterContextFacade getParameterContext() {
+ return delegate.getParameterContext();
+ }
+
+ @Override
+ public FlowContextType getType() {
+ return delegate.getType();
+ }
+
+ @Override
+ public Bundle getBundle() {
+ return delegate.getBundle();
+ }
+
+ @Override
+ public void verifyUpdateFlow(final VersionedExternalFlow versionedExternalFlow) throws FlowUpdateException {
+ delegate.verifyUpdateFlow(versionedExternalFlow);
+ }
+
+ @Override
+ public void updateFlow(final VersionedExternalFlow versionedExternalFlow, final AssetManager assetManager) throws FlowUpdateException {
+ throw new FlowUpdateException("updateFlow() cannot be called from MigratableConnector.migrateConfiguration() or migrateState();"
+ + " record the desired post-migration configuration via ConnectorMigrationContext.setProperties() / replaceProperties()"
+ + " and the framework will drive applyUpdate() to rebuild the managed flow using the same code path used on restart.");
+ }
+
+ @Override
+ public void restoreTroubleshootingFlow(final VersionedProcessGroup troubleshootingProcessGroup) {
+ throw new IllegalStateException("restoreTroubleshootingFlow() cannot be called from MigratableConnector.migrateConfiguration() or migrateState();"
+ + " the migration context exposes a read-only view of the managed flow so that the framework remains the sole owner of"
+ + " installing the managed Process Group.");
+ }
+}
diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardConnectorConfigurationContext.java b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardConnectorConfigurationContext.java
index 8cc1662d06c0..59b5bfc7f142 100644
--- a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardConnectorConfigurationContext.java
+++ b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardConnectorConfigurationContext.java
@@ -153,7 +153,17 @@ public ConfigurationUpdateResult setProperties(final String stepName, final Step
final StepConfiguration existingConfig = propertyConfigurations.get(stepName);
final Map existingProperties = existingConfig != null ? existingConfig.getPropertyValues() : new HashMap<>();
final Map mergedProperties = new HashMap<>(existingProperties);
- mergedProperties.putAll(configuration.getPropertyValues());
+
+ // A null value reference is an explicit instruction to remove the property from the step rather than to
+ // store a null value. This matches the "null value removes the property" contract of the migration
+ // setProperties(...) and setValueReference(s)(...) APIs and of createWithOverrides(...).
+ for (final Map.Entry entry : configuration.getPropertyValues().entrySet()) {
+ if (entry.getValue() == null) {
+ mergedProperties.remove(entry.getKey());
+ } else {
+ mergedProperties.put(entry.getKey(), entry.getValue());
+ }
+ }
final StepConfiguration resolvedConfig = resolvePropertyValues(mergedProperties);
@@ -221,9 +231,20 @@ private StringLiteralValue resolveAssetReferences(final AssetReference assetRefe
public ConfigurationUpdateResult replaceProperties(final String stepName, final StepConfiguration configuration) {
writeLock.lock();
try {
- final StepConfiguration resolvedConfig = resolvePropertyValues(configuration.getPropertyValues());
+ // A null value reference removes the property rather than storing a null value, so the replacement never
+ // retains a property whose value reference is null.
+ final Map replacementProperties = new HashMap<>();
+ for (final Map.Entry entry : configuration.getPropertyValues().entrySet()) {
+ if (entry.getValue() == null) {
+ continue;
+ }
+
+ replacementProperties.put(entry.getKey(), entry.getValue());
+ }
+
+ final StepConfiguration resolvedConfig = resolvePropertyValues(replacementProperties);
- final StepConfiguration updatedStepConfig = new StepConfiguration(new HashMap<>(configuration.getPropertyValues()));
+ final StepConfiguration updatedStepConfig = new StepConfiguration(new HashMap<>(replacementProperties));
final StepConfiguration existingStepConfig = this.propertyConfigurations.put(stepName, updatedStepConfig);
final StepConfiguration existingResolvedStepConfig = this.resolvedPropertyConfigurations.put(stepName, resolvedConfig);
diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardConnectorMigrationManager.java b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardConnectorMigrationManager.java
new file mode 100644
index 000000000000..2859684d615b
--- /dev/null
+++ b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardConnectorMigrationManager.java
@@ -0,0 +1,820 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.components.connector;
+
+import org.apache.nifi.annotation.behavior.Stateful;
+import org.apache.nifi.components.ConfigurableComponent;
+import org.apache.nifi.components.connector.migration.MigratableConnector;
+import org.apache.nifi.components.state.Scope;
+import org.apache.nifi.components.state.StateManager;
+import org.apache.nifi.connectable.Connection;
+import org.apache.nifi.connectable.Port;
+import org.apache.nifi.controller.FlowController;
+import org.apache.nifi.controller.ProcessorNode;
+import org.apache.nifi.controller.ScheduledState;
+import org.apache.nifi.controller.flow.FlowManager;
+import org.apache.nifi.controller.queue.QueueSize;
+import org.apache.nifi.controller.service.ControllerServiceNode;
+import org.apache.nifi.controller.service.ControllerServiceState;
+import org.apache.nifi.flow.ExternalControllerServiceReference;
+import org.apache.nifi.flow.VersionedComponentState;
+import org.apache.nifi.flow.VersionedExternalFlow;
+import org.apache.nifi.flow.VersionedExternalFlowMetadata;
+import org.apache.nifi.flow.VersionedNodeState;
+import org.apache.nifi.flow.synchronization.VersionedComponentStateValidator;
+import org.apache.nifi.groups.ProcessGroup;
+import org.apache.nifi.nar.NarCloseable;
+import org.apache.nifi.registry.flow.RegisteredFlow;
+import org.apache.nifi.registry.flow.RegisteredFlowSnapshot;
+import org.apache.nifi.registry.flow.RegisteredFlowSnapshotMetadata;
+import org.apache.nifi.registry.flow.VersionControlInformation;
+import org.apache.nifi.registry.flow.VersionedFlowState;
+import org.apache.nifi.registry.flow.VersionedFlowStatus;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.BooleanSupplier;
+
+public class StandardConnectorMigrationManager implements ConnectorMigrationManager {
+ private static final Logger logger = LoggerFactory.getLogger(StandardConnectorMigrationManager.class);
+
+ private static final String MIGRATED_SOURCE_PREFIX = "(Migrated) ";
+
+ private final FlowController flowController;
+ private final ConnectorFlowSnapshotProvider snapshotProvider;
+
+ public StandardConnectorMigrationManager(final FlowController flowController, final ConnectorFlowSnapshotProvider snapshotProvider) {
+ this.flowController = Objects.requireNonNull(flowController, "FlowController is required");
+ this.snapshotProvider = Objects.requireNonNull(snapshotProvider, "ConnectorFlowSnapshotProvider is required");
+ }
+
+ @Override
+ public List listMigrationSources(final String connectorId) {
+ final ConnectorNode connector = getRequiredConnector(connectorId);
+ final List migrationSources = new ArrayList<>();
+
+ for (final ProcessGroup processGroup : getCandidateSourceGroups()) {
+ buildMigrationSourceForListing(connector, processGroup).ifPresent(migrationSources::add);
+ }
+
+ return migrationSources;
+ }
+
+ /**
+ * Builds a {@link ConnectorMigrationSource} for the given Process Group when it is structurally compatible with the
+ * target Connector. Returns {@link Optional#empty()} when the Process Group fails a hard filter (not under version
+ * control, already managed by a Connector, or rejected by the Connector's own structural compatibility check).
+ * Returns a populated source — possibly with {@link ConnectorMigrationSource#isReadyForMigration()} set to
+ * {@code false} and an ineligibility reason — when the group is compatible but currently in a state that requires
+ * user remediation before migration can proceed.
+ */
+ private Optional buildMigrationSourceForListing(final ConnectorNode connector, final ProcessGroup processGroup) {
+ final VersionControlInformation versionControlInformation = processGroup.getVersionControlInformation();
+ if (versionControlInformation == null) {
+ return Optional.empty();
+ }
+ if (processGroup.getConnectorIdentifier().isPresent()) {
+ return Optional.empty();
+ }
+
+ final VersionedExternalFlow sourceFlow = obtainSourceFlowForListing(processGroup);
+ if (!isConnectorMigrationSupported(connector, sourceFlow)) {
+ return Optional.empty();
+ }
+
+ final List stateIneligibilityReasons = getStateIneligibilityReasons(processGroup, versionControlInformation, sourceFlow);
+ return Optional.of(toMigrationSource(processGroup, versionControlInformation, stateIneligibilityReasons));
+ }
+
+ @Override
+ public void verifyEligibility(final String connectorId, final String processGroupId) {
+ final ConnectorNode connector = getRequiredConnector(connectorId);
+ final ProcessGroup processGroup = getRequiredSourceProcessGroup(processGroupId);
+ final String ineligibilityReason = getIneligibilityReason(connector, processGroup);
+ if (ineligibilityReason != null) {
+ throw new IllegalStateException(ineligibilityReason);
+ }
+ }
+
+ @Override
+ public void verifyConnectorReadyForMigration(final String connectorId) {
+ final ConnectorNode connector = getRequiredConnector(connectorId);
+ verifyConnectorCanReceiveMigration(connector);
+ verifyTargetIsAtInitialFlow(connector);
+ flowController.getConnectorRepository().verifyMigration(connectorId);
+ }
+
+ @Override
+ public void migrateFromVersionedFlow(final String connectorId, final String processGroupId, final VersionedExternalFlow sourceFlow,
+ final BooleanSupplier cancellationCheck) throws FlowUpdateException {
+ // Mark the migration in progress so a concurrent flow synchronization does not reclaim the assets this
+ // migration copies before the managed Process Group is rebuilt to reference them. The marker is cleared in
+ // the finally block whether the migration succeeds, fails, or rolls back.
+ flowController.getConnectorRepository().beginMigration(connectorId);
+ try {
+ performMigration(connectorId, processGroupId, sourceFlow, cancellationCheck);
+ } finally {
+ flowController.getConnectorRepository().endMigration(connectorId);
+ }
+ }
+
+ private void performMigration(final String connectorId, final String processGroupId, final VersionedExternalFlow sourceFlow,
+ final BooleanSupplier cancellationCheck) throws FlowUpdateException {
+ final BooleanSupplier cancellation = cancellationCheck == null ? () -> false : cancellationCheck;
+ final ConnectorNode connector = getRequiredConnector(connectorId);
+ verifyConnectorCanReceiveMigration(connector);
+ verifyTargetIsAtInitialFlow(connector);
+ // Give the ConnectorConfigurationProvider the chance to reject the migration before any flow manipulation
+ // begins, so a rejection aborts the migration without exercising the rollback machinery.
+ flowController.getConnectorRepository().verifyMigration(connectorId);
+ validateMigrationSource(sourceFlow);
+
+ final boolean localMigration = processGroupId != null;
+ final ProcessGroup sourceProcessGroup = localMigration ? getRequiredSourceProcessGroup(processGroupId) : null;
+ // Seed the migration context with a working clone of the connector's active configuration. The connector's
+ // setProperties/replaceProperties calls during migrateConfiguration(...) mutate this clone directly, so it
+ // always holds the fully-merged result. The active configuration is left untouched until commit.
+ final MutableConnectorConfigurationContext workingConfiguration = connector.getActiveFlowContext().getConfigurationContext().clone();
+ final StandardFrameworkConnectorMigrationContext migrationContext = new StandardFrameworkConnectorMigrationContext(
+ connectorId,
+ sourceFlow,
+ localMigration,
+ connector.getActiveFlowContext(),
+ workingConfiguration,
+ flowController.getAssetManager(),
+ flowController.getConnectorRepository(),
+ flowController.getStateManagerProvider(),
+ flowController
+ );
+
+ final Connector rawConnector = connector.getConnector();
+ if (!(rawConnector instanceof final MigratableConnector migratableConnector)) {
+ throw new FlowUpdateException("Connector " + connectorId + " does not support migration.");
+ }
+
+ try (final NarCloseable ignored = NarCloseable.withComponentNarLoader(flowController.getExtensionManager(), rawConnector.getClass(), connectorId)) {
+ if (!migratableConnector.isMigrationSupported(migrationContext)) {
+ throw new FlowUpdateException("Connector " + connectorId + " does not support migration from the provided source flow.");
+ }
+ }
+
+ // Phase 1: drive migrateConfiguration(...) -> applyMigratedConfiguration(...). The connector records the
+ // configuration it wants on the other side of migration by mutating the working configuration clone seeded
+ // into the migration context; the framework then drives Connector.applyUpdate(workingContext, activeContext)
+ // so the managed Process Group is rebuilt from that merged configuration. The active configuration itself is
+ // intentionally not written here: the merged configuration is held until the state phase below succeeds, then
+ // committed by commitMigratedConfiguration(...). That ordering keeps rollback simple - any failure in either
+ // phase can be recovered by restoring the initial flow because the active configuration was never mutated.
+ try (final NarCloseable ignored = NarCloseable.withComponentNarLoader(flowController.getExtensionManager(), rawConnector.getClass(), connectorId)) {
+ flowController.getConnectorRepository().syncAssetsFromProvider(connector);
+ migratableConnector.migrateConfiguration(migrationContext);
+
+ if (cancellation.getAsBoolean()) {
+ throw new FlowUpdateException("Migration of Connector " + connectorId + " was cancelled after migrateConfiguration() completed.");
+ }
+ } catch (final FlowUpdateException e) {
+ failMigration(connector, migrationContext);
+ throw e;
+ } catch (final Exception e) {
+ failMigration(connector, migrationContext);
+ throw new FlowUpdateException("Failed to migrate Connector " + connectorId + " from the provided source flow", e);
+ }
+
+ final ConnectorConfiguration mergedConfiguration;
+ try {
+ mergedConfiguration = connector.applyMigratedConfiguration(migrationContext.getMergedConfiguration());
+ } catch (final FlowUpdateException e) {
+ failMigration(connector, migrationContext);
+ throw e;
+ } catch (final Exception e) {
+ failMigration(connector, migrationContext);
+ throw new FlowUpdateException("Failed to apply migrated configuration for Connector " + connectorId, e);
+ }
+
+ // Phase 2: drive migrateState(...) -> drain staged component-state writes -> apply each entry to the live
+ // StateManager. The managed Process Group has been rebuilt by applyMigratedConfiguration(...) above, so the
+ // managed components exist before the connector tries to record state for them. Wrong-phase calls
+ // (setProperties/replaceProperties) now throw IllegalStateException from the context; updateFlow(...) on
+ // the active flow context still throws via the MigrationFlowContext wrapper.
+ migrationContext.setPhase(StandardFrameworkConnectorMigrationContext.Phase.STATE);
+ try (final NarCloseable ignored = NarCloseable.withComponentNarLoader(flowController.getExtensionManager(), rawConnector.getClass(), connectorId)) {
+ migratableConnector.migrateState(migrationContext);
+
+ if (cancellation.getAsBoolean()) {
+ throw new FlowUpdateException("Migration of Connector " + connectorId + " was cancelled after migrateState() completed.");
+ }
+ } catch (final FlowUpdateException e) {
+ failMigration(connector, migrationContext);
+ throw e;
+ } catch (final Exception e) {
+ failMigration(connector, migrationContext);
+ throw new FlowUpdateException("Failed to migrate state for Connector " + connectorId, e);
+ }
+
+ final Map stagedStates = migrationContext.drainStagedComponentStates();
+ try {
+ applyMigratedComponentStates(connector, stagedStates);
+ } catch (final Exception e) {
+ failMigration(connector, migrationContext);
+ throw new FlowUpdateException("Failed to apply migrated component state for Connector " + connectorId, e);
+ }
+
+ // Commit: both phases have succeeded, so persist the merged configuration onto the active configuration.
+ // From this point on the migration is durable across restarts: inheritConfiguration(...) on the next load will
+ // rebuild the managed Process Group from the committed configuration via the same Connector.applyUpdate(...)
+ // path used here.
+ try {
+ connector.commitMigratedConfiguration(mergedConfiguration);
+ } catch (final Exception e) {
+ failMigration(connector, migrationContext);
+ throw new FlowUpdateException("Failed to commit migrated configuration for Connector " + connectorId, e);
+ }
+
+ migrationContext.setPhase(StandardFrameworkConnectorMigrationContext.Phase.COMPLETED);
+
+ // The migration is durable now that the merged configuration is committed, so notify the
+ // ConnectorConfigurationProvider that the migration completed. The source Process Group identifier is null for
+ // an uploaded-payload migration. A failure here is logged rather than rolled back, because rolling back a
+ // committed migration would silently resurrect it on the next restart.
+ try {
+ flowController.getConnectorRepository().notifyMigrationComplete(connectorId, processGroupId);
+ } catch (final Exception e) {
+ logger.warn("Connector {} migration completed and is durable, but notifying the configuration provider of completion failed", connectorId, e);
+ }
+
+ // Finalize: the migration is already durable at this point because the merged configuration has been committed
+ // onto the active configuration. The remaining work is cosmetic - disabling and renaming the source Process
+ // Group so the operator can tell it apart from the new managed flow. Failures (or a late cancellation) below
+ // are logged but no longer trigger a rollback because rolling back a committed migration would leave the
+ // active configuration migrated while the managed Process Group was reset to the initial flow, which would
+ // silently resurrect the migration on the next restart.
+ if (cancellation.getAsBoolean()) {
+ logger.warn("Migration of Connector {} was cancelled after the merged configuration was committed; the migration is durable but the source Process Group was not finalized", connectorId);
+ return;
+ }
+
+ if (sourceProcessGroup != null) {
+ try {
+ disableAndRenameSourceProcessGroup(sourceProcessGroup);
+ } catch (final Exception e) {
+ logger.warn("Failed to finalize source Process Group {} after a successful migration of Connector {}; manual cleanup may be required",
+ sourceProcessGroup.getIdentifier(), connectorId, e);
+ }
+ }
+ }
+
+ /**
+ * Applies the staged component-state writes drained from {@code migrateState(...)} onto the live components in
+ * the connector's managed Process Group. For each entry the framework:
+ *
+ * - resolves the managed component by its versioned identifier;
+ * - looks up the component's runtime instance identifier and fetches its {@link StateManager} via
+ * {@link org.apache.nifi.components.state.StateManagerProvider#getStateManager(String)
+ * StateManagerProvider.getStateManager(...)};
+ * - writes the cluster-scope state, when the component declares {@link Scope#CLUSTER} on its
+ * {@code @Stateful} annotation;
+ * - writes the local-scope state for this node, when the component declares {@link Scope#LOCAL}. The
+ * local-state entry is picked by node ordinal so a clustered migration installs the right per-node state
+ * on each node, matching the framework's normal versioned-flow synchronizer.
+ *
+ * Skips components whose {@code @Stateful} annotation does not declare the requested scope and logs a warning,
+ * matching the behavior of {@code StandardVersionedComponentSynchronizer.restoreComponentState(...)}.
+ *
+ *
+ * This loop applies entries sequentially and does not roll back partial writes on its own. The rollback path that
+ * runs when any entry throws (see {@link #clearConnectorComponentState}) wipes every managed component's
+ * StateManager state, so a partially-applied set of writes is cleared along with the rest of the migration's
+ * side effects rather than left behind.
+ *
+ */
+ private void applyMigratedComponentStates(final ConnectorNode connector, final Map stagedStates) throws IOException {
+ if (stagedStates.isEmpty()) {
+ return;
+ }
+
+ final FrameworkFlowContext activeFlowContext = connector.getActiveFlowContext();
+ if (activeFlowContext == null) {
+ throw new IllegalStateException("Cannot apply migrated component state for Connector " + connector.getIdentifier()
+ + " because it has no active flow context.");
+ }
+ final ProcessGroup managedGroup = activeFlowContext.getManagedProcessGroup();
+ if (managedGroup == null) {
+ throw new IllegalStateException("Cannot apply migrated component state for Connector " + connector.getIdentifier()
+ + " because its managed Process Group does not exist.");
+ }
+
+ final Map processorsByVersionedId = new HashMap<>();
+ for (final ProcessorNode processor : managedGroup.findAllProcessors()) {
+ processor.getVersionedComponentId().ifPresent(versionedId -> processorsByVersionedId.put(versionedId, processor));
+ }
+
+ final Map servicesByVersionedId = new HashMap<>();
+ for (final ControllerServiceNode service : managedGroup.findAllControllerServices()) {
+ service.getVersionedComponentId().ifPresent(versionedId -> servicesByVersionedId.put(versionedId, service));
+ }
+
+ final int localNodeOrdinal = flowController.getLocalNodeOrdinal();
+
+ for (final Map.Entry entry : stagedStates.entrySet()) {
+ final String versionedId = entry.getKey();
+ final VersionedComponentState desiredState = entry.getValue();
+
+ final ProcessorNode processor = processorsByVersionedId.get(versionedId);
+ if (processor != null) {
+ applyComponentState(processor.getIdentifier(), processor.getComponent(), desiredState, localNodeOrdinal);
+ continue;
+ }
+ final ControllerServiceNode service = servicesByVersionedId.get(versionedId);
+ if (service != null) {
+ applyComponentState(service.getIdentifier(), service.getControllerServiceImplementation(), desiredState, localNodeOrdinal);
+ continue;
+ }
+ logger.warn("Connector {} migrateState() requested state for component [{}] but no managed Processor or Controller Service with that versioned identifier exists; skipping",
+ connector.getIdentifier(), versionedId);
+ }
+ }
+
+ private void failMigration(final ConnectorNode connector, final StandardFrameworkConnectorMigrationContext migrationContext) {
+ migrationContext.setPhase(StandardFrameworkConnectorMigrationContext.Phase.COMPLETED);
+ rollbackMigration(connector, migrationContext);
+ }
+
+ private void applyComponentState(final String runtimeComponentId, final ConfigurableComponent component, final VersionedComponentState desiredState, final int localNodeOrdinal)
+ throws IOException {
+ final StateManager stateManager = flowController.getStateManagerProvider().getStateManager(runtimeComponentId);
+ if (stateManager == null) {
+ logger.warn("No StateManager registered for migrated component [{}]; skipping state write", runtimeComponentId);
+ return;
+ }
+
+ final Set declaredScopes = declaredStatefulScopes(component);
+
+ final Map clusterState = desiredState.getClusterState();
+ if (clusterState != null && !clusterState.isEmpty()) {
+ if (declaredScopes.contains(Scope.CLUSTER)) {
+ stateManager.setState(clusterState, Scope.CLUSTER);
+ } else {
+ logger.warn("Component [{}] does not declare @Stateful scope CLUSTER; skipping cluster state migration", runtimeComponentId);
+ }
+ }
+
+ final List localNodeStates = desiredState.getLocalNodeStates();
+ if (localNodeStates != null && !localNodeStates.isEmpty()) {
+ if (!declaredScopes.contains(Scope.LOCAL)) {
+ logger.warn("Component [{}] does not declare @Stateful scope LOCAL; skipping local state migration", runtimeComponentId);
+ return;
+ }
+ if (localNodeOrdinal < 0) {
+ logger.warn("Local node ordinal is unknown; skipping local state migration for component [{}]", runtimeComponentId);
+ return;
+ }
+ if (localNodeOrdinal >= localNodeStates.size()) {
+ logger.warn("Component [{}] migration has {} local node state entries but this node ordinal is {}; skipping local state migration",
+ runtimeComponentId, localNodeStates.size(), localNodeOrdinal);
+ return;
+ }
+ final VersionedNodeState nodeState = localNodeStates.get(localNodeOrdinal);
+ if (nodeState == null || nodeState.getState() == null || nodeState.getState().isEmpty()) {
+ return;
+ }
+ stateManager.setState(nodeState.getState(), Scope.LOCAL);
+ }
+ }
+
+ private static Set declaredStatefulScopes(final ConfigurableComponent component) {
+ if (component == null) {
+ return Set.of();
+ }
+ final Stateful stateful = component.getClass().getAnnotation(Stateful.class);
+ if (stateful == null) {
+ return Set.of();
+ }
+ return EnumSet.copyOf(Arrays.asList(stateful.scopes()));
+ }
+
+ private void rollbackMigration(final ConnectorNode connector, final StandardFrameworkConnectorMigrationContext migrationContext) {
+ clearConnectorComponentState(connector);
+
+ final Set copiedAssetIds = migrationContext.getCopiedAssetIds();
+ if (!copiedAssetIds.isEmpty()) {
+ try {
+ flowController.getConnectorRepository().deleteAssets(connector.getIdentifier(), copiedAssetIds);
+ } catch (final Exception e) {
+ logger.warn("Failed to delete copied assets {} for Connector {} during migration rollback", copiedAssetIds, connector.getIdentifier(), e);
+ }
+ }
+
+ try {
+ connector.loadInitialFlow();
+ } catch (final FlowUpdateException e) {
+ // Do not propagate the rollback failure: the caller's primary need is to see why the migration originally
+ // failed, not how the recovery attempt failed. The operator can read the rollback failure from the log.
+ logger.error("Failed to restore Connector {} to its initial state after migration failure; manual cleanup may be required",
+ connector.getIdentifier(), e);
+ return;
+ }
+
+ flowController.getConnectorRepository().discardWorkingConfiguration(connector);
+ }
+
+ private void clearConnectorComponentState(final ConnectorNode connector) {
+ // Migration is only permitted on a Connector whose active flow has not been modified from its initial flow.
+ // Rollback restores that initial flow immediately after this call, so clearing state for every Processor and
+ // Controller Service currently in the managed flow is acceptable: any component carried over from the initial
+ // flow is rebuilt fresh by loadInitialFlow(), and any component introduced by the failed migration is
+ // discarded. Clearing the whole managed flow this way avoids the brittle approach of trying to map source-flow
+ // versioned identifiers to runtime component identifiers.
+ final FrameworkFlowContext activeFlowContext = connector.getActiveFlowContext();
+ if (activeFlowContext == null) {
+ return;
+ }
+
+ final ProcessGroup managedGroup = activeFlowContext.getManagedProcessGroup();
+ if (managedGroup == null) {
+ return;
+ }
+
+ for (final ProcessorNode processor : managedGroup.findAllProcessors()) {
+ clearComponentState(processor.getIdentifier());
+ }
+ for (final ControllerServiceNode controllerService : managedGroup.findAllControllerServices()) {
+ clearComponentState(controllerService.getIdentifier());
+ }
+ }
+
+ private void clearComponentState(final String componentIdentifier) {
+ final StateManager stateManager = flowController.getStateManagerProvider().getStateManager(componentIdentifier);
+ if (stateManager == null) {
+ return;
+ }
+
+ try {
+ stateManager.clear(Scope.LOCAL);
+ } catch (final Exception e) {
+ logger.warn("Failed to clear LOCAL state for component {} during migration rollback", componentIdentifier, e);
+ }
+
+ try {
+ stateManager.clear(Scope.CLUSTER);
+ } catch (final Exception e) {
+ logger.warn("Failed to clear CLUSTER state for component {} during migration rollback", componentIdentifier, e);
+ }
+ }
+
+ private ConnectorNode getRequiredConnector(final String connectorId) {
+ final ConnectorNode connector = flowController.getConnectorRepository().getConnector(connectorId, ConnectorSyncMode.LOCAL_ONLY);
+ if (connector == null) {
+ throw new IllegalArgumentException("Could not find Connector with ID " + connectorId);
+ }
+
+ return connector;
+ }
+
+ private ProcessGroup getRequiredSourceProcessGroup(final String processGroupId) {
+ final ProcessGroup rootGroup = flowController.getFlowManager().getRootGroup();
+ final ProcessGroup processGroup = rootGroup == null ? null : rootGroup.findProcessGroup(processGroupId);
+ if (processGroup == null) {
+ throw new IllegalArgumentException("Could not find Process Group with ID " + processGroupId);
+ }
+
+ return processGroup;
+ }
+
+ private List getCandidateSourceGroups() {
+ final FlowManager flowManager = flowController.getFlowManager();
+ final ProcessGroup rootGroup = flowManager.getRootGroup();
+ if (rootGroup == null) {
+ return Collections.emptyList();
+ }
+
+ return new ArrayList<>(rootGroup.findAllProcessGroups());
+ }
+
+ private String getIneligibilityReason(final ConnectorNode connector, final ProcessGroup processGroup) {
+ final VersionControlInformation versionControlInformation = processGroup.getVersionControlInformation();
+ if (versionControlInformation == null) {
+ return "The Process Group is not under version control.";
+ }
+
+ if (processGroup.getConnectorIdentifier().isPresent()) {
+ return "The Process Group is managed by another Connector and cannot be used as a migration source.";
+ }
+
+ final VersionedExternalFlow sourceFlow = obtainSourceFlowForListing(processGroup);
+
+ final List stateIneligibilityReasons = getStateIneligibilityReasons(processGroup, versionControlInformation, sourceFlow);
+ if (!stateIneligibilityReasons.isEmpty()) {
+ // Each entry describes a distinct condition the user can address. Joining with newlines keeps the
+ // individual reasons identifiable (e.g. "Running or enabled processor count: 8.\nQueued FlowFile count: 3.")
+ // rather than collapsing them into a single run-on sentence when they are surfaced to the operator.
+ return String.join(System.lineSeparator(), stateIneligibilityReasons);
+ }
+
+ if (!isConnectorMigrationSupported(connector, sourceFlow)) {
+ return "The Connector does not support migration from this Process Group.";
+ }
+
+ return null;
+ }
+
+ /**
+ * Reports every condition that currently prevents the Process Group from being used as a migration source.
+ * Reasons describe conditions the user can remediate (stopping processors, draining queues, refreshing version
+ * control, etc.) without changing the structural compatibility of the source flow. The returned list contains
+ * one entry per applicable condition so the user can address them together; an empty list means the Process
+ * Group is in a state that allows migration. Callers must ensure the supplied {@code versionControlInformation}
+ * matches the {@code processGroup}; this method does not tolerate a null value.
+ *
+ * @return ordered list of state-based ineligibility reasons, or an empty list if the Process Group is in a state
+ * that allows migration
+ */
+ private List getStateIneligibilityReasons(final ProcessGroup processGroup, final VersionControlInformation versionControlInformation, final VersionedExternalFlow sourceFlow) {
+ final List reasons = new ArrayList<>();
+
+ final VersionedFlowStatus versionedFlowStatus = versionControlInformation.getStatus();
+ final VersionedFlowState state = versionedFlowStatus == null ? null : versionedFlowStatus.getState();
+ if (state != VersionedFlowState.UP_TO_DATE) {
+ reasons.add(describeIneligibleVersionedFlowState(state));
+ }
+
+ final int runningProcessorCount = countRunningProcessors(processGroup);
+ if (runningProcessorCount > 0) {
+ reasons.add("Running or enabled processor count: " + runningProcessorCount + ".");
+ }
+
+ final int enabledServiceCount = countEnabledControllerServices(processGroup);
+ if (enabledServiceCount > 0) {
+ reasons.add("Enabled controller service count: " + enabledServiceCount + ".");
+ }
+
+ final long queuedFlowFileCount = countQueuedFlowFiles(processGroup);
+ if (queuedFlowFileCount > 0) {
+ reasons.add("Queued FlowFile count: " + queuedFlowFileCount + ".");
+ }
+
+ final int externalControllerServiceCount = countExternalControllerServices(sourceFlow);
+ if (externalControllerServiceCount > 0) {
+ reasons.add("Controller service count referenced from outside the Process Group: " + externalControllerServiceCount + ".");
+ }
+
+ return reasons;
+ }
+
+ private boolean isConnectorMigrationSupported(final ConnectorNode connector, final VersionedExternalFlow sourceFlow) {
+ // isMigrationSupported is a side-effect-free predicate wrapped by EligibilityConnectorMigrationContext, which
+ // rejects every write API. The working configuration is therefore never mutated here, but the context still
+ // requires one, so hand it a throwaway clone of the connector's active configuration.
+ final MutableConnectorConfigurationContext workingConfiguration = connector.getActiveFlowContext().getConfigurationContext().clone();
+ final StandardFrameworkConnectorMigrationContext underlyingContext = new StandardFrameworkConnectorMigrationContext(
+ connector.getIdentifier(),
+ sourceFlow,
+ true,
+ connector.getActiveFlowContext(),
+ workingConfiguration,
+ flowController.getAssetManager(),
+ flowController.getConnectorRepository(),
+ flowController.getStateManagerProvider(),
+ flowController
+ );
+ final FrameworkConnectorMigrationContext migrationContext = new EligibilityConnectorMigrationContext(underlyingContext);
+ return connector.isMigrationSupported(migrationContext);
+ }
+
+ private static String describeIneligibleVersionedFlowState(final VersionedFlowState state) {
+ if (state == null) {
+ return "There are no version control state details available for this Process Group.";
+ }
+
+ return switch (state) {
+ case STALE -> "The versioned flow is stale; bring it back to the latest published version before migrating.";
+ case LOCALLY_MODIFIED -> "There are local modifications relative to the registered flow; revert or publish them before migrating.";
+ case LOCALLY_MODIFIED_AND_STALE -> "The versioned flow is stale and has local modifications; revert them and refresh to the latest version before migrating.";
+ case SYNC_FAILURE -> "Synchronization with the flow registry has failed; restore connectivity and refresh the version control status before migrating.";
+ default -> "The versioned flow is not up to date with its registered flow.";
+ };
+ }
+
+ private void verifyConnectorCanReceiveMigration(final ConnectorNode connector) {
+ if (connector.getCurrentState() != ConnectorState.STOPPED || connector.getDesiredState() != ConnectorState.STOPPED) {
+ throw new IllegalStateException("Connector " + connector.getIdentifier() + " must be stopped before it can be migrated.");
+ }
+ }
+
+ private void verifyTargetIsAtInitialFlow(final ConnectorNode connector) {
+ if (connector.isModified()) {
+ throw new IllegalStateException("Connector " + connector.getIdentifier()
+ + " has been modified since it was created; migration would overwrite those modifications.");
+ }
+ }
+
+ private void validateMigrationSource(final VersionedExternalFlow sourceFlow) {
+ final Map externalControllerServices =
+ Optional.ofNullable(sourceFlow.getExternalControllerServices()).orElse(Collections.emptyMap());
+ if (!externalControllerServices.isEmpty()) {
+ throw new IllegalStateException("Connector cannot reference services outside its managed flow.");
+ }
+
+ final int connectedNodeCount = flowController.getConnectedNodeCount();
+ VersionedComponentStateValidator.validateLocalStateTopology(sourceFlow.getFlowContents(), connectedNodeCount);
+ }
+
+ private void disableAndRenameSourceProcessGroup(final ProcessGroup processGroup) {
+ disableSourceProcessors(processGroup);
+ disableSourceControllerServices(processGroup);
+ disableSourcePorts(processGroup);
+
+ final String currentName = processGroup.getName();
+ if (currentName != null && currentName.startsWith(MIGRATED_SOURCE_PREFIX)) {
+ return;
+ }
+
+ processGroup.setName(MIGRATED_SOURCE_PREFIX + currentName);
+ }
+
+ private void disableSourceProcessors(final ProcessGroup processGroup) {
+ for (final ProcessorNode processor : processGroup.findAllProcessors()) {
+ if (processor.getDesiredState() == ScheduledState.DISABLED) {
+ continue;
+ }
+
+ final ProcessGroup parent = processor.getProcessGroup();
+ if (parent == null) {
+ continue;
+ }
+
+ try {
+ parent.disableProcessor(processor);
+ } catch (final Exception e) {
+ logger.warn("Failed to disable processor {} while finalizing migrated source Process Group {}", processor.getIdentifier(), processGroup.getIdentifier(), e);
+ }
+ }
+ }
+
+ private void disableSourceControllerServices(final ProcessGroup processGroup) {
+ final List enabledServices = new ArrayList<>();
+ for (final ControllerServiceNode controllerService : processGroup.findAllControllerServices()) {
+ if (controllerService.getState() != ControllerServiceState.DISABLED) {
+ enabledServices.add(controllerService);
+ }
+ }
+
+ if (enabledServices.isEmpty()) {
+ return;
+ }
+
+ try {
+ flowController.getControllerServiceProvider().disableControllerServicesAsync(enabledServices);
+ } catch (final Exception e) {
+ logger.warn("Failed to disable controller services while finalizing migrated source Process Group {}", processGroup.getIdentifier(), e);
+ }
+ }
+
+ private void disableSourcePorts(final ProcessGroup processGroup) {
+ for (final Port inputPort : processGroup.findAllInputPorts()) {
+ if (inputPort.getScheduledState() == ScheduledState.DISABLED) {
+ continue;
+ }
+
+ final ProcessGroup parent = inputPort.getProcessGroup();
+ if (parent == null) {
+ continue;
+ }
+
+ try {
+ parent.disableInputPort(inputPort);
+ } catch (final Exception e) {
+ logger.warn("Failed to disable input port {} while finalizing migrated source Process Group {}", inputPort.getIdentifier(), processGroup.getIdentifier(), e);
+ }
+ }
+
+ for (final Port outputPort : processGroup.findAllOutputPorts()) {
+ if (outputPort.getScheduledState() == ScheduledState.DISABLED) {
+ continue;
+ }
+
+ final ProcessGroup parent = outputPort.getProcessGroup();
+ if (parent == null) {
+ continue;
+ }
+
+ try {
+ parent.disableOutputPort(outputPort);
+ } catch (final Exception e) {
+ logger.warn("Failed to disable output port {} while finalizing migrated source Process Group {}", outputPort.getIdentifier(), processGroup.getIdentifier(), e);
+ }
+ }
+ }
+
+ private int countRunningProcessors(final ProcessGroup processGroup) {
+ int count = 0;
+ for (final ProcessorNode processor : processGroup.findAllProcessors()) {
+ final ScheduledState physicalScheduledState = processor.getPhysicalScheduledState();
+ if (physicalScheduledState != ScheduledState.STOPPED && physicalScheduledState != ScheduledState.DISABLED) {
+ count++;
+ }
+ }
+ return count;
+ }
+
+ private int countEnabledControllerServices(final ProcessGroup processGroup) {
+ int count = 0;
+ for (final ControllerServiceNode controllerService : processGroup.findAllControllerServices()) {
+ if (controllerService.getState() != ControllerServiceState.DISABLED) {
+ count++;
+ }
+ }
+ return count;
+ }
+
+ private long countQueuedFlowFiles(final ProcessGroup processGroup) {
+ long total = 0L;
+ for (final Connection connection : processGroup.findAllConnections()) {
+ final QueueSize queueSize = connection.getFlowFileQueue().size();
+ total += queueSize.getObjectCount();
+ }
+ return total;
+ }
+
+ private static int countExternalControllerServices(final VersionedExternalFlow sourceFlow) {
+ final Map externalControllerServices = sourceFlow.getExternalControllerServices();
+ return externalControllerServices == null ? 0 : externalControllerServices.size();
+ }
+
+ private ConnectorMigrationSource toMigrationSource(final ProcessGroup processGroup, final VersionControlInformation versionControlInformation, final List ineligibilityReasons) {
+ final ConnectorMigrationSource migrationSource = new ConnectorMigrationSource();
+ migrationSource.setProcessGroupId(processGroup.getIdentifier());
+ migrationSource.setProcessGroupName(processGroup.getName());
+ final ProcessGroup parent = processGroup.getParent();
+ migrationSource.setParentProcessGroupId(parent == null ? null : parent.getIdentifier());
+ migrationSource.setRegistryClientId(versionControlInformation.getRegistryIdentifier());
+ migrationSource.setBucketId(versionControlInformation.getBucketIdentifier());
+ migrationSource.setFlowId(versionControlInformation.getFlowIdentifier());
+ migrationSource.setFlowName(versionControlInformation.getFlowName());
+ migrationSource.setVersion(versionControlInformation.getVersion());
+ migrationSource.setReadyForMigration(ineligibilityReasons.isEmpty());
+ migrationSource.setIneligibilityReasons(List.copyOf(ineligibilityReasons));
+ return migrationSource;
+ }
+
+ private VersionedExternalFlow obtainSourceFlowForListing(final ProcessGroup processGroup) {
+ // Listing-time eligibility checks intentionally exclude component state. Materializing component state is
+ // expensive and is only required for the actual migration. The Connector's isMigrationSupported(...) must rely
+ // solely on structural information (processor types, parameter names, exported metadata) at listing time;
+ // per-state precondition checks belong inside the Connector's own migrateConfiguration/migrateState implementations.
+ final RegisteredFlowSnapshot snapshot = snapshotProvider.getCurrentFlowSnapshotByGroupId(processGroup.getIdentifier(), true, false);
+ return createExternalFlow(snapshot);
+ }
+
+ public VersionedExternalFlow createExternalFlow(final RegisteredFlowSnapshot flowSnapshot) {
+ final VersionedExternalFlowMetadata externalFlowMetadata = new VersionedExternalFlowMetadata();
+ final RegisteredFlowSnapshotMetadata snapshotMetadata = flowSnapshot.getSnapshotMetadata();
+ if (snapshotMetadata != null) {
+ externalFlowMetadata.setAuthor(snapshotMetadata.getAuthor());
+ externalFlowMetadata.setBucketIdentifier(snapshotMetadata.getBucketIdentifier());
+ externalFlowMetadata.setComments(snapshotMetadata.getComments());
+ externalFlowMetadata.setFlowIdentifier(snapshotMetadata.getFlowIdentifier());
+ externalFlowMetadata.setTimestamp(snapshotMetadata.getTimestamp());
+ externalFlowMetadata.setVersion(snapshotMetadata.getVersion());
+ }
+
+ final RegisteredFlow registeredFlow = flowSnapshot.getFlow();
+ if (registeredFlow == null) {
+ externalFlowMetadata.setFlowName(flowSnapshot.getFlowContents().getName());
+ } else {
+ externalFlowMetadata.setFlowName(registeredFlow.getName());
+ }
+
+ final VersionedExternalFlow externalFlow = new VersionedExternalFlow();
+ externalFlow.setFlowContents(flowSnapshot.getFlowContents());
+ externalFlow.setExternalControllerServices(flowSnapshot.getExternalControllerServices());
+ externalFlow.setParameterContexts(flowSnapshot.getParameterContexts());
+ externalFlow.setParameterProviders(flowSnapshot.getParameterProviders());
+ externalFlow.setMetadata(externalFlowMetadata);
+ return externalFlow;
+ }
+}
diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardConnectorNode.java b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardConnectorNode.java
index c9f8c9d3769f..2f55ccd7d09b 100644
--- a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardConnectorNode.java
+++ b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardConnectorNode.java
@@ -29,6 +29,12 @@
import org.apache.nifi.components.ValidationResult;
import org.apache.nifi.components.connector.components.FlowContext;
import org.apache.nifi.components.connector.components.ParameterContextFacade;
+import org.apache.nifi.components.connector.migration.ConnectorMigrationContext;
+import org.apache.nifi.components.connector.migration.MigratableConnector;
+import org.apache.nifi.components.state.Scope;
+import org.apache.nifi.components.state.StateManager;
+import org.apache.nifi.components.state.StateManagerProvider;
+import org.apache.nifi.components.state.StateMap;
import org.apache.nifi.components.validation.DisabledServiceValidationResult;
import org.apache.nifi.components.validation.ValidationState;
import org.apache.nifi.components.validation.ValidationStatus;
@@ -94,6 +100,7 @@ public class StandardConnectorNode implements ConnectorNode, GroupedComponent {
private final String identifier;
private final FlowManager flowManager;
private final ExtensionManager extensionManager;
+ private final StateManagerProvider stateManagerProvider;
private final Authorizable parentAuthorizable;
private final ConnectorDetails connectorDetails;
private final String componentType;
@@ -120,16 +127,16 @@ public class StandardConnectorNode implements ConnectorNode, GroupedComponent {
private volatile Map customLoggingAttributes = Map.of();
private volatile Map mergedLoggingAttributes = Map.of();
-
public StandardConnectorNode(final String identifier, final FlowManager flowManager, final ExtensionManager extensionManager,
- final Authorizable parentAuthorizable, final ConnectorDetails connectorDetails, final String componentType, final String componentCanonicalClass,
- final MutableConnectorConfigurationContext configurationContext,
+ final StateManagerProvider stateManagerProvider, final Authorizable parentAuthorizable, final ConnectorDetails connectorDetails,
+ final String componentType, final String componentCanonicalClass, final MutableConnectorConfigurationContext configurationContext,
final ConnectorStateTransition stateTransition, final FlowContextFactory flowContextFactory,
final ConnectorValidationTrigger validationTrigger, final boolean extensionMissing) {
this.identifier = identifier;
this.flowManager = flowManager;
this.extensionManager = extensionManager;
+ this.stateManagerProvider = stateManagerProvider;
this.parentAuthorizable = parentAuthorizable;
this.connectorDetails = connectorDetails;
this.componentType = componentType;
@@ -305,6 +312,67 @@ public void prepareForUpdate() throws FlowUpdateException {
}
}
+ @Override
+ public ConnectorConfiguration applyMigratedConfiguration(final MutableConnectorConfigurationContext mergedConfiguration) throws FlowUpdateException {
+ if (initializationContext == null) {
+ throw new IllegalStateException("Cannot apply migrated configuration because " + this + " has not been initialized yet.");
+ }
+ Objects.requireNonNull(mergedConfiguration, "Merged configuration is required");
+
+ logger.debug("Applying migrated configuration to {}", this);
+ // mergedConfiguration is the working configuration the connector mutated through the migration context. It was
+ // seeded with a clone of this connector's active configuration, so it already holds the fully-merged result of
+ // every setProperties/replaceProperties call the connector made. The clone is the working flow context that
+ // drives Connector.applyUpdate(working, active) below. The live active configuration is intentionally left
+ // untouched at this point: the framework only commits the merged configuration onto the active configuration
+ // after the state-migration phase has also succeeded (see commitMigratedConfiguration). This means a failure in
+ // migrateState(...) or in applying the staged component states can be rolled back simply by restoring the
+ // initial flow, without having to revert any active-configuration mutations.
+ mergedConfiguration.resolvePropertyValues();
+
+ final FrameworkFlowContext migrationWorkingContext = flowContextFactory.createWorkingFlowContext(
+ identifier, connectorDetails.getComponentLog(), mergedConfiguration, activeFlowContext.getBundle());
+
+ try (final NarCloseable ignored = NarCloseable.withComponentNarLoader(extensionManager, getConnector().getClass(), getIdentifier())) {
+ // Same applyUpdate(...) call signature the framework uses for regular working->active updates. The connector
+ // calls getInitializationContext().updateFlow(activeFlowContext, ...) inside applyUpdate(...); the
+ // activeFlowContext here is the real FrameworkFlowContext, so updateFlow(...) installs the rebuilt managed
+ // flow normally (it is only the migration-scoped wrapper handed to migrateConfiguration/migrateState that
+ // refuses updateFlow(...)).
+ getConnector().applyUpdate(migrationWorkingContext, activeFlowContext);
+ } catch (final FlowUpdateException e) {
+ throw e;
+ } catch (final Throwable t) {
+ throw new FlowUpdateException("Failed to apply migrated configuration for " + this, t);
+ }
+
+ logger.info("Applied migrated configuration to {}; managed Process Group has been rebuilt and the merged"
+ + " configuration is pending commit", this);
+ return mergedConfiguration.toConnectorConfiguration();
+ }
+
+ @Override
+ public void commitMigratedConfiguration(final ConnectorConfiguration mergedConfiguration) {
+ if (initializationContext == null) {
+ throw new IllegalStateException("Cannot commit migrated configuration because " + this + " has not been initialized yet.");
+ }
+ Objects.requireNonNull(mergedConfiguration, "Merged configuration is required");
+
+ // Write the merged configuration onto the active configuration so the migration outcome is persisted to
+ // flow.json.gz. Each step is written via replaceProperties so the resulting active configuration matches the
+ // merged configuration exactly, including any properties the connector removed during migration. This is the
+ // durability boundary of the two-phase migration: once this commit runs the migration is part of the persisted
+ // flow and will be restored on every restart via inheritConfiguration(...).
+ for (final NamedStepConfiguration stepConfig : mergedConfiguration.getNamedStepConfigurations()) {
+ activeFlowContext.getConfigurationContext().replaceProperties(stepConfig.stepName(), stepConfig.configuration());
+ }
+
+ resetValidationState();
+ recreateWorkingFlowContext();
+
+ logger.info("Committed migrated configuration onto active configuration for {}", this);
+ }
+
@Override
public void inheritConfiguration(final List activeConfig, final List workingConfig,
final Bundle flowContextBundle) throws FlowUpdateException {
@@ -1054,6 +1122,183 @@ public void loadInitialFlow() throws FlowUpdateException {
recreateWorkingFlowContext();
}
+ @Override
+ public boolean isMigrationSupported(final ConnectorMigrationContext context) {
+ try (final NarCloseable ignored = NarCloseable.withComponentNarLoader(extensionManager, getConnector().getClass(), getIdentifier())) {
+ final Connector connector = getConnector();
+ if (!(connector instanceof final MigratableConnector migratableConnector)) {
+ return false;
+ }
+ return migratableConnector.isMigrationSupported(context);
+ } catch (final Exception e) {
+ getComponentLog().warn("Failed to evaluate whether migration is supported for {}; assuming migration is not supported", context.getSourceFlow(), e);
+ return false;
+ }
+ }
+
+ /**
+ * Determines whether the Connector has been modified since it was created. Rather than comparing the managed flow
+ * structure (which a Connector derives entirely from its configuration), this considers the Connector modified when
+ * either of the following holds for the Active or Working configuration:
+ *
+ * - any configured property differs from the property's declared default value; or
+ * - any Processor or Controller Service in the managed flow has stored component state.
+ *
+ * A configuration whose properties are all at their defaults produces the initial flow, so an unmodified Connector
+ * can be safely migrated without discarding user changes. Any deviation in configuration, or any component state
+ * accumulated by running the flow, means migration would overwrite those changes and is therefore disallowed.
+ *
+ * @return {@code true} if the Connector's Active or Working configuration deviates from its defaults or any managed
+ * component has stored state; {@code false} otherwise
+ */
+ @Override
+ public boolean isModified() {
+ final Map> defaultValuesByStep = buildDefaultValuesByStep();
+
+ if (configurationDiffersFromDefaults(activeFlowContext, defaultValuesByStep)
+ || configurationDiffersFromDefaults(workingFlowContext, defaultValuesByStep)) {
+ return true;
+ }
+
+ return hasComponentState(activeFlowContext) || hasComponentState(workingFlowContext);
+ }
+
+ /**
+ * Builds a mapping of configuration step name to the declared default value of each property within that step, as
+ * defined by the Connector's {@link ConfigurationStep configuration steps}. A property with no default is
+ * represented by a {@code null} value so that an unset (or explicitly null) configured value compares equal to it.
+ */
+ private Map> buildDefaultValuesByStep() {
+ final Map> defaultValuesByStep = new HashMap<>();
+
+ final List configurationSteps = getConfigurationSteps();
+ if (configurationSteps == null) {
+ return defaultValuesByStep;
+ }
+
+ for (final ConfigurationStep configurationStep : configurationSteps) {
+ final Map propertyDefaults = new HashMap<>();
+ for (final ConnectorPropertyGroup propertyGroup : configurationStep.getPropertyGroups()) {
+ for (final ConnectorPropertyDescriptor descriptor : propertyGroup.getProperties()) {
+ propertyDefaults.put(descriptor.getName(), descriptor.getDefaultValue());
+ }
+ }
+ defaultValuesByStep.put(configurationStep.getName(), propertyDefaults);
+ }
+
+ return defaultValuesByStep;
+ }
+
+ /**
+ * Determines whether the configuration held by the given flow context deviates from the Connector's default
+ * configuration. A property is treated as modified when it is configured with a String literal whose value differs
+ * from the property's declared default, or with a populated Secret or Asset reference (neither of which can
+ * represent a default). A structurally-empty Secret or Asset reference is a placeholder for an unset property and is
+ * not treated as a modification.
+ */
+ private boolean configurationDiffersFromDefaults(final FrameworkFlowContext flowContext, final Map> defaultValuesByStep) {
+ if (flowContext == null) {
+ return false;
+ }
+
+ final MutableConnectorConfigurationContext configurationContext = flowContext.getConfigurationContext();
+ if (configurationContext == null) {
+ return false;
+ }
+
+ final ConnectorConfiguration configuration = configurationContext.toConnectorConfiguration();
+ for (final NamedStepConfiguration namedStepConfiguration : configuration.getNamedStepConfigurations()) {
+ final Map propertyDefaults = defaultValuesByStep.getOrDefault(namedStepConfiguration.stepName(), Map.of());
+
+ for (final Map.Entry propertyEntry : namedStepConfiguration.configuration().getPropertyValues().entrySet()) {
+ final String propertyName = propertyEntry.getKey();
+ final ConnectorValueReference valueReference = propertyEntry.getValue();
+ if (valueReference == null) {
+ continue;
+ }
+
+ if (valueReference instanceof final StringLiteralValue stringLiteralValue) {
+ if (!Objects.equals(stringLiteralValue.getValue(), propertyDefaults.get(propertyName))) {
+ logger.debug("{} differs from its initial flow because property [{}] of configuration step [{}] is not set to its default value",
+ this, propertyName, namedStepConfiguration.stepName());
+ return true;
+ }
+ } else if (!isStructurallyEmptyReference(valueReference)) {
+ logger.debug("{} differs from its initial flow because property [{}] of configuration step [{}] is configured with a {} reference",
+ this, propertyName, namedStepConfiguration.stepName(), valueReference.getValueType());
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Determines whether the given non-{@code StringLiteralValue} reference is structurally empty, meaning it carries no
+ * actual referenced value and acts as a placeholder for an unset property rather than a configured one. A
+ * structurally-empty {@link SecretReference} has no provider or secret name; a structurally-empty
+ * {@link AssetReference} has no asset identifiers.
+ */
+ private boolean isStructurallyEmptyReference(final ConnectorValueReference valueReference) {
+ return switch (valueReference) {
+ case SecretReference secretReference -> isEmptySecretReference(secretReference);
+ case AssetReference assetReference -> assetReference.getAssetIdentifiers() == null || assetReference.getAssetIdentifiers().isEmpty();
+ default -> false;
+ };
+ }
+
+ /**
+ * Determines whether any Processor or Controller Service within the given flow context's managed Process Group has
+ * stored component state in either the local or cluster scope. A component with stored state has been run since the
+ * Connector was created, so the managed flow no longer reflects the Connector's initial flow.
+ */
+ private boolean hasComponentState(final FrameworkFlowContext flowContext) {
+ if (flowContext == null) {
+ return false;
+ }
+
+ final ProcessGroup managedProcessGroup = flowContext.getManagedProcessGroup();
+ if (managedProcessGroup == null) {
+ return false;
+ }
+
+ for (final ProcessorNode processor : managedProcessGroup.findAllProcessors()) {
+ if (componentHasStoredState(processor.getIdentifier())) {
+ logger.debug("{} differs from its initial flow because Processor [{}] has stored component state", this, processor.getIdentifier());
+ return true;
+ }
+ }
+
+ for (final ControllerServiceNode controllerService : managedProcessGroup.findAllControllerServices()) {
+ if (componentHasStoredState(controllerService.getIdentifier())) {
+ logger.debug("{} differs from its initial flow because Controller Service [{}] has stored component state", this, controllerService.getIdentifier());
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private boolean componentHasStoredState(final String componentIdentifier) {
+ final StateManager stateManager = stateManagerProvider.getStateManager(componentIdentifier);
+ if (stateManager == null) {
+ return false;
+ }
+
+ return hasStoredState(stateManager, Scope.LOCAL) || hasStoredState(stateManager, Scope.CLUSTER);
+ }
+
+ private boolean hasStoredState(final StateManager stateManager, final Scope scope) {
+ try {
+ final StateMap stateMap = stateManager.getState(scope);
+ return stateMap != null && !stateMap.toMap().isEmpty();
+ } catch (final IOException e) {
+ logger.warn("Failed to read {} state for a component of {} while checking whether it matches its initial flow; treating the component as having no state in this scope", scope, this, e);
+ return false;
+ }
+ }
+
private void stopComponents(final VersionedProcessGroup group) {
for (final VersionedProcessor processor : group.getProcessors()) {
if (processor.getScheduledState() == ScheduledState.RUNNING) {
@@ -1167,7 +1412,8 @@ private boolean isBundleResolvable(final String componentType, final Bundle curr
return availableBundles.size() == 1;
}
- private void recreateWorkingFlowContext() {
+ @Override
+ public void recreateWorkingFlowContext() {
destroyWorkingContext();
workingFlowContext = flowContextFactory.createWorkingFlowContext(identifier,
connectorDetails.getComponentLog(), activeFlowContext.getConfigurationContext(), activeFlowContext.getBundle());
@@ -1599,6 +1845,7 @@ public List getAvailableActions() {
actions.add(createDrainFlowFilesAction(stopped && !troubleshooting, dataQueued));
actions.add(createCancelDrainFlowFilesAction(currentState == ConnectorState.DRAINING));
actions.add(createApplyUpdatesAction(currentState, troubleshooting));
+ actions.add(createMigrateAction(stopped && !troubleshooting));
actions.add(createDeleteAction(stopped && !troubleshooting, dataQueued));
actions.add(createEnterTroubleshootingAction(currentState));
actions.add(createEndTroubleshootingAction());
@@ -1606,6 +1853,27 @@ public List getAvailableActions() {
return actions;
}
+ private ConnectorAction createMigrateAction(final boolean stopped) {
+ final boolean allowed;
+ final String reason;
+
+ if (!stopped) {
+ allowed = false;
+ reason = "Connector must be stopped";
+ } else if (!(getConnector() instanceof MigratableConnector)) {
+ allowed = false;
+ reason = "Connector does not support migration from a Versioned flow";
+ } else if (isModified()) {
+ allowed = false;
+ reason = "Connector has been modified since it was created; migration would overwrite those modifications";
+ } else {
+ allowed = true;
+ reason = null;
+ }
+
+ return new StandardConnectorAction("MIGRATE", "Migrate a Versioned flow's assets and configuration into this Connector", allowed, reason);
+ }
+
private boolean isStopped() {
final ConnectorState currentState = getCurrentState();
if (currentState == ConnectorState.STOPPED) {
diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardConnectorRepository.java b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardConnectorRepository.java
index 185ad7f100e1..8dce9108847e 100644
--- a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardConnectorRepository.java
+++ b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardConnectorRepository.java
@@ -31,8 +31,11 @@
import org.apache.nifi.flow.VersionedConnectorState;
import org.apache.nifi.flow.VersionedConnectorValueReference;
import org.apache.nifi.flow.VersionedProcessGroup;
+import org.apache.nifi.groups.ProcessGroup;
import org.apache.nifi.nar.ExtensionManager;
import org.apache.nifi.nar.NarCloseable;
+import org.apache.nifi.parameter.Parameter;
+import org.apache.nifi.parameter.ParameterContext;
import org.apache.nifi.util.BundleUtils;
import org.apache.nifi.util.ReflectionUtils;
import org.apache.nifi.web.api.dto.BundleDTO;
@@ -43,6 +46,7 @@
import java.io.InputStream;
import java.time.Duration;
import java.util.ArrayList;
+import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
@@ -71,6 +75,7 @@ public class StandardConnectorRepository implements ConnectorRepository {
private static final int CUSTOM_LOGGING_ATTRIBUTE_CARDINALITY_WARN_THRESHOLD = 5;
private final Map connectors = new ConcurrentHashMap<>();
+ private final Set connectorsBeingMigrated = ConcurrentHashMap.newKeySet();
private final FlowEngine lifecycleExecutor = new FlowEngine(8, "NiFi Connector Lifecycle");
private volatile FlowManager flowManager;
@@ -125,7 +130,7 @@ public ConnectorSyncResult syncConnector(final VersionedConnector versionedConne
final VersionedConnectorState proposedScheduledState = versionedConnector.getScheduledState();
logger.debug("syncConnector called for connector [{}]", connectorId);
- // Consult the provider for external state checks and working config
+ // Consult the provider for external state checks and any externally managed working configuration.
final ConnectorSyncDirective directive;
if (configurationProvider != null) {
try {
@@ -170,7 +175,6 @@ public ConnectorSyncResult syncConnector(final VersionedConnector versionedConne
}
// ALLOW: proceed with sync
- // Look up or create the connector node
ConnectorNode connector = connectors.get(connectorId);
final boolean isNewConnector = connector == null;
if (isNewConnector) {
@@ -200,7 +204,7 @@ public ConnectorSyncResult syncConnector(final VersionedConnector versionedConne
return ConnectorSyncResult.rejected(connector);
}
- // Determine effective name, working config, and ScheduledState
+ // Determine the connector name, working configuration, and ScheduledState that should win for this sync pass.
final ConnectorWorkingConfiguration providerConfig = directive.getWorkingConfiguration();
// Enrich provider-sourced SECRET_REFERENCE values with providerId before they are compared
@@ -252,8 +256,8 @@ public ConnectorSyncResult syncConnector(final VersionedConnector versionedConne
if (wasRunning) {
try {
stopConnector(connector);
- } catch (final Exception stopEx) {
- logger.error("{} also failed to stop after sync failure", connector, stopEx);
+ } catch (final Exception stopException) {
+ logger.error("{} also failed to stop after sync failure", connector, stopException);
}
}
return ConnectorSyncResult.failed(connector);
@@ -287,9 +291,25 @@ public ConnectorSyncResult syncConnector(final VersionedConnector versionedConne
connector.restoreTroubleshootingState();
}
+ // Asset cleanup is intentionally skipped while the Connector is in Troubleshooting mode. Troubleshooting is
+ // a transient state in which the user may have uploaded Assets that the experimental flow does not yet
+ // reference and may have temporarily removed references to the authoritative Assets. Deleting unreferenced
+ // Assets here would discard Assets the user still needs. Orphaned Assets are reclaimed by the next
+ // authoritative configuration change, which is only permitted once Troubleshooting mode has ended.
return ConnectorSyncResult.syncedConfigUnchanged(connector, effectiveScheduledState);
}
+ // Clean up assets after configuration has been inherited so that the Connector's flow contexts reflect the
+ // synchronized configuration; performing this before synchronization would treat still-referenced assets as
+ // unreferenced because the managed flow has not yet been populated. Skip cleanup while a migration is in
+ // progress for this Connector: a migration copies assets before it rebuilds the managed Process Group to
+ // reference them, so reclaiming unreferenced assets during that window could delete the just-copied assets.
+ if (connectorsBeingMigrated.contains(connectorId)) {
+ logger.debug("Skipping asset cleanup for {} because a migration is in progress", connector);
+ } else {
+ cleanUpAssets(connector);
+ }
+
return configChanged
? ConnectorSyncResult.synced(connector, effectiveScheduledState)
: ConnectorSyncResult.syncedConfigUnchanged(connector, effectiveScheduledState);
@@ -358,8 +378,6 @@ private ConnectorState waitForStableState(final ConnectorNode connector, final C
return current;
}
- // --- Configuration comparison ---
-
private boolean isConfigurationUpdated(final ConnectorNode existingConnector,
final List effectiveActiveConfig,
final List effectiveWorkingConfig) {
@@ -413,10 +431,10 @@ private boolean isConfigurationStepUpdated(final NamedStepConfiguration existing
for (final Map.Entry versionedEntry : versionedProperties.entrySet()) {
final String propertyName = versionedEntry.getKey();
- final VersionedConnectorValueReference versionedRef = versionedEntry.getValue();
- final ConnectorValueReference existingRef = existingProperties.get(propertyName);
+ final VersionedConnectorValueReference versionedValueReference = versionedEntry.getValue();
+ final ConnectorValueReference existingValueReference = existingProperties.get(propertyName);
- if (!valueReferencesEqual(versionedRef, existingRef)) {
+ if (!valueReferencesEqual(versionedValueReference, existingValueReference)) {
return true;
}
}
@@ -440,9 +458,9 @@ private boolean valueReferencesEqual(final VersionedConnectorValueReference vers
return switch (existingReference) {
case StringLiteralValue stringLiteral -> Objects.equals(stringLiteral.getValue(), versionedReference.getValue());
- case AssetReference assetRef -> Objects.equals(assetRef.getAssetIdentifiers(), versionedReference.getAssetIds());
- case SecretReference secretRef -> Objects.equals(secretRef.getProviderId(), versionedReference.getProviderId())
- && Objects.equals(secretRef.getSecretName(), versionedReference.getSecretName());
+ case AssetReference assetReference -> Objects.equals(assetReference.getAssetIdentifiers(), versionedReference.getAssetIds());
+ case SecretReference secretReference -> Objects.equals(secretReference.getProviderId(), versionedReference.getProviderId())
+ && Objects.equals(secretReference.getSecretName(), versionedReference.getSecretName());
};
}
@@ -706,7 +724,44 @@ private void waitForState(final ConnectorNode connector, final Set referencedAssetIds = new HashSet<>();
collectReferencedAssetIds(connector.getActiveFlowContext(), referencedAssetIds);
@@ -739,6 +794,11 @@ private void collectReferencedAssetIds(final FrameworkFlowContext flowContext, f
return;
}
+ final ProcessGroup managedProcessGroup = flowContext.getManagedProcessGroup();
+ if (managedProcessGroup != null) {
+ collectReferencedParameterContextAssetIds(managedProcessGroup.getParameterContext(), referencedAssetIds);
+ }
+
final ConnectorConfiguration configuration = flowContext.getConfigurationContext().toConnectorConfiguration();
for (final NamedStepConfiguration namedStepConfiguration : configuration.getNamedStepConfigurations()) {
final StepConfiguration stepConfiguration = namedStepConfiguration.configuration();
@@ -754,6 +814,23 @@ private void collectReferencedAssetIds(final FrameworkFlowContext flowContext, f
}
}
+ private void collectReferencedParameterContextAssetIds(final ParameterContext parameterContext, final Set referencedAssetIds) {
+ if (parameterContext == null) {
+ return;
+ }
+
+ for (final Parameter parameter : parameterContext.getEffectiveParameters().values()) {
+ final List referencedAssets = parameter.getReferencedAssets();
+ if (referencedAssets == null) {
+ continue;
+ }
+
+ for (final Asset referencedAsset : referencedAssets) {
+ referencedAssetIds.add(referencedAsset.getIdentifier());
+ }
+ }
+ }
+
@Override
public void updateConnector(final ConnectorNode connector, final String name) {
if (connector.getCurrentState() == ConnectorState.TROUBLESHOOTING) {
@@ -939,16 +1016,30 @@ public List getAssets(final String connectorId) {
public void deleteAssets(final String connectorId) {
final List assets = assetManager.getAssets(connectorId);
for (final Asset asset : assets) {
- try {
- if (configurationProvider != null) {
- // When a provider is configured, delegate entirely to the provider, which should delete from the AssetManager and sync to the external store.
- configurationProvider.deleteAsset(connectorId, asset.getIdentifier());
- } else {
- assetManager.deleteAsset(asset.getIdentifier());
- }
- } catch (final Exception e) {
- logger.warn("Failed to delete asset [nifiUuid={}] for connector [{}]", asset.getIdentifier(), connectorId, e);
+ deleteAsset(connectorId, asset.getIdentifier());
+ }
+ }
+
+ @Override
+ public void deleteAssets(final String connectorId, final Collection assetIdentifiers) {
+ if (assetIdentifiers == null || assetIdentifiers.isEmpty()) {
+ return;
+ }
+
+ for (final String assetIdentifier : assetIdentifiers) {
+ deleteAsset(connectorId, assetIdentifier);
+ }
+ }
+
+ private void deleteAsset(final String connectorId, final String assetIdentifier) {
+ try {
+ if (configurationProvider != null) {
+ configurationProvider.deleteAsset(connectorId, assetIdentifier);
+ } else {
+ assetManager.deleteAsset(assetIdentifier);
}
+ } catch (final Exception e) {
+ logger.warn("Failed to delete asset [nifiUuid={}] for connector [{}]", assetIdentifier, connectorId, e);
}
}
@@ -979,12 +1070,12 @@ private void syncFromProvider(final ConnectorNode connector) {
return;
}
- final ConnectorWorkingConfiguration config = externalConfig.get();
- if (config.getName() != null) {
- connector.setName(config.getName());
+ final ConnectorWorkingConfiguration externalWorkingConfiguration = externalConfig.get();
+ if (externalWorkingConfiguration.getName() != null) {
+ connector.setName(externalWorkingConfiguration.getName());
}
- final List workingFlowConfiguration = config.getWorkingFlowConfiguration();
+ final List workingFlowConfiguration = externalWorkingConfiguration.getWorkingFlowConfiguration();
if (workingFlowConfiguration == null) {
return;
@@ -1010,10 +1101,10 @@ private void syncFromProvider(final ConnectorNode connector) {
}
private ConnectorWorkingConfiguration buildWorkingConfiguration(final ConnectorNode connector) {
- final ConnectorWorkingConfiguration config = new ConnectorWorkingConfiguration();
- config.setName(connector.getName());
- config.setWorkingFlowConfiguration(buildVersionedConfigurationSteps(connector.getWorkingFlowContext()));
- return config;
+ final ConnectorWorkingConfiguration workingConfiguration = new ConnectorWorkingConfiguration();
+ workingConfiguration.setName(connector.getName());
+ workingConfiguration.setWorkingFlowConfiguration(buildVersionedConfigurationSteps(connector.getWorkingFlowContext()));
+ return workingConfiguration;
}
private List buildVersionedConfigurationSteps(final FrameworkFlowContext flowContext) {
@@ -1094,12 +1185,12 @@ private VersionedConnectorValueReference toVersionedValueReference(final Connect
switch (valueReference) {
case StringLiteralValue stringLiteral -> versionedReference.setValue(stringLiteral.getValue());
- case AssetReference assetRef -> versionedReference.setAssetIds(assetRef.getAssetIdentifiers());
- case SecretReference secretRef -> {
- versionedReference.setProviderId(secretRef.getProviderId());
- versionedReference.setProviderName(secretRef.getProviderName());
- versionedReference.setSecretName(secretRef.getSecretName());
- versionedReference.setFullyQualifiedSecretName(secretRef.getFullyQualifiedName());
+ case AssetReference assetReference -> versionedReference.setAssetIds(assetReference.getAssetIdentifiers());
+ case SecretReference secretReference -> {
+ versionedReference.setProviderId(secretReference.getProviderId());
+ versionedReference.setProviderName(secretReference.getProviderName());
+ versionedReference.setSecretName(secretReference.getSecretName());
+ versionedReference.setFullyQualifiedSecretName(secretReference.getFullyQualifiedName());
}
}
@@ -1111,9 +1202,9 @@ private StepConfiguration toStepConfiguration(final VersionedConfigurationStep s
final Map versionedProperties = step.getProperties();
if (versionedProperties != null) {
for (final Map.Entry entry : versionedProperties.entrySet()) {
- final VersionedConnectorValueReference versionedRef = entry.getValue();
- if (versionedRef != null) {
- propertyValues.put(entry.getKey(), toConnectorValueReference(versionedRef));
+ final VersionedConnectorValueReference versionedValueReference = entry.getValue();
+ if (versionedValueReference != null) {
+ propertyValues.put(entry.getKey(), toConnectorValueReference(versionedValueReference));
}
}
}
@@ -1154,16 +1245,25 @@ private void resolveSecretReferencesFromProvider(final List properties.values().stream())
- .filter(Objects::nonNull)
- .filter(ref -> ConnectorValueType.SECRET_REFERENCE.name().equals(ref.getValueType()))
- .filter(ref -> ref.getProviderId() == null)
- .filter(ref -> ref.getProviderName() != null)
- .forEach(ref -> ref.setProviderId(findProviderIdByName(ref.getProviderName())));
+ for (final VersionedConfigurationStep step : steps) {
+ if (step == null || step.getProperties() == null) {
+ continue;
+ }
+
+ for (final VersionedConnectorValueReference valueReference : step.getProperties().values()) {
+ if (valueReference == null) {
+ continue;
+ }
+ if (!ConnectorValueType.SECRET_REFERENCE.name().equals(valueReference.getValueType())) {
+ continue;
+ }
+ if (valueReference.getProviderId() != null || valueReference.getProviderName() == null) {
+ continue;
+ }
+
+ valueReference.setProviderId(findProviderIdByName(valueReference.getProviderName()));
+ }
+ }
}
/**
diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardFrameworkConnectorMigrationContext.java b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardFrameworkConnectorMigrationContext.java
new file mode 100644
index 000000000000..c1f9486b6ef7
--- /dev/null
+++ b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/StandardFrameworkConnectorMigrationContext.java
@@ -0,0 +1,328 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.components.connector;
+
+import org.apache.nifi.asset.Asset;
+import org.apache.nifi.asset.AssetManager;
+import org.apache.nifi.components.state.StateManagerProvider;
+import org.apache.nifi.controller.ClusterTopologyProvider;
+import org.apache.nifi.flow.VersionedComponentState;
+import org.apache.nifi.flow.VersionedExternalFlow;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+
+public class StandardFrameworkConnectorMigrationContext implements FrameworkConnectorMigrationContext {
+ private static final Logger logger = LoggerFactory.getLogger(StandardFrameworkConnectorMigrationContext.class);
+
+ /**
+ * Tracks the phase the migration is currently in. The framework transitions the phase via
+ * {@link #setPhase(Phase)} between calls to the two {@code MigratableConnector} methods so the context can
+ * enforce phase-scoping on its write APIs and refuse any out-of-phase calls. Once a migration finishes (success
+ * or failure), the phase is moved to {@link #COMPLETED} and every write call throws.
+ */
+ enum Phase {
+ CONFIGURATION,
+ STATE,
+ COMPLETED
+ }
+
+ private final String connectorId;
+ private final VersionedExternalFlow sourceFlow;
+ private final boolean localMigration;
+ private final FrameworkFlowContext activeFlowContext;
+ private final FrameworkFlowContext migrationFlowContext;
+ private final MutableConnectorConfigurationContext workingConfiguration;
+ private final AssetManager sourceAssetManager;
+ private final ConnectorRepository connectorRepository;
+ private final StateManagerProvider stateManagerProvider;
+ private final ClusterTopologyProvider clusterTopologyProvider;
+ private final Set copiedAssetIds = Collections.synchronizedSet(new java.util.LinkedHashSet<>());
+
+ private final Object stagingLock = new Object();
+ private final Map stagedComponentStates = new LinkedHashMap<>();
+ private volatile Phase phase = Phase.CONFIGURATION;
+
+ public StandardFrameworkConnectorMigrationContext(final String connectorId, final VersionedExternalFlow sourceFlow, final boolean localMigration,
+ final FrameworkFlowContext activeFlowContext, final MutableConnectorConfigurationContext workingConfiguration, final AssetManager sourceAssetManager,
+ final ConnectorRepository connectorRepository, final StateManagerProvider stateManagerProvider, final ClusterTopologyProvider clusterTopologyProvider) {
+ this.connectorId = connectorId;
+ this.sourceFlow = sourceFlow;
+ this.localMigration = localMigration;
+ this.activeFlowContext = activeFlowContext;
+ // Hand the connector a wrapped active flow context so any updateFlow(...) attempt from either migration
+ // phase fails immediately. The framework still has access to the unwrapped activeFlowContext for the
+ // applyUpdate(...) it drives between the two phases.
+ this.migrationFlowContext = activeFlowContext == null ? null : new MigrationFlowContext(activeFlowContext);
+ // The connector's configuration writes during migrateConfiguration(...) are applied directly to this working
+ // configuration, which is seeded by the framework with a clone of the connector's active configuration. The
+ // working configuration always holds the fully-merged result, so there is no separate per-step delta to track
+ // or replay. The framework reads the merged result back via getMergedConfiguration() and never mutates the
+ // active configuration until both migration phases have succeeded.
+ this.workingConfiguration = workingConfiguration;
+ this.sourceAssetManager = sourceAssetManager;
+ this.connectorRepository = connectorRepository;
+ this.stateManagerProvider = stateManagerProvider;
+ this.clusterTopologyProvider = clusterTopologyProvider;
+ }
+
+ @Override
+ public VersionedExternalFlow getSourceFlow() {
+ return sourceFlow;
+ }
+
+ @Override
+ public boolean isLocalMigration() {
+ return localMigration;
+ }
+
+ @Override
+ public FrameworkFlowContext getActiveFlowContext() {
+ return migrationFlowContext;
+ }
+
+ /**
+ * Returns the unwrapped active flow context owned by the framework. The connector never sees this object; only
+ * the migration manager calls it to feed the real {@code FrameworkFlowContext} into
+ * {@link Connector#applyUpdate(org.apache.nifi.components.connector.components.FlowContext,
+ * org.apache.nifi.components.connector.components.FlowContext) Connector.applyUpdate(...)}.
+ */
+ FrameworkFlowContext getUnwrappedActiveFlowContext() {
+ return activeFlowContext;
+ }
+
+ @Override
+ public AssetReference copyAssetFromSource(final String sourceAssetId) {
+ if (sourceAssetId == null || sourceAssetId.isBlank()) {
+ throw new IllegalArgumentException("Source asset identifier must be specified.");
+ }
+ if (!localMigration) {
+ throw new IllegalStateException("Source assets can only be copied for migrations from a local Versioned Process Group.");
+ }
+
+ final String copiedAssetId = UUID.nameUUIDFromBytes((connectorId + ":" + sourceAssetId).getBytes(StandardCharsets.UTF_8)).toString();
+ final Optional existingAsset = connectorRepository.getAsset(copiedAssetId);
+ if (existingAsset.isPresent() && existingAsset.get().getFile().isFile() && existingAsset.get().getFile().length() > 0L) {
+ copiedAssetIds.add(copiedAssetId);
+ return new AssetReference(Set.of(copiedAssetId));
+ }
+
+ final Optional sourceAsset = sourceAssetManager.getAsset(sourceAssetId);
+ if (sourceAsset.isEmpty()) {
+ logger.warn("Connector [{}] migration could not locate source asset [{}]; the asset will not be copied and the affected parameter "
+ + "will be left without an asset reference for the user to re-attach after migration.", connectorId, sourceAssetId);
+ return new AssetReference(Set.of());
+ }
+
+ try (final InputStream sourceContents = new FileInputStream(sourceAsset.get().getFile())) {
+ final Asset copiedAsset = connectorRepository.storeAsset(connectorId, copiedAssetId, sourceAsset.get().getName(), sourceContents);
+ copiedAssetIds.add(copiedAsset.getIdentifier());
+ return new AssetReference(Set.of(copiedAsset.getIdentifier()));
+ } catch (final IOException e) {
+ throw new IllegalStateException("Failed to copy source asset with identifier " + sourceAssetId, e);
+ }
+ }
+
+ @Override
+ public void setProperties(final String stepName, final Map propertyValues) {
+ requireConfigurationPhase("setProperties");
+ Objects.requireNonNull(stepName, "Configuration step name is required");
+ Objects.requireNonNull(propertyValues, "Property values are required");
+
+ // Merge the requested values onto the step as it currently stands on the working configuration. The working
+ // configuration's setProperties(...) overlays the new values on top of the existing ones, so properties the
+ // connector does not mention (including asset and secret references it inherited from its prior configuration)
+ // are preserved.
+ synchronized (stagingLock) {
+ workingConfiguration.setProperties(stepName, toStepConfiguration(propertyValues));
+ }
+ }
+
+ @Override
+ public void replaceProperties(final String stepName, final Map propertyValues) {
+ requireConfigurationPhase("replaceProperties");
+ Objects.requireNonNull(stepName, "Configuration step name is required");
+ Objects.requireNonNull(propertyValues, "Property values are required");
+
+ // Replace the entire step on the working configuration, dropping any property the connector does not include.
+ synchronized (stagingLock) {
+ workingConfiguration.replaceProperties(stepName, toStepConfiguration(propertyValues));
+ }
+ }
+
+ @Override
+ public void setValueReference(final String stepName, final String propertyName, final ConnectorValueReference valueReference) {
+ requireConfigurationPhase("setValueReference");
+ Objects.requireNonNull(stepName, "Configuration step name is required");
+ Objects.requireNonNull(propertyName, "Property name is required");
+
+ // A HashMap is used rather than Map.of(...) because the value reference may be null, signalling that the
+ // property should be removed, and Map.of(...) rejects null values.
+ final Map singlePropertyValue = new HashMap<>();
+ singlePropertyValue.put(propertyName, valueReference);
+ synchronized (stagingLock) {
+ workingConfiguration.setProperties(stepName, new StepConfiguration(singlePropertyValue));
+ }
+ }
+
+ @Override
+ public void setValueReferences(final String stepName, final Map valueReferences) {
+ requireConfigurationPhase("setValueReferences");
+ Objects.requireNonNull(stepName, "Configuration step name is required");
+ Objects.requireNonNull(valueReferences, "Value references are required");
+
+ // Merge the requested value references onto the step just as setProperties(...) merges string literal values,
+ // so references the connector does not mention are preserved. A null reference removes that property.
+ synchronized (stagingLock) {
+ workingConfiguration.setProperties(stepName, new StepConfiguration(new HashMap<>(valueReferences)));
+ }
+ }
+
+ private static StepConfiguration toStepConfiguration(final Map propertyValues) {
+ final Map converted = new HashMap<>();
+ for (final Map.Entry entry : propertyValues.entrySet()) {
+ // A null property value is carried through as a null value reference so the working configuration removes
+ // the property, honoring the "null value removes the property" contract of setProperties(...) and
+ // replaceProperties(...).
+ final String propertyValue = entry.getValue();
+ converted.put(entry.getKey(), propertyValue == null ? null : new StringLiteralValue(propertyValue));
+ }
+ return new StepConfiguration(converted);
+ }
+
+ @Override
+ public void setComponentState(final String managedComponentId, final VersionedComponentState state) {
+ requireStatePhase("setComponentState");
+ if (managedComponentId == null || managedComponentId.isBlank()) {
+ throw new IllegalArgumentException("Managed component identifier is required");
+ }
+ Objects.requireNonNull(state, "Versioned component state is required");
+
+ synchronized (stagingLock) {
+ stagedComponentStates.put(managedComponentId, state);
+ }
+ }
+
+ @Override
+ public AssetManager getSourceAssetManager() {
+ return sourceAssetManager;
+ }
+
+ @Override
+ public ConnectorRepository getConnectorRepository() {
+ return connectorRepository;
+ }
+
+ @Override
+ public StateManagerProvider getStateManagerProvider() {
+ return stateManagerProvider;
+ }
+
+ @Override
+ public ClusterTopologyProvider getClusterTopologyProvider() {
+ return clusterTopologyProvider;
+ }
+
+ @Override
+ public Set getCopiedAssetIds() {
+ synchronized (copiedAssetIds) {
+ return Set.copyOf(copiedAssetIds);
+ }
+ }
+
+ /**
+ * Returns the current migration phase. Visible for use by {@link StandardConnectorMigrationManager} and unit
+ * tests that exercise phase enforcement.
+ */
+ Phase getPhase() {
+ return phase;
+ }
+
+ /**
+ * Transitions the migration phase. Only {@link StandardConnectorMigrationManager} should call this. Each
+ * transition moves the phase strictly forward (CONFIGURATION -> STATE -> COMPLETED, with CONFIGURATION -> COMPLETED
+ * also allowed as the failure shortcut). Backward or self transitions are rejected so a connector cannot re-enter
+ * an earlier write phase after the framework has moved on.
+ */
+ void setPhase(final Phase nextPhase) {
+ Objects.requireNonNull(nextPhase, "Phase is required");
+ final Phase currentPhase = this.phase;
+ if (!isAllowedTransition(currentPhase, nextPhase)) {
+ throw new IllegalStateException("Cannot transition migration phase from " + currentPhase + " to " + nextPhase
+ + "; phases must move strictly forward (CONFIGURATION -> STATE -> COMPLETED)");
+ }
+ this.phase = nextPhase;
+ }
+
+ private static boolean isAllowedTransition(final Phase currentPhase, final Phase nextPhase) {
+ return switch (currentPhase) {
+ case CONFIGURATION -> nextPhase == Phase.STATE || nextPhase == Phase.COMPLETED;
+ case STATE -> nextPhase == Phase.COMPLETED;
+ case COMPLETED -> false;
+ };
+ }
+
+ /**
+ * Returns the merged working configuration the connector produced during {@code migrateConfiguration(...)}. The
+ * framework hands this to {@code ConnectorNode.applyMigratedConfiguration(...)} so the managed Process Group can be
+ * rebuilt from it. This is a live reference to the working configuration, not a copy; the framework does not write
+ * it onto the active configuration until both migration phases have succeeded.
+ */
+ MutableConnectorConfigurationContext getMergedConfiguration() {
+ synchronized (stagingLock) {
+ return workingConfiguration;
+ }
+ }
+
+ /**
+ * Returns the staged component-state writes and clears the internal buffer. Called by the framework once after
+ * {@code migrateState(...)} returns so it can apply the recorded state to the managed components.
+ */
+ Map drainStagedComponentStates() {
+ synchronized (stagingLock) {
+ final Map snapshot = new LinkedHashMap<>(stagedComponentStates);
+ stagedComponentStates.clear();
+ return snapshot;
+ }
+ }
+
+ private void requireConfigurationPhase(final String methodName) {
+ final Phase currentPhase = this.phase;
+ if (currentPhase != Phase.CONFIGURATION) {
+ throw new IllegalStateException(methodName + "() is only valid during MigratableConnector.migrateConfiguration(); current migration phase is " + currentPhase);
+ }
+ }
+
+ private void requireStatePhase(final String methodName) {
+ final Phase currentPhase = this.phase;
+ if (currentPhase != Phase.STATE) {
+ throw new IllegalStateException(methodName + "() is only valid during MigratableConnector.migrateState(); current migration phase is " + currentPhase);
+ }
+ }
+}
diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/facades/standalone/StandaloneParameterContextFacade.java b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/facades/standalone/StandaloneParameterContextFacade.java
index 2599269d3554..b4387c74503f 100644
--- a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/facades/standalone/StandaloneParameterContextFacade.java
+++ b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/components/connector/facades/standalone/StandaloneParameterContextFacade.java
@@ -109,6 +109,7 @@ private Map createParameterMap(final Collection createParameterValues(final ParameterContext context) {
final List parameterValues = new ArrayList<>();
for (final Parameter parameter : context.getParameters().values()) {
+ final List referencedAssets = parameter.getReferencedAssets();
final ParameterValue.Builder parameterValueBuilder = new ParameterValue.Builder()
.name(parameter.getDescriptor().getName())
.sensitive(parameter.getDescriptor().isSensitive())
- .value(parameter.getValue());
+ .value(referencedAssets == null || referencedAssets.isEmpty() ? parameter.getValue() : null);
- parameter.getReferencedAssets().forEach(parameterValueBuilder::addReferencedAsset);
+ if (referencedAssets != null) {
+ referencedAssets.forEach(parameterValueBuilder::addReferencedAsset);
+ }
parameterValues.add(parameterValueBuilder.build());
}
diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/flow/StandardFlowManager.java b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/flow/StandardFlowManager.java
index 902f6301e31c..eefa16c8d1e5 100644
--- a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/flow/StandardFlowManager.java
+++ b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/main/java/org/apache/nifi/controller/flow/StandardFlowManager.java
@@ -777,6 +777,7 @@ public ConnectorNode createConnector(final String type, final String id, final B
.bundleCoordinate(coordinate)
.extensionManager(extensionManager)
.controllerServiceProvider(flowController.getControllerServiceProvider())
+ .stateManagerProvider(flowController.getStateManagerProvider())
.managedProcessGroup(managedRootGroup)
.activeConfigurationContext(activeConfigurationContext)
.flowContextFactory(flowContextFactory)
diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/StandardConnectorNodeIT.java b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/StandardConnectorNodeIT.java
index 0ae7e272a895..53ff90dab7c4 100644
--- a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/StandardConnectorNodeIT.java
+++ b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/StandardConnectorNodeIT.java
@@ -322,6 +322,22 @@ public void testParameterUpdateRestartsReferencingProcessors() throws FlowUpdate
assertEquals("Hi.", parameterContext.getParameter("Text").orElseThrow().getValue());
}
+ // A Connector that only sets parameter values (as this one does) keeps the same managed flow structure regardless of
+ // its configuration. isModified() must therefore key off the Connector's configuration rather than the flow
+ // structure: a freshly created Connector is not modified, but changing a property value away from its default must
+ // be detected as a modification so that migration is no longer offered.
+ @Test
+ public void testIsModifiedReflectsConfigurationChanges() throws FlowUpdateException {
+ final ConnectorNode connectorNode = initializeParameterConnector();
+ assertFalse(connectorNode.isModified());
+
+ final StepConfiguration textStepConfig = new StepConfiguration(Map.of("Text", new StringLiteralValue("Hi.")));
+ final NamedStepConfiguration textNamedStep = new NamedStepConfiguration("Text Configuration", textStepConfig);
+ configure(connectorNode, new ConnectorConfiguration(Set.of(textNamedStep)));
+
+ assertTrue(connectorNode.isModified());
+ }
+
@Test
@Timeout(10)
public void testOnPropertyModifiedCalledOnApplyUpdate() throws FlowUpdateException {
diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestEligibilityConnectorMigrationContext.java b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestEligibilityConnectorMigrationContext.java
new file mode 100644
index 000000000000..f92da9fda8fb
--- /dev/null
+++ b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestEligibilityConnectorMigrationContext.java
@@ -0,0 +1,69 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.components.connector;
+
+import org.apache.nifi.asset.AssetManager;
+import org.apache.nifi.components.state.StateManagerProvider;
+import org.apache.nifi.controller.ClusterTopologyProvider;
+import org.apache.nifi.flow.VersionedExternalFlow;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+
+public class TestEligibilityConnectorMigrationContext {
+
+ private static final String CONNECTOR_ID = "connector-1";
+
+ @Test
+ public void testEligibilityContextRejectsCopyAssetFromSource() {
+ final VersionedExternalFlow sourceFlow = mock(VersionedExternalFlow.class);
+ final FrameworkFlowContext activeFlowContext = mock(FrameworkFlowContext.class);
+ final AssetManager sourceAssetManager = mock(AssetManager.class);
+ final ConnectorRepository connectorRepository = mock(ConnectorRepository.class);
+ final StateManagerProvider stateManagerProvider = mock(StateManagerProvider.class);
+ final ClusterTopologyProvider clusterTopologyProvider = mock(ClusterTopologyProvider.class);
+
+ final MutableConnectorConfigurationContext workingConfiguration = mock(MutableConnectorConfigurationContext.class);
+ final StandardFrameworkConnectorMigrationContext underlying = new StandardFrameworkConnectorMigrationContext(
+ CONNECTOR_ID, sourceFlow, true, activeFlowContext, workingConfiguration, sourceAssetManager, connectorRepository,
+ stateManagerProvider, clusterTopologyProvider);
+ final EligibilityConnectorMigrationContext eligibilityContext = new EligibilityConnectorMigrationContext(underlying);
+
+ final IllegalStateException thrown = assertThrows(IllegalStateException.class,
+ () -> eligibilityContext.copyAssetFromSource("any-asset-id"));
+ assertTrue(thrown.getMessage().contains("copyAssetFromSource"));
+ assertTrue(thrown.getMessage().contains("isMigrationSupported"));
+
+ // Every other accessor must return whatever the underlying context returns.
+ assertSame(sourceFlow, eligibilityContext.getSourceFlow());
+ assertTrue(eligibilityContext.isLocalMigration());
+ // The underlying StandardFrameworkConnectorMigrationContext wraps the active flow context in a
+ // MigrationFlowContext so any updateFlow() call from the eligibility path throws as well; the eligibility
+ // wrapper just propagates that read-only handle.
+ assertTrue(eligibilityContext.getActiveFlowContext() instanceof MigrationFlowContext,
+ "Eligibility context must propagate the read-only MigrationFlowContext returned by the underlying migration context");
+ assertSame(sourceAssetManager, eligibilityContext.getSourceAssetManager());
+ assertSame(connectorRepository, eligibilityContext.getConnectorRepository());
+ assertSame(stateManagerProvider, eligibilityContext.getStateManagerProvider());
+ assertSame(clusterTopologyProvider, eligibilityContext.getClusterTopologyProvider());
+ assertEquals(underlying.getCopiedAssetIds(), eligibilityContext.getCopiedAssetIds());
+ }
+}
diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestStandardConnectorConfigurationContext.java b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestStandardConnectorConfigurationContext.java
index 0265a70e6989..8ce5e977f47c 100644
--- a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestStandardConnectorConfigurationContext.java
+++ b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestStandardConnectorConfigurationContext.java
@@ -140,6 +140,7 @@ public void testSetPropertiesRemovesKeyWhenValueIsNull() {
assertEquals("value1", context.getProperty("step1", "key1").getValue());
assertNull(context.getProperty("step1", "key2").getValue());
assertEquals("value3", context.getProperty("step1", "key3").getValue());
+ assertEquals(Set.of("key1", "key3"), context.getPropertyNames("step1"));
}
@Test
@@ -217,6 +218,7 @@ public void testComplexMergingScenario() {
assertEquals("6", context.getProperty("step1", "f").getValue());
assertEquals("7", context.getProperty("step1", "g").getValue());
assertEquals("8", context.getProperty("step1", "h").getValue());
+ assertEquals(Set.of("a", "c", "d", "e", "f", "g", "h"), context.getPropertyNames("step1"));
}
@Test
diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestStandardConnectorMigrationManager.java b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestStandardConnectorMigrationManager.java
new file mode 100644
index 000000000000..db0c9443b6ae
--- /dev/null
+++ b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestStandardConnectorMigrationManager.java
@@ -0,0 +1,1023 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.components.connector;
+
+import org.apache.nifi.asset.AssetManager;
+import org.apache.nifi.components.connector.migration.ConnectorMigrationContext;
+import org.apache.nifi.components.connector.migration.MigratableConnector;
+import org.apache.nifi.components.state.Scope;
+import org.apache.nifi.components.state.StateManager;
+import org.apache.nifi.components.state.StateManagerProvider;
+import org.apache.nifi.connectable.Connection;
+import org.apache.nifi.connectable.Port;
+import org.apache.nifi.controller.FlowController;
+import org.apache.nifi.controller.ProcessorNode;
+import org.apache.nifi.controller.ScheduledState;
+import org.apache.nifi.controller.flow.FlowManager;
+import org.apache.nifi.controller.queue.FlowFileQueue;
+import org.apache.nifi.controller.queue.QueueSize;
+import org.apache.nifi.controller.service.ControllerServiceNode;
+import org.apache.nifi.controller.service.ControllerServiceProvider;
+import org.apache.nifi.controller.service.ControllerServiceState;
+import org.apache.nifi.flow.ExternalControllerServiceReference;
+import org.apache.nifi.flow.VersionedComponentState;
+import org.apache.nifi.flow.VersionedExternalFlow;
+import org.apache.nifi.flow.VersionedNodeState;
+import org.apache.nifi.flow.VersionedProcessGroup;
+import org.apache.nifi.flow.VersionedProcessor;
+import org.apache.nifi.groups.ProcessGroup;
+import org.apache.nifi.nar.ExtensionManager;
+import org.apache.nifi.registry.flow.RegisteredFlow;
+import org.apache.nifi.registry.flow.RegisteredFlowSnapshot;
+import org.apache.nifi.registry.flow.VersionControlInformation;
+import org.apache.nifi.registry.flow.VersionedFlowState;
+import org.apache.nifi.registry.flow.VersionedFlowStatus;
+import org.junit.jupiter.api.Test;
+
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.withSettings;
+
+public class TestStandardConnectorMigrationManager {
+
+ private static final String CONNECTOR_ID = "connector-1";
+ private static final String SOURCE_GROUP_ID = "source-group";
+ private static final String SOURCE_GROUP_NAME = "Source Flow";
+
+ @Test
+ public void testMigrateFromUploadedPayloadRejectsLocalStateBeyondConnectedNodeCount() {
+ final FlowController flowController = createFlowController(2);
+ final ConnectorNode connectorNode = wireFreshConnector(flowController, CONNECTOR_ID);
+
+ final StandardConnectorMigrationManager migrationManager = newMigrationManager(flowController);
+ final VersionedExternalFlow sourceFlow = createSourceFlowWithLocalStateCount(3);
+
+ final IllegalStateException exception = assertThrows(IllegalStateException.class,
+ () -> migrationManager.migrateFromVersionedFlow(CONNECTOR_ID, null, sourceFlow));
+ assertTrue(exception.getMessage().contains("Cannot import flow"), exception.getMessage());
+
+ verify(connectorNode, never()).commitMigratedConfiguration(any(ConnectorConfiguration.class));
+ }
+
+ @Test
+ public void testMigrateFromLocalSourceDisablesAndRenamesProcessGroupAfterSuccess() throws Exception {
+ final FlowController flowController = createFlowController(1);
+ final ProcessGroup sourceProcessGroup = wireSourceProcessGroup(flowController, SOURCE_GROUP_ID, SOURCE_GROUP_NAME);
+ final ConnectorNode connectorNode = wireFreshConnector(flowController, CONNECTOR_ID);
+
+ final StandardConnectorMigrationManager migrationManager = newMigrationManager(flowController);
+ final VersionedExternalFlow sourceFlow = createSourceFlowWithLocalStateCount(1);
+
+ migrationManager.migrateFromVersionedFlow(CONNECTOR_ID, SOURCE_GROUP_ID, sourceFlow);
+
+ verify(connectorNode).commitMigratedConfiguration(any(ConnectorConfiguration.class));
+ verify(sourceProcessGroup).setName("(Migrated) " + SOURCE_GROUP_NAME);
+ }
+
+ @Test
+ public void testMigrateFromLocalSourceDisablesSourceInputAndOutputPorts() throws Exception {
+ final FlowController flowController = createFlowController(1);
+ final ProcessGroup sourceProcessGroup = wireSourceProcessGroup(flowController, SOURCE_GROUP_ID, SOURCE_GROUP_NAME);
+
+ // Ports may live in the source group or one of its descendants, so each port reports its own parent group and
+ // the manager must disable it through that parent.
+ final ProcessGroup portParent = mock(ProcessGroup.class);
+ final Port runningInputPort = mockSourcePort(portParent, "input-port-1", ScheduledState.RUNNING);
+ final Port disabledInputPort = mockSourcePort(portParent, "input-port-2", ScheduledState.DISABLED);
+ when(sourceProcessGroup.findAllInputPorts()).thenReturn(List.of(runningInputPort, disabledInputPort));
+
+ final Port runningOutputPort = mockSourcePort(portParent, "output-port-1", ScheduledState.RUNNING);
+ when(sourceProcessGroup.findAllOutputPorts()).thenReturn(List.of(runningOutputPort));
+
+ wireFreshConnector(flowController, CONNECTOR_ID);
+
+ final StandardConnectorMigrationManager migrationManager = newMigrationManager(flowController);
+ final VersionedExternalFlow sourceFlow = createSourceFlowWithLocalStateCount(1);
+
+ migrationManager.migrateFromVersionedFlow(CONNECTOR_ID, SOURCE_GROUP_ID, sourceFlow);
+
+ verify(portParent).disableInputPort(runningInputPort);
+ verify(portParent).disableOutputPort(runningOutputPort);
+
+ // A port that is already disabled must be left untouched.
+ verify(portParent, never()).disableInputPort(disabledInputPort);
+ }
+
+ @Test
+ public void testRenameIsIdempotentWhenSourceAlreadyHasMigratedPrefix() throws Exception {
+ final FlowController flowController = createFlowController(1);
+ final ProcessGroup sourceProcessGroup = wireSourceProcessGroup(flowController, SOURCE_GROUP_ID, "(Migrated) " + SOURCE_GROUP_NAME);
+
+ final ProcessGroup sourceParent = mock(ProcessGroup.class);
+ final ProcessorNode runningProcessor = mock(ProcessorNode.class);
+ when(runningProcessor.getDesiredState()).thenReturn(ScheduledState.RUNNING);
+ when(runningProcessor.getProcessGroup()).thenReturn(sourceParent);
+ when(sourceProcessGroup.findAllProcessors()).thenReturn(List.of(runningProcessor));
+
+ final ControllerServiceNode enabledService = mock(ControllerServiceNode.class);
+ when(enabledService.getState()).thenReturn(ControllerServiceState.ENABLED);
+ when(sourceProcessGroup.findAllControllerServices()).thenReturn(Set.of(enabledService));
+
+ final ControllerServiceProvider controllerServiceProvider = mock(ControllerServiceProvider.class);
+ when(flowController.getControllerServiceProvider()).thenReturn(controllerServiceProvider);
+
+ wireFreshConnector(flowController, CONNECTOR_ID);
+
+ final StandardConnectorMigrationManager migrationManager = newMigrationManager(flowController);
+ final VersionedExternalFlow sourceFlow = createSourceFlowWithLocalStateCount(1);
+
+ migrationManager.migrateFromVersionedFlow(CONNECTOR_ID, SOURCE_GROUP_ID, sourceFlow);
+
+ // The source already had the (Migrated) prefix from a previous attempt, so it must not be re-prefixed.
+ verify(sourceProcessGroup, never()).setName(anyString());
+
+ // Disable must still run unconditionally even when the rename was skipped.
+ verify(sourceParent).disableProcessor(runningProcessor);
+ verify(controllerServiceProvider).disableControllerServicesAsync(List.of(enabledService));
+ }
+
+ @Test
+ public void testRerunAfterFailureIsAllowed() throws Exception {
+ final FlowController flowController = createFlowController(1);
+ final ConnectorNode connectorNode = wireFreshConnector(flowController, CONNECTOR_ID);
+
+ final MigratableConnector connector = (MigratableConnector) connectorNode.getConnector();
+ doThrow(new FlowUpdateException("transient")).doNothing().when(connector).migrateConfiguration(any(ConnectorMigrationContext.class));
+
+ final StandardConnectorMigrationManager migrationManager = newMigrationManager(flowController);
+ final VersionedExternalFlow sourceFlow = createSourceFlowWithLocalStateCount(1);
+
+ assertThrows(FlowUpdateException.class, () -> migrationManager.migrateFromVersionedFlow(CONNECTOR_ID, null, sourceFlow));
+
+ // Second attempt must succeed because the prior attempt rolled back fully.
+ migrationManager.migrateFromVersionedFlow(CONNECTOR_ID, null, sourceFlow);
+ verify(connectorNode, atLeastOnce()).commitMigratedConfiguration(any(ConnectorConfiguration.class));
+ }
+
+ @Test
+ public void testStateMigrationFailureSkipsCommitOfMigratedConfiguration() throws Exception {
+ final FlowController flowController = createFlowController(1);
+ final ConnectorNode connectorNode = wireFreshConnector(flowController, CONNECTOR_ID);
+ final MigratableConnector connector = (MigratableConnector) connectorNode.getConnector();
+ // Phase 1 succeeds (default behavior); phase 2 throws so the framework must roll back without committing
+ // the merged configuration onto active.
+ doThrow(new FlowUpdateException("phase-2 failure")).when(connector).migrateState(any(ConnectorMigrationContext.class));
+
+ final StandardConnectorMigrationManager migrationManager = newMigrationManager(flowController);
+ final VersionedExternalFlow sourceFlow = createSourceFlowWithLocalStateCount(1);
+
+ assertThrows(FlowUpdateException.class, () -> migrationManager.migrateFromVersionedFlow(CONNECTOR_ID, null, sourceFlow));
+
+ // applyMigratedConfiguration ran (phase 1 succeeded), but the merged configuration must not be committed
+ // because the phase-2 failure means the migration as a whole did not succeed.
+ verify(connectorNode).applyMigratedConfiguration(any());
+ verify(connectorNode, never()).commitMigratedConfiguration(any(ConnectorConfiguration.class));
+ verify(connectorNode).loadInitialFlow();
+ }
+
+ @Test
+ public void testNonFlowUpdateExceptionFromMigrateStillTriggersRollback() throws Exception {
+ final FlowController flowController = createFlowController(1);
+ final ConnectorRepository connectorRepository = flowController.getConnectorRepository();
+ final ConnectorNode connectorNode = wireFreshConnector(flowController, CONNECTOR_ID);
+ final MigratableConnector connector = (MigratableConnector) connectorNode.getConnector();
+ doThrow(new RuntimeException("boom")).when(connector).migrateConfiguration(any(ConnectorMigrationContext.class));
+
+ final StandardConnectorMigrationManager migrationManager = newMigrationManager(flowController);
+ final VersionedExternalFlow sourceFlow = createSourceFlowWithLocalStateCount(1);
+
+ assertThrows(FlowUpdateException.class, () -> migrationManager.migrateFromVersionedFlow(CONNECTOR_ID, null, sourceFlow));
+
+ verify(connectorNode).loadInitialFlow();
+ verify(connectorRepository).discardWorkingConfiguration(connectorNode);
+ verify(connectorNode, never()).commitMigratedConfiguration(any(ConnectorConfiguration.class));
+ }
+
+ @Test
+ public void testRollbackClearsLocalAndClusterStateAndDeletesOnlyCopiedAssets() throws Exception {
+ final FlowController flowController = createFlowController(1);
+ final StateManagerProvider stateManagerProvider = flowController.getStateManagerProvider();
+ final StateManager componentStateManager = mock(StateManager.class);
+ when(stateManagerProvider.getStateManager(anyString())).thenReturn(componentStateManager);
+
+ final ConnectorRepository connectorRepository = flowController.getConnectorRepository();
+ final ConnectorNode connectorNode = wireFreshConnector(flowController, CONNECTOR_ID);
+
+ final ProcessorNode processor = mock(ProcessorNode.class);
+ when(processor.getIdentifier()).thenReturn("processor-runtime-id");
+ final ControllerServiceNode controllerService = mock(ControllerServiceNode.class);
+ when(controllerService.getIdentifier()).thenReturn("service-runtime-id");
+ final ProcessGroup managedGroup = mock(ProcessGroup.class);
+ when(managedGroup.findAllProcessors()).thenReturn(List.of(processor));
+ when(managedGroup.findAllControllerServices()).thenReturn(Set.of(controllerService));
+ when(connectorNode.getActiveFlowContext().getManagedProcessGroup()).thenReturn(managedGroup);
+
+ final MigratableConnector connector = (MigratableConnector) connectorNode.getConnector();
+ doThrow(new FlowUpdateException("nope")).when(connector).migrateConfiguration(any(ConnectorMigrationContext.class));
+
+ final StandardConnectorMigrationManager migrationManager = newMigrationManager(flowController);
+ final VersionedExternalFlow sourceFlow = createSourceFlowWithLocalStateCount(1);
+
+ assertThrows(FlowUpdateException.class, () -> migrationManager.migrateFromVersionedFlow(CONNECTOR_ID, null, sourceFlow));
+
+ // Rollback clears LOCAL and CLUSTER state for each component currently in the Connector's managed flow.
+ verify(stateManagerProvider).getStateManager("processor-runtime-id");
+ verify(stateManagerProvider).getStateManager("service-runtime-id");
+ verify(componentStateManager, atLeastOnce()).clear(Scope.LOCAL);
+ verify(componentStateManager, atLeastOnce()).clear(Scope.CLUSTER);
+
+ verify(connectorRepository, never()).deleteAssets(eq(CONNECTOR_ID), any());
+ verify(connectorNode).loadInitialFlow();
+ }
+
+ @Test
+ public void testListingIncludesStaleAndLocallyModifiedSourcesAsNotReady() {
+ final FlowController flowController = createFlowController(1);
+ final ConnectorNode connectorNode = wireFreshConnector(flowController, CONNECTOR_ID);
+ stubFrameworkMigrationGating(connectorNode);
+
+ final ProcessGroup rootGroup = mock(ProcessGroup.class);
+ when(flowController.getFlowManager().getRootGroup()).thenReturn(rootGroup);
+
+ final ProcessGroup staleGroup = mockVersionedGroup("stale-group", VersionedFlowState.STALE);
+ final ProcessGroup locallyModifiedGroup = mockVersionedGroup("locally-modified-group", VersionedFlowState.LOCALLY_MODIFIED);
+ when(rootGroup.findAllProcessGroups()).thenReturn(List.of(staleGroup, locallyModifiedGroup));
+ when(rootGroup.findProcessGroup("stale-group")).thenReturn(staleGroup);
+ when(rootGroup.findProcessGroup("locally-modified-group")).thenReturn(locallyModifiedGroup);
+
+ final StandardConnectorMigrationManager migrationManager = newMigrationManager(flowController);
+ final List sources = migrationManager.listMigrationSources(CONNECTOR_ID);
+
+ assertEquals(2, sources.size(), "Stale and locally-modified groups must appear in the listing so users can remediate their state");
+
+ assertIneligibilityReasonContains(findSource(sources, "stale-group"), "stale");
+ assertIneligibilityReasonContains(findSource(sources, "locally-modified-group"), "local modifications");
+
+ final IllegalStateException staleException = assertThrows(IllegalStateException.class,
+ () -> migrationManager.verifyEligibility(CONNECTOR_ID, "stale-group"));
+ assertTrue(staleException.getMessage().contains("stale"), staleException.getMessage());
+
+ final IllegalStateException locallyModifiedException = assertThrows(IllegalStateException.class,
+ () -> migrationManager.verifyEligibility(CONNECTOR_ID, "locally-modified-group"));
+ assertTrue(locallyModifiedException.getMessage().contains("local modifications"), locallyModifiedException.getMessage());
+ }
+
+ @Test
+ public void testVerifyEligibilitySurfacesEachReasonOnItsOwnLine() {
+ final FlowController flowController = createFlowController(1);
+ final ConnectorNode connectorNode = wireFreshConnector(flowController, CONNECTOR_ID);
+ stubFrameworkMigrationGating(connectorNode);
+
+ final ProcessGroup rootGroup = mock(ProcessGroup.class);
+ when(flowController.getFlowManager().getRootGroup()).thenReturn(rootGroup);
+
+ final ProcessGroup multiIssueGroup = mockEligibleVersionedGroup("multi-issue-group");
+
+ final ProcessorNode runningProcessor = mock(ProcessorNode.class);
+ when(runningProcessor.getPhysicalScheduledState()).thenReturn(ScheduledState.RUNNING);
+ when(multiIssueGroup.findAllProcessors()).thenReturn(List.of(runningProcessor));
+
+ final Connection connection = mock(Connection.class);
+ final FlowFileQueue queue = mock(FlowFileQueue.class);
+ when(queue.size()).thenReturn(new QueueSize(7, 2048L));
+ when(connection.getFlowFileQueue()).thenReturn(queue);
+ when(multiIssueGroup.findAllConnections()).thenReturn(List.of(connection));
+
+ when(rootGroup.findProcessGroup("multi-issue-group")).thenReturn(multiIssueGroup);
+
+ final StandardConnectorMigrationManager migrationManager = newMigrationManager(flowController);
+ final IllegalStateException exception = assertThrows(IllegalStateException.class,
+ () -> migrationManager.verifyEligibility(CONNECTOR_ID, "multi-issue-group"));
+
+ // Each ineligibility reason must appear on its own line so the operator can identify and address every
+ // condition independently instead of seeing them joined into a single run-on sentence.
+ final String message = exception.getMessage();
+ assertTrue(message.contains("Running or enabled processor count: 1."), message);
+ assertTrue(message.contains("Queued FlowFile count: 7."), message);
+ assertTrue(message.contains(System.lineSeparator()), "verifyEligibility must separate reasons with the system line separator: " + message);
+ }
+
+ @Test
+ public void testListingSurfacesAllConcurrentIneligibilityReasons() {
+ final FlowController flowController = createFlowController(1);
+ final ConnectorNode connectorNode = wireFreshConnector(flowController, CONNECTOR_ID);
+ stubFrameworkMigrationGating(connectorNode);
+
+ final ProcessGroup rootGroup = mock(ProcessGroup.class);
+ when(flowController.getFlowManager().getRootGroup()).thenReturn(rootGroup);
+
+ // A single group that simultaneously fails three soft-filter conditions: running processors, enabled controller
+ // services, and queued FlowFiles. Every applicable reason must appear in the DTO so the user can remediate them
+ // together rather than discovering them one at a time.
+ final ProcessGroup multiIssueGroup = mockEligibleVersionedGroup("multi-issue-group");
+
+ final ProcessorNode runningProcessor = mock(ProcessorNode.class);
+ when(runningProcessor.getPhysicalScheduledState()).thenReturn(ScheduledState.RUNNING);
+ final ProcessorNode stoppedProcessor = mock(ProcessorNode.class);
+ when(stoppedProcessor.getPhysicalScheduledState()).thenReturn(ScheduledState.STOPPED);
+ when(multiIssueGroup.findAllProcessors()).thenReturn(List.of(runningProcessor, stoppedProcessor));
+
+ final ControllerServiceNode enabledService = mock(ControllerServiceNode.class);
+ when(enabledService.getState()).thenReturn(ControllerServiceState.ENABLED);
+ final ControllerServiceNode disabledService = mock(ControllerServiceNode.class);
+ when(disabledService.getState()).thenReturn(ControllerServiceState.DISABLED);
+ when(multiIssueGroup.findAllControllerServices()).thenReturn(Set.of(enabledService, disabledService));
+
+ final Connection connection = mock(Connection.class);
+ final FlowFileQueue queue = mock(FlowFileQueue.class);
+ when(queue.size()).thenReturn(new QueueSize(7, 2048L));
+ when(connection.getFlowFileQueue()).thenReturn(queue);
+ when(multiIssueGroup.findAllConnections()).thenReturn(List.of(connection));
+
+ when(rootGroup.findAllProcessGroups()).thenReturn(List.of(multiIssueGroup));
+
+ final StandardConnectorMigrationManager migrationManager = newMigrationManager(flowController);
+ final List sources = migrationManager.listMigrationSources(CONNECTOR_ID);
+
+ assertEquals(1, sources.size());
+ final ConnectorMigrationSource source = sources.get(0);
+ assertFalse(source.isReadyForMigration());
+ final List reasons = source.getIneligibilityReasons();
+ assertEquals(3, reasons.size(), "All three concurrent ineligibility conditions must be surfaced: " + reasons);
+ assertTrue(reasons.contains("Running or enabled processor count: 1."), reasons.toString());
+ assertTrue(reasons.contains("Enabled controller service count: 1."), reasons.toString());
+ assertTrue(reasons.contains("Queued FlowFile count: 7."), reasons.toString());
+ }
+
+ @Test
+ public void testListingMessageFormatIsIndependentOfCount() {
+ final FlowController flowController = createFlowController(1);
+ final ConnectorNode connectorNode = wireFreshConnector(flowController, CONNECTOR_ID);
+ stubFrameworkMigrationGating(connectorNode);
+
+ final ProcessGroup rootGroup = mock(ProcessGroup.class);
+ when(flowController.getFlowManager().getRootGroup()).thenReturn(rootGroup);
+
+ final ProcessGroup pluralGroup = mockEligibleVersionedGroup("plural-group");
+ final ProcessorNode runningProcessor1 = mock(ProcessorNode.class);
+ when(runningProcessor1.getPhysicalScheduledState()).thenReturn(ScheduledState.RUNNING);
+ final ProcessorNode runningProcessor2 = mock(ProcessorNode.class);
+ when(runningProcessor2.getPhysicalScheduledState()).thenReturn(ScheduledState.RUNNING);
+ final ProcessorNode runningProcessor3 = mock(ProcessorNode.class);
+ when(runningProcessor3.getPhysicalScheduledState()).thenReturn(ScheduledState.RUNNING);
+ when(pluralGroup.findAllProcessors()).thenReturn(List.of(runningProcessor1, runningProcessor2, runningProcessor3));
+
+ when(rootGroup.findAllProcessGroups()).thenReturn(List.of(pluralGroup));
+
+ final StandardConnectorMigrationManager migrationManager = newMigrationManager(flowController);
+ final List sources = migrationManager.listMigrationSources(CONNECTOR_ID);
+
+ assertEquals(1, sources.size());
+ assertEquals(List.of("Running or enabled processor count: 3."), sources.get(0).getIneligibilityReasons());
+ }
+
+ @Test
+ public void testListingMarksProcessGroupsWithRemediableStateAsNotReady() {
+ final FlowController flowController = createFlowController(1);
+ final ConnectorNode connectorNode = wireFreshConnector(flowController, CONNECTOR_ID);
+ stubFrameworkMigrationGating(connectorNode);
+
+ final ProcessGroup rootGroup = mock(ProcessGroup.class);
+ when(flowController.getFlowManager().getRootGroup()).thenReturn(rootGroup);
+
+ final ProcessGroup runningGroup = mockEligibleVersionedGroup("running-group");
+ final ProcessorNode runningProcessor = mock(ProcessorNode.class);
+ when(runningProcessor.getPhysicalScheduledState()).thenReturn(ScheduledState.RUNNING);
+ when(runningGroup.findAllProcessors()).thenReturn(List.of(runningProcessor));
+
+ final ProcessGroup enabledServicesGroup = mockEligibleVersionedGroup("enabled-services-group");
+ final ControllerServiceNode enabledService = mock(ControllerServiceNode.class);
+ when(enabledService.getState()).thenReturn(ControllerServiceState.ENABLED);
+ when(enabledServicesGroup.findAllControllerServices()).thenReturn(Set.of(enabledService));
+
+ final ProcessGroup queuedGroup = mockEligibleVersionedGroup("queued-group");
+ final Connection connection = mock(Connection.class);
+ final FlowFileQueue queue = mock(FlowFileQueue.class);
+ when(queue.size()).thenReturn(new QueueSize(5, 1024L));
+ when(connection.getFlowFileQueue()).thenReturn(queue);
+ when(queuedGroup.findAllConnections()).thenReturn(List.of(connection));
+
+ when(rootGroup.findAllProcessGroups()).thenReturn(List.of(runningGroup, enabledServicesGroup, queuedGroup));
+
+ final StandardConnectorMigrationManager migrationManager = newMigrationManager(flowController);
+ final List sources = migrationManager.listMigrationSources(CONNECTOR_ID);
+
+ assertEquals(3, sources.size());
+ assertIneligibilityReasonContains(findSource(sources, "running-group"), "Running or enabled processor");
+ assertIneligibilityReasonContains(findSource(sources, "enabled-services-group"), "Enabled controller service");
+ assertIneligibilityReasonContains(findSource(sources, "queued-group"), "Queued FlowFile");
+ }
+
+ @Test
+ public void testListingMarksProcessGroupReferencingExternalControllerServicesAsNotReady() {
+ final FlowController flowController = createFlowController(1);
+ final ConnectorNode connectorNode = wireFreshConnector(flowController, CONNECTOR_ID);
+ stubFrameworkMigrationGating(connectorNode);
+
+ final ProcessGroup rootGroup = mock(ProcessGroup.class);
+ when(flowController.getFlowManager().getRootGroup()).thenReturn(rootGroup);
+
+ final ProcessGroup externalReferencingGroup = mockEligibleVersionedGroup("external-services-group");
+ when(rootGroup.findAllProcessGroups()).thenReturn(List.of(externalReferencingGroup));
+
+ final RegisteredFlowSnapshot snapshotWithExternalServices = new RegisteredFlowSnapshot();
+ snapshotWithExternalServices.setFlowContents(new VersionedProcessGroup());
+ snapshotWithExternalServices.setFlow(new RegisteredFlow());
+ snapshotWithExternalServices.setExternalControllerServices(Map.of("external-service", new ExternalControllerServiceReference()));
+ final ConnectorFlowSnapshotProvider snapshotProvider = (groupId, includeReferencedServices, includeComponentState) -> snapshotWithExternalServices;
+ final StandardConnectorMigrationManager migrationManager = new StandardConnectorMigrationManager(flowController, snapshotProvider);
+
+ final List sources = migrationManager.listMigrationSources(CONNECTOR_ID);
+
+ assertEquals(1, sources.size());
+ assertIneligibilityReasonContains(sources.get(0), "from outside the Process Group");
+ }
+
+ @Test
+ public void testListingPopulatesAllDtoFieldsForReadySource() {
+ final FlowController flowController = createFlowController(1);
+ final ConnectorNode connectorNode = wireFreshConnector(flowController, CONNECTOR_ID);
+ stubFrameworkMigrationGating(connectorNode);
+
+ final ProcessGroup rootGroup = mock(ProcessGroup.class);
+ when(rootGroup.getIdentifier()).thenReturn("root-group");
+ when(flowController.getFlowManager().getRootGroup()).thenReturn(rootGroup);
+
+ final ProcessGroup eligibleGroup = mockEligibleVersionedGroup("eligible-group");
+ when(eligibleGroup.getParent()).thenReturn(rootGroup);
+ final VersionControlInformation versionControlInformation = eligibleGroup.getVersionControlInformation();
+ when(versionControlInformation.getFlowName()).thenReturn("My Flow");
+ when(versionControlInformation.getRegistryIdentifier()).thenReturn("registry-1");
+ when(versionControlInformation.getBucketIdentifier()).thenReturn("bucket-1");
+ when(versionControlInformation.getFlowIdentifier()).thenReturn("flow-1");
+ when(versionControlInformation.getVersion()).thenReturn("1");
+ when(rootGroup.findAllProcessGroups()).thenReturn(List.of(eligibleGroup));
+
+ final StandardConnectorMigrationManager migrationManager = newMigrationManager(flowController);
+ final List sources = migrationManager.listMigrationSources(CONNECTOR_ID);
+
+ assertEquals(1, sources.size());
+ final ConnectorMigrationSource source = sources.get(0);
+ assertEquals("eligible-group", source.getProcessGroupId());
+ assertEquals("root-group", source.getParentProcessGroupId());
+ assertEquals("registry-1", source.getRegistryClientId());
+ assertEquals("bucket-1", source.getBucketId());
+ assertEquals("flow-1", source.getFlowId());
+ assertEquals("My Flow", source.getFlowName());
+ assertEquals("1", source.getVersion());
+ assertTrue(source.isReadyForMigration());
+ assertEquals(List.of(), source.getIneligibilityReasons());
+ }
+
+ @Test
+ public void testListingExcludesNonRegistryBackedProcessGroups() {
+ final FlowController flowController = createFlowController(1);
+ wireFreshConnector(flowController, CONNECTOR_ID);
+
+ final ProcessGroup rootGroup = mock(ProcessGroup.class);
+ when(flowController.getFlowManager().getRootGroup()).thenReturn(rootGroup);
+
+ // Process group has no version control information at all, so it must not appear in listing.
+ final ProcessGroup nonRegistryBacked = mock(ProcessGroup.class);
+ when(nonRegistryBacked.getIdentifier()).thenReturn("non-registry-backed");
+ when(nonRegistryBacked.getName()).thenReturn("Non Registry Backed");
+ when(nonRegistryBacked.getVersionControlInformation()).thenReturn(null);
+ when(nonRegistryBacked.getConnectorIdentifier()).thenReturn(Optional.empty());
+ when(rootGroup.findAllProcessGroups()).thenReturn(List.of(nonRegistryBacked));
+
+ final StandardConnectorMigrationManager migrationManager = newMigrationManager(flowController);
+ final List sources = migrationManager.listMigrationSources(CONNECTOR_ID);
+
+ assertTrue(sources.isEmpty(), "Process groups without VersionControlInformation must not appear in the migration sources listing");
+ }
+
+ @Test
+ public void testEligibilityRejectsNonVersionControlledSource() {
+ final FlowController flowController = createFlowController(1);
+ wireFreshConnector(flowController, CONNECTOR_ID);
+
+ final ProcessGroup rootGroup = mock(ProcessGroup.class);
+ when(flowController.getFlowManager().getRootGroup()).thenReturn(rootGroup);
+ final ProcessGroup nonVersioned = mock(ProcessGroup.class);
+ when(nonVersioned.getIdentifier()).thenReturn("non-versioned");
+ when(nonVersioned.getVersionControlInformation()).thenReturn(null);
+ when(nonVersioned.getConnectorIdentifier()).thenReturn(Optional.empty());
+ when(rootGroup.findProcessGroup("non-versioned")).thenReturn(nonVersioned);
+
+ final StandardConnectorMigrationManager migrationManager = newMigrationManager(flowController);
+
+ final IllegalStateException exception = assertThrows(IllegalStateException.class,
+ () -> migrationManager.verifyEligibility(CONNECTOR_ID, "non-versioned"));
+ assertTrue(exception.getMessage().contains("not under version control"), exception.getMessage());
+ }
+
+ @Test
+ public void testValidateMigrationSourceRejectsExternalControllerServices() {
+ final FlowController flowController = createFlowController(1);
+ wireFreshConnector(flowController, CONNECTOR_ID);
+
+ final StandardConnectorMigrationManager migrationManager = newMigrationManager(flowController);
+ final VersionedExternalFlow sourceFlow = createSourceFlowWithLocalStateCount(1);
+ sourceFlow.setExternalControllerServices(Map.of("external-service", new ExternalControllerServiceReference()));
+
+ final IllegalStateException exception = assertThrows(IllegalStateException.class,
+ () -> migrationManager.migrateFromVersionedFlow(CONNECTOR_ID, null, sourceFlow));
+ assertTrue(exception.getMessage().contains("services outside its managed flow"), exception.getMessage());
+ }
+
+ @Test
+ public void testRejectMigrationWhenTargetHasBeenModifiedSinceCreation() {
+ final FlowController flowController = createFlowController(1);
+ final ConnectorNode connectorNode = wireFreshConnector(flowController, CONNECTOR_ID);
+
+ // The Connector has been modified since it was created, so a migration would overwrite user modifications.
+ when(connectorNode.isModified()).thenReturn(true);
+
+ final StandardConnectorMigrationManager migrationManager = newMigrationManager(flowController);
+ final VersionedExternalFlow sourceFlow = createSourceFlowWithLocalStateCount(1);
+
+ final IllegalStateException exception = assertThrows(IllegalStateException.class,
+ () -> migrationManager.migrateFromVersionedFlow(CONNECTOR_ID, null, sourceFlow));
+ assertTrue(exception.getMessage().contains("modified since it was created"), exception.getMessage());
+ }
+
+ @Test
+ public void testRollbackFailureDoesNotMaskOriginalMigrationFailure() throws Exception {
+ final FlowController flowController = createFlowController(1);
+ final ConnectorNode connectorNode = wireFreshConnector(flowController, CONNECTOR_ID);
+ final MigratableConnector connector = (MigratableConnector) connectorNode.getConnector();
+
+ final FlowUpdateException originalFailure = new FlowUpdateException("original");
+ doThrow(originalFailure).when(connector).migrateConfiguration(any(ConnectorMigrationContext.class));
+ doThrow(new FlowUpdateException("rollback failed")).when(connectorNode).loadInitialFlow();
+
+ final StandardConnectorMigrationManager migrationManager = newMigrationManager(flowController);
+ final VersionedExternalFlow sourceFlow = createSourceFlowWithLocalStateCount(1);
+
+ final FlowUpdateException thrown = assertThrows(FlowUpdateException.class,
+ () -> migrationManager.migrateFromVersionedFlow(CONNECTOR_ID, null, sourceFlow));
+
+ // The caller must see the original migration failure, not the rollback failure.
+ assertEquals("original", thrown.getMessage());
+ }
+
+ @Test
+ public void testIsMigrationSupportedFalseDoesNotTriggerRollback() throws Exception {
+ final FlowController flowController = createFlowController(1);
+ final ConnectorRepository connectorRepository = flowController.getConnectorRepository();
+ final ConnectorNode connectorNode = wireFreshConnector(flowController, CONNECTOR_ID);
+ when(((MigratableConnector) connectorNode.getConnector()).isMigrationSupported(any())).thenReturn(false);
+
+ final StandardConnectorMigrationManager migrationManager = newMigrationManager(flowController);
+ final VersionedExternalFlow sourceFlow = createSourceFlowWithLocalStateCount(1);
+
+ assertThrows(FlowUpdateException.class, () -> migrationManager.migrateFromVersionedFlow(CONNECTOR_ID, null, sourceFlow));
+
+ // An eligibility rejection must not exercise the rollback machinery: no flow was attempted.
+ verify(connectorNode, never()).loadInitialFlow();
+ verify(connectorRepository, never()).discardWorkingConfiguration(connectorNode);
+ verify(connectorRepository, never()).deleteAssets(eq(CONNECTOR_ID), any());
+ }
+
+ @Test
+ public void testEachIneligibleVersionedFlowStateHasDistinctDiagnostic() {
+ final FlowController flowController = createFlowController(1);
+ wireFreshConnector(flowController, CONNECTOR_ID);
+
+ final ProcessGroup rootGroup = mock(ProcessGroup.class);
+ when(flowController.getFlowManager().getRootGroup()).thenReturn(rootGroup);
+
+ for (final VersionedFlowState state : VersionedFlowState.values()) {
+ if (state == VersionedFlowState.UP_TO_DATE) {
+ continue;
+ }
+ final ProcessGroup processGroup = mockVersionedGroup("group-" + state, state);
+ when(rootGroup.findProcessGroup("group-" + state)).thenReturn(processGroup);
+ }
+
+ final StandardConnectorMigrationManager migrationManager = newMigrationManager(flowController);
+
+ final String staleMessage = assertThrows(IllegalStateException.class,
+ () -> migrationManager.verifyEligibility(CONNECTOR_ID, "group-STALE")).getMessage();
+ final String locallyModifiedMessage = assertThrows(IllegalStateException.class,
+ () -> migrationManager.verifyEligibility(CONNECTOR_ID, "group-LOCALLY_MODIFIED")).getMessage();
+ final String locallyModifiedAndStaleMessage = assertThrows(IllegalStateException.class,
+ () -> migrationManager.verifyEligibility(CONNECTOR_ID, "group-LOCALLY_MODIFIED_AND_STALE")).getMessage();
+ final String syncFailureMessage = assertThrows(IllegalStateException.class,
+ () -> migrationManager.verifyEligibility(CONNECTOR_ID, "group-SYNC_FAILURE")).getMessage();
+
+ assertEquals(4, Set.of(staleMessage, locallyModifiedMessage, locallyModifiedAndStaleMessage, syncFailureMessage).size(),
+ "Each non-UP_TO_DATE VersionedFlowState must produce a unique diagnostic message");
+ }
+
+ @Test
+ public void testListMigrationSourcesReturnsEmptyForNonMigratableConnector() {
+ final FlowController flowController = createFlowController(1);
+ final ConnectorNode connectorNode = wireFreshConnector(flowController, CONNECTOR_ID, false);
+ stubFrameworkMigrationGating(connectorNode);
+
+ final ProcessGroup rootGroup = mock(ProcessGroup.class);
+ when(flowController.getFlowManager().getRootGroup()).thenReturn(rootGroup);
+ final ProcessGroup eligibleGroup = mockEligibleVersionedGroup("eligible-group");
+ when(rootGroup.findAllProcessGroups()).thenReturn(List.of(eligibleGroup));
+
+ final StandardConnectorMigrationManager migrationManager = newMigrationManager(flowController);
+
+ assertTrue(migrationManager.listMigrationSources(CONNECTOR_ID).isEmpty(),
+ "A Connector that does not implement MigratableConnector must never appear in the listing");
+ }
+
+ @Test
+ public void testMigrateRejectsConnectorThatDoesNotImplementMigratableConnector() throws Exception {
+ final FlowController flowController = createFlowController(1);
+ final ConnectorRepository connectorRepository = flowController.getConnectorRepository();
+ final ConnectorNode connectorNode = wireFreshConnector(flowController, CONNECTOR_ID, false);
+
+ final StandardConnectorMigrationManager migrationManager = newMigrationManager(flowController);
+ final VersionedExternalFlow sourceFlow = createSourceFlowWithLocalStateCount(1);
+
+ final FlowUpdateException exception = assertThrows(FlowUpdateException.class,
+ () -> migrationManager.migrateFromVersionedFlow(CONNECTOR_ID, null, sourceFlow));
+ assertTrue(exception.getMessage().contains("does not support migration"), exception.getMessage());
+
+ // Rejection happens before any flow manipulation is attempted, so rollback must not be exercised.
+ verify(connectorNode, never()).loadInitialFlow();
+ verify(connectorRepository, never()).deleteAssets(eq(CONNECTOR_ID), any());
+ }
+
+ @Test
+ public void testMigrateRejectsRunningConnector() {
+ final FlowController flowController = createFlowController(1);
+ final ConnectorNode connectorNode = wireFreshConnector(flowController, CONNECTOR_ID);
+ when(connectorNode.getCurrentState()).thenReturn(ConnectorState.RUNNING);
+ when(connectorNode.getDesiredState()).thenReturn(ConnectorState.RUNNING);
+
+ final StandardConnectorMigrationManager migrationManager = newMigrationManager(flowController);
+ final VersionedExternalFlow sourceFlow = createSourceFlowWithLocalStateCount(1);
+
+ final IllegalStateException exception = assertThrows(IllegalStateException.class,
+ () -> migrationManager.migrateFromVersionedFlow(CONNECTOR_ID, null, sourceFlow));
+ assertTrue(exception.getMessage().contains("must be stopped before it can be migrated"), exception.getMessage());
+ }
+
+ @Test
+ public void testVerifyConnectorReadyForMigration() {
+ final FlowController flowController = createFlowController(1);
+ final ConnectorNode connectorNode = wireFreshConnector(flowController, CONNECTOR_ID);
+ final StandardConnectorMigrationManager migrationManager = newMigrationManager(flowController);
+
+ // A stopped Connector still at its initial flow is ready: no exception.
+ migrationManager.verifyConnectorReadyForMigration(CONNECTOR_ID);
+
+ when(connectorNode.getCurrentState()).thenReturn(ConnectorState.RUNNING);
+ when(connectorNode.getDesiredState()).thenReturn(ConnectorState.RUNNING);
+ final IllegalStateException notStopped = assertThrows(IllegalStateException.class,
+ () -> migrationManager.verifyConnectorReadyForMigration(CONNECTOR_ID));
+ assertTrue(notStopped.getMessage().contains("must be stopped before it can be migrated"), notStopped.getMessage());
+
+ when(connectorNode.getCurrentState()).thenReturn(ConnectorState.STOPPED);
+ when(connectorNode.getDesiredState()).thenReturn(ConnectorState.STOPPED);
+ when(connectorNode.isModified()).thenReturn(true);
+ final IllegalStateException modified = assertThrows(IllegalStateException.class,
+ () -> migrationManager.verifyConnectorReadyForMigration(CONNECTOR_ID));
+ assertTrue(modified.getMessage().contains("modified since it was created"), modified.getMessage());
+ }
+
+ @Test
+ public void testMigrateMarksMigrationInProgressAndClearsItOnSuccess() throws Exception {
+ final FlowController flowController = createFlowController(1);
+ final ConnectorRepository connectorRepository = flowController.getConnectorRepository();
+ wireSourceProcessGroup(flowController, SOURCE_GROUP_ID, SOURCE_GROUP_NAME);
+ wireFreshConnector(flowController, CONNECTOR_ID);
+ final StandardConnectorMigrationManager migrationManager = newMigrationManager(flowController);
+ final VersionedExternalFlow sourceFlow = createSourceFlowWithLocalStateCount(1);
+
+ migrationManager.migrateFromVersionedFlow(CONNECTOR_ID, SOURCE_GROUP_ID, sourceFlow);
+
+ verify(connectorRepository).beginMigration(CONNECTOR_ID);
+ verify(connectorRepository).endMigration(CONNECTOR_ID);
+ }
+
+ @Test
+ public void testMigrateClearsMigrationInProgressOnFailure() throws Exception {
+ final FlowController flowController = createFlowController(1);
+ final ConnectorRepository connectorRepository = flowController.getConnectorRepository();
+ final ConnectorNode connectorNode = wireFreshConnector(flowController, CONNECTOR_ID);
+ final MigratableConnector connector = (MigratableConnector) connectorNode.getConnector();
+ doThrow(new FlowUpdateException("boom")).when(connector).migrateConfiguration(any(ConnectorMigrationContext.class));
+
+ final StandardConnectorMigrationManager migrationManager = newMigrationManager(flowController);
+ final VersionedExternalFlow sourceFlow = createSourceFlowWithLocalStateCount(1);
+
+ assertThrows(FlowUpdateException.class, () -> migrationManager.migrateFromVersionedFlow(CONNECTOR_ID, null, sourceFlow));
+
+ // The marker must be cleared even when the migration fails and rolls back.
+ verify(connectorRepository).beginMigration(CONNECTOR_ID);
+ verify(connectorRepository).endMigration(CONNECTOR_ID);
+ }
+
+ @Test
+ public void testListMigrationSourcesPropagatesEligibilityWrapper() throws Exception {
+ final FlowController flowController = createFlowController(1);
+ final ConnectorRepository connectorRepository = flowController.getConnectorRepository();
+ final ConnectorNode connectorNode = wireFreshConnector(flowController, CONNECTOR_ID);
+ stubFrameworkMigrationGating(connectorNode);
+
+ final MigratableConnector migratableConnector = (MigratableConnector) connectorNode.getConnector();
+ when(migratableConnector.isMigrationSupported(any())).thenAnswer(invocation -> {
+ final ConnectorMigrationContext context = invocation.getArgument(0);
+ context.copyAssetFromSource("any-id");
+ return true;
+ });
+
+ final ProcessGroup rootGroup = mock(ProcessGroup.class);
+ when(flowController.getFlowManager().getRootGroup()).thenReturn(rootGroup);
+ final ProcessGroup eligibleGroup = mockEligibleVersionedGroup("eligible-group");
+ when(rootGroup.findAllProcessGroups()).thenReturn(List.of(eligibleGroup));
+
+ final StandardConnectorMigrationManager migrationManager = newMigrationManager(flowController);
+
+ assertTrue(migrationManager.listMigrationSources(CONNECTOR_ID).isEmpty(),
+ "A Connector that calls copyAssetFromSource() from isMigrationSupported() must be filtered out by the eligibility wrapper");
+ verify(connectorRepository, never()).storeAsset(anyString(), anyString(), anyString(), any(InputStream.class));
+ }
+
+ @Test
+ public void testProviderVerifyMigrationRejectionAbortsBeforeFlowManipulation() throws Exception {
+ final FlowController flowController = createFlowController(1);
+ final ConnectorRepository connectorRepository = flowController.getConnectorRepository();
+ final ConnectorNode connectorNode = wireFreshConnector(flowController, CONNECTOR_ID);
+ doThrow(new IllegalStateException("provider says no")).when(connectorRepository).verifyMigration(CONNECTOR_ID);
+
+ final StandardConnectorMigrationManager migrationManager = newMigrationManager(flowController);
+ final VersionedExternalFlow sourceFlow = createSourceFlowWithLocalStateCount(1);
+
+ final IllegalStateException exception = assertThrows(IllegalStateException.class,
+ () -> migrationManager.migrateFromVersionedFlow(CONNECTOR_ID, null, sourceFlow));
+ assertTrue(exception.getMessage().contains("provider says no"), exception.getMessage());
+
+ // The provider rejected the migration before any flow manipulation, so neither the commit nor the rollback
+ // machinery is exercised.
+ verify(connectorNode, never()).commitMigratedConfiguration(any(ConnectorConfiguration.class));
+ verify(connectorNode, never()).loadInitialFlow();
+ }
+
+ @Test
+ public void testVerifyConnectorReadyForMigrationConsultsProvider() {
+ final FlowController flowController = createFlowController(1);
+ final ConnectorRepository connectorRepository = flowController.getConnectorRepository();
+ wireFreshConnector(flowController, CONNECTOR_ID);
+ doThrow(new IllegalStateException("provider says no")).when(connectorRepository).verifyMigration(CONNECTOR_ID);
+
+ final StandardConnectorMigrationManager migrationManager = newMigrationManager(flowController);
+
+ final IllegalStateException exception = assertThrows(IllegalStateException.class,
+ () -> migrationManager.verifyConnectorReadyForMigration(CONNECTOR_ID));
+ assertTrue(exception.getMessage().contains("provider says no"), exception.getMessage());
+ }
+
+ @Test
+ public void testProviderNotifiedOfCompletionForLocalSource() throws Exception {
+ final FlowController flowController = createFlowController(1);
+ final ConnectorRepository connectorRepository = flowController.getConnectorRepository();
+ wireSourceProcessGroup(flowController, SOURCE_GROUP_ID, SOURCE_GROUP_NAME);
+ wireFreshConnector(flowController, CONNECTOR_ID);
+
+ final StandardConnectorMigrationManager migrationManager = newMigrationManager(flowController);
+ final VersionedExternalFlow sourceFlow = createSourceFlowWithLocalStateCount(1);
+
+ migrationManager.migrateFromVersionedFlow(CONNECTOR_ID, SOURCE_GROUP_ID, sourceFlow);
+
+ verify(connectorRepository).notifyMigrationComplete(CONNECTOR_ID, SOURCE_GROUP_ID);
+ }
+
+ @Test
+ public void testProviderNotifiedOfCompletionForUploadedPayloadWithNullSource() throws Exception {
+ final FlowController flowController = createFlowController(1);
+ final ConnectorRepository connectorRepository = flowController.getConnectorRepository();
+ wireFreshConnector(flowController, CONNECTOR_ID);
+
+ final StandardConnectorMigrationManager migrationManager = newMigrationManager(flowController);
+ final VersionedExternalFlow sourceFlow = createSourceFlowWithLocalStateCount(1);
+
+ migrationManager.migrateFromVersionedFlow(CONNECTOR_ID, null, sourceFlow);
+
+ verify(connectorRepository).notifyMigrationComplete(CONNECTOR_ID, null);
+ }
+
+ @Test
+ public void testProviderNotNotifiedOfCompletionWhenMigrationFails() throws Exception {
+ final FlowController flowController = createFlowController(1);
+ final ConnectorRepository connectorRepository = flowController.getConnectorRepository();
+ final ConnectorNode connectorNode = wireFreshConnector(flowController, CONNECTOR_ID);
+ final MigratableConnector connector = (MigratableConnector) connectorNode.getConnector();
+ doThrow(new FlowUpdateException("boom")).when(connector).migrateConfiguration(any(ConnectorMigrationContext.class));
+
+ final StandardConnectorMigrationManager migrationManager = newMigrationManager(flowController);
+ final VersionedExternalFlow sourceFlow = createSourceFlowWithLocalStateCount(1);
+
+ assertThrows(FlowUpdateException.class, () -> migrationManager.migrateFromVersionedFlow(CONNECTOR_ID, null, sourceFlow));
+
+ verify(connectorRepository, never()).notifyMigrationComplete(anyString(), any());
+ }
+
+ private static void assertIneligibilityReasonContains(final ConnectorMigrationSource source, final String expectedSubstring) {
+ assertFalse(source.isReadyForMigration(), "Expected source " + source.getProcessGroupId() + " to be marked not ready for migration");
+ final List reasons = source.getIneligibilityReasons();
+ boolean found = false;
+ for (final String reason : reasons) {
+ if (reason.contains(expectedSubstring)) {
+ found = true;
+ break;
+ }
+ }
+ assertTrue(found, "Ineligibility reasons for source " + source.getProcessGroupId()
+ + " should contain at least one entry with substring '" + expectedSubstring + "' but were: " + reasons);
+ }
+
+ private static ConnectorMigrationSource findSource(final List sources, final String processGroupId) {
+ for (final ConnectorMigrationSource source : sources) {
+ if (processGroupId.equals(source.getProcessGroupId())) {
+ return source;
+ }
+ }
+ throw new AssertionError("No source with processGroupId " + processGroupId);
+ }
+
+ private ProcessGroup mockEligibleVersionedGroup(final String groupId) {
+ final ProcessGroup processGroup = mockVersionedGroup(groupId, VersionedFlowState.UP_TO_DATE);
+ when(processGroup.getName()).thenReturn(groupId);
+ return processGroup;
+ }
+
+ private StandardConnectorMigrationManager newMigrationManager(final FlowController flowController) {
+ // The snapshot provider is only exercised by the listing path. Tests that drive listing wire their own snapshots.
+ final RegisteredFlowSnapshot emptySnapshot = new RegisteredFlowSnapshot();
+ emptySnapshot.setFlowContents(new VersionedProcessGroup());
+ emptySnapshot.setFlow(new RegisteredFlow());
+ final ConnectorFlowSnapshotProvider snapshotProvider = (groupId, includeReferencedServices, includeComponentState) -> emptySnapshot;
+ return new StandardConnectorMigrationManager(flowController, snapshotProvider);
+ }
+
+ private FlowController createFlowController(final int connectedNodeCount) {
+ final FlowController flowController = mock(FlowController.class);
+ when(flowController.getConnectorRepository()).thenReturn(mock(ConnectorRepository.class));
+ when(flowController.getFlowManager()).thenReturn(mock(FlowManager.class));
+ when(flowController.getExtensionManager()).thenReturn(mock(ExtensionManager.class));
+ when(flowController.getAssetManager()).thenReturn(mock(AssetManager.class));
+ when(flowController.getConnectorAssetManager()).thenReturn(mock(AssetManager.class));
+ when(flowController.getStateManagerProvider()).thenReturn(mock(StateManagerProvider.class));
+ when(flowController.getConnectedNodeCount()).thenReturn(connectedNodeCount);
+ return flowController;
+ }
+
+ private ConnectorNode wireFreshConnector(final FlowController flowController, final String connectorId) {
+ return wireFreshConnector(flowController, connectorId, true);
+ }
+
+ private ConnectorNode wireFreshConnector(final FlowController flowController, final String connectorId, final boolean migratable) {
+ final ConnectorNode connectorNode = mock(ConnectorNode.class);
+ when(connectorNode.getIdentifier()).thenReturn(connectorId);
+ when(connectorNode.getCurrentState()).thenReturn(ConnectorState.STOPPED);
+ when(connectorNode.getDesiredState()).thenReturn(ConnectorState.STOPPED);
+ when(connectorNode.isModified()).thenReturn(false);
+ final FrameworkFlowContext flowContext = mock(FrameworkFlowContext.class);
+ when(connectorNode.getActiveFlowContext()).thenReturn(flowContext);
+ // The manager seeds the migration context with a working clone of the connector's active configuration, so the
+ // mock active flow context must expose a configuration context whose clone() returns a usable working config.
+ final MutableConnectorConfigurationContext configurationContext = mock(MutableConnectorConfigurationContext.class);
+ when(configurationContext.clone()).thenReturn(configurationContext);
+ when(flowContext.getConfigurationContext()).thenReturn(configurationContext);
+ // Mirror the contract of StandardConnectorNode.applyMigratedConfiguration: return a non-null merged
+ // configuration the manager can hand to commitMigratedConfiguration once the state phase succeeds.
+ try {
+ when(connectorNode.applyMigratedConfiguration(any())).thenReturn(new ConnectorConfiguration(Set.of()));
+ } catch (final FlowUpdateException e) {
+ throw new AssertionError(e);
+ }
+
+ final Connector connector;
+ if (migratable) {
+ connector = mock(Connector.class, withSettings().extraInterfaces(MigratableConnector.class));
+ when(((MigratableConnector) connector).isMigrationSupported(any())).thenReturn(true);
+ } else {
+ connector = mock(Connector.class);
+ }
+ when(connectorNode.getConnector()).thenReturn(connector);
+ when(flowController.getConnectorRepository().getConnector(connectorId, ConnectorSyncMode.LOCAL_ONLY)).thenReturn(connectorNode);
+ return connectorNode;
+ }
+
+ /**
+ * Mirrors {@link StandardConnectorNode#isMigrationSupported} on the mock {@link ConnectorNode}: returns false
+ * unless the underlying {@link Connector} implements {@link MigratableConnector}, and swallows any exception
+ * thrown by the underlying call by returning false. Tests that exercise the listing/eligibility path must call
+ * this so the mock reflects the framework-side gating rather than Mockito's default {@code false}.
+ */
+ private void stubFrameworkMigrationGating(final ConnectorNode connectorNode) {
+ when(connectorNode.isMigrationSupported(any())).thenAnswer(invocation -> {
+ final Connector connector = connectorNode.getConnector();
+ if (!(connector instanceof final MigratableConnector migratableConnector)) {
+ return false;
+ }
+ try {
+ return migratableConnector.isMigrationSupported(invocation.getArgument(0));
+ } catch (final Exception ignored) {
+ return false;
+ }
+ });
+ }
+
+ private Port mockSourcePort(final ProcessGroup parentGroup, final String portId, final ScheduledState scheduledState) {
+ final Port port = mock(Port.class);
+ when(port.getIdentifier()).thenReturn(portId);
+ when(port.getScheduledState()).thenReturn(scheduledState);
+ when(port.getProcessGroup()).thenReturn(parentGroup);
+ return port;
+ }
+
+ private ProcessGroup wireSourceProcessGroup(final FlowController flowController, final String groupId, final String groupName) {
+ final FlowManager flowManager = flowController.getFlowManager();
+ final ProcessGroup rootGroup = mock(ProcessGroup.class);
+ final ProcessGroup sourceProcessGroup = mock(ProcessGroup.class);
+ when(flowManager.getRootGroup()).thenReturn(rootGroup);
+ when(rootGroup.findProcessGroup(groupId)).thenReturn(sourceProcessGroup);
+ when(sourceProcessGroup.getName()).thenReturn(groupName);
+ return sourceProcessGroup;
+ }
+
+ private ProcessGroup mockVersionedGroup(final String groupId, final VersionedFlowState state) {
+ final ProcessGroup processGroup = mock(ProcessGroup.class);
+ when(processGroup.getIdentifier()).thenReturn(groupId);
+ when(processGroup.getConnectorIdentifier()).thenReturn(Optional.empty());
+ final VersionControlInformation versionControlInformation = mock(VersionControlInformation.class);
+ final VersionedFlowStatus status = mock(VersionedFlowStatus.class);
+ when(status.getState()).thenReturn(state);
+ when(versionControlInformation.getStatus()).thenReturn(status);
+ when(processGroup.getVersionControlInformation()).thenReturn(versionControlInformation);
+ return processGroup;
+ }
+
+ private VersionedExternalFlow createSourceFlowWithLocalStateCount(final int localStateCount) {
+ final VersionedComponentState componentState = new VersionedComponentState();
+ componentState.setLocalNodeStates(createNodeStates(localStateCount));
+
+ final VersionedProcessor processor = new VersionedProcessor();
+ processor.setIdentifier("processor-1");
+ processor.setName("count-1");
+ processor.setComponentState(componentState);
+
+ final VersionedProcessGroup processGroup = new VersionedProcessGroup();
+ processGroup.setIdentifier("group-1");
+ processGroup.setName("Source Flow");
+ processGroup.setProcessors(Collections.singleton(processor));
+
+ final VersionedExternalFlow sourceFlow = new VersionedExternalFlow();
+ sourceFlow.setFlowContents(processGroup);
+ sourceFlow.setExternalControllerServices(Collections.emptyMap());
+ sourceFlow.setParameterContexts(Collections.emptyMap());
+ sourceFlow.setParameterProviders(Collections.emptyMap());
+ return sourceFlow;
+ }
+
+ private List createNodeStates(final int localStateCount) {
+ final List nodeStates = new ArrayList<>();
+ for (int i = 0; i < localStateCount; i++) {
+ nodeStates.add(new VersionedNodeState(Map.of("count", Integer.toString(i + 1))));
+ }
+ return nodeStates;
+ }
+}
diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestStandardConnectorNode.java b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestStandardConnectorNode.java
index bbd0497a257f..e8afda888ea5 100644
--- a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestStandardConnectorNode.java
+++ b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestStandardConnectorNode.java
@@ -24,10 +24,17 @@
import org.apache.nifi.components.connector.components.FlowContext;
import org.apache.nifi.components.connector.components.FlowContextType;
import org.apache.nifi.components.connector.secrets.SecretsManager;
+import org.apache.nifi.components.state.Scope;
+import org.apache.nifi.components.state.StateManager;
+import org.apache.nifi.components.state.StateManagerProvider;
+import org.apache.nifi.components.state.StateMap;
import org.apache.nifi.components.validation.ValidationState;
import org.apache.nifi.components.validation.ValidationStatus;
+import org.apache.nifi.controller.MockStateManagerProvider;
+import org.apache.nifi.controller.ProcessorNode;
import org.apache.nifi.controller.flow.FlowManager;
import org.apache.nifi.controller.queue.QueueSize;
+import org.apache.nifi.controller.state.StandardStateMap;
import org.apache.nifi.engine.FlowEngine;
import org.apache.nifi.flow.Bundle;
import org.apache.nifi.flow.VersionedExternalFlow;
@@ -42,6 +49,7 @@
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
+import java.io.IOException;
import java.time.Duration;
import java.util.Collection;
import java.util.Collections;
@@ -49,6 +57,7 @@
import java.util.HashSet;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
@@ -62,6 +71,7 @@
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anySet;
import static org.mockito.ArgumentMatchers.eq;
@@ -84,14 +94,18 @@ public class TestStandardConnectorNode {
private SecretsManager secretsManager;
private FlowContextFactory flowContextFactory;
+ private StateManagerProvider stateManagerProvider;
@BeforeEach
public void setUp() {
MockitoAnnotations.openMocks(this);
scheduler = new FlowEngine(1, "flow-engine");
+ stateManagerProvider = new MockStateManagerProvider();
when(managedProcessGroup.purge()).thenReturn(CompletableFuture.completedFuture(null));
when(managedProcessGroup.getQueueSize()).thenReturn(new QueueSize(0, 0L));
+ when(managedProcessGroup.findAllProcessors()).thenReturn(List.of());
+ when(managedProcessGroup.findAllControllerServices()).thenReturn(Set.of());
flowContextFactory = new FlowContextFactory() {
@Override
@@ -554,6 +568,75 @@ public void testDiscardWorkingConfigurationFiresOnConfiguredForEveryWorkingStep(
assertTrue(trackingConnector.wasOnPropertyGroupConfiguredCalled("step2"));
}
+ @Test
+ public void testApplyMigratedConfigurationReturnsMergedConfigurationWithoutMutatingActive() throws FlowUpdateException {
+ final TrackingConnector trackingConnector = new TrackingConnector();
+ final StandardConnectorNode connectorNode = createConnectorNode(trackingConnector);
+
+ // Populate the active configuration with pre-migration values.
+ connectorNode.transitionStateForUpdating();
+ connectorNode.prepareForUpdate();
+ connectorNode.setConfiguration("stepA", createStepConfiguration(Map.of("propA", "before-migration")));
+ connectorNode.setConfiguration("stepB", createStepConfiguration(Map.of("propB", "before-migration")));
+ connectorNode.applyUpdate();
+
+ // Build the merged working configuration the way the migration context does: clone the active configuration,
+ // merge new values onto stepA, and replace stepB wholesale.
+ final MutableConnectorConfigurationContext working = connectorNode.getActiveFlowContext().getConfigurationContext().clone();
+ working.setProperties("stepA", createStepConfiguration(Map.of("propA", "after-migration", "propC", "added-by-merge")));
+ working.replaceProperties("stepB", createStepConfiguration(Map.of("propB-renamed", "after-migration")));
+
+ final ConnectorConfiguration merged = connectorNode.applyMigratedConfiguration(working);
+
+ // The returned merged configuration must reflect the applied changes...
+ final NamedStepConfiguration mergedStepA = merged.getNamedStepConfiguration("stepA");
+ assertEquals(new StringLiteralValue("after-migration"), mergedStepA.configuration().getPropertyValues().get("propA"));
+ assertEquals(new StringLiteralValue("added-by-merge"), mergedStepA.configuration().getPropertyValues().get("propC"));
+ final NamedStepConfiguration mergedStepB = merged.getNamedStepConfiguration("stepB");
+ assertEquals(Map.of("propB-renamed", new StringLiteralValue("after-migration")), mergedStepB.configuration().getPropertyValues());
+
+ // ...but the active configuration must still hold the pre-migration values. This is the durability boundary:
+ // the migration outcome is only persisted onto active when commitMigratedConfiguration runs after the state
+ // phase succeeds.
+ assertEquals(Map.of("propA", new StringLiteralValue("before-migration")), activeStepProperties(connectorNode, "stepA"));
+ assertEquals(Map.of("propB", new StringLiteralValue("before-migration")), activeStepProperties(connectorNode, "stepB"));
+ }
+
+ @Test
+ public void testCommitMigratedConfigurationWritesMergedConfigurationOntoActive() throws FlowUpdateException {
+ final TrackingConnector trackingConnector = new TrackingConnector();
+ final StandardConnectorNode connectorNode = createConnectorNode(trackingConnector);
+
+ connectorNode.transitionStateForUpdating();
+ connectorNode.prepareForUpdate();
+ connectorNode.setConfiguration("stepA", createStepConfiguration(Map.of("propA", "before-migration")));
+ connectorNode.setConfiguration("stepB", createStepConfiguration(Map.of("propB", "before-migration")));
+ connectorNode.applyUpdate();
+
+ final MutableConnectorConfigurationContext working = connectorNode.getActiveFlowContext().getConfigurationContext().clone();
+ working.setProperties("stepA", createStepConfiguration(Map.of("propA", "after-migration")));
+ working.replaceProperties("stepB", createStepConfiguration(Map.of("propB-renamed", "after-migration")));
+
+ final ConnectorConfiguration merged = connectorNode.applyMigratedConfiguration(working);
+ connectorNode.commitMigratedConfiguration(merged);
+
+ assertEquals(Map.of("propA", new StringLiteralValue("after-migration")), activeStepProperties(connectorNode, "stepA"));
+ // replaceProperties removed propB and introduced propB-renamed.
+ assertEquals(Map.of("propB-renamed", new StringLiteralValue("after-migration")), activeStepProperties(connectorNode, "stepB"));
+ }
+
+ @Test
+ public void testCommitMigratedConfigurationRejectsNullMergedConfiguration() throws FlowUpdateException {
+ final StandardConnectorNode connectorNode = createConnectorNode();
+ assertThrows(NullPointerException.class, () -> connectorNode.commitMigratedConfiguration(null));
+ }
+
+ private static Map activeStepProperties(final StandardConnectorNode connectorNode, final String stepName) {
+ final ConnectorConfiguration activeConfig = connectorNode.getActiveFlowContext().getConfigurationContext().toConnectorConfiguration();
+ final NamedStepConfiguration namedStep = activeConfig.getNamedStepConfiguration(stepName);
+ return namedStep == null ? Map.of() : namedStep.configuration().getPropertyValues();
+ }
+
@Test
public void testDiscardWorkingConfigurationContinuesOnStepFailure() throws FlowUpdateException {
final FailingStepConnector failingStepConnector = new FailingStepConnector("failingStep");
@@ -911,6 +994,166 @@ public void testGetProcessGroupReturnsManagedFlowRoot() throws Exception {
assertEquals(managedProcessGroup, connectorNode.getProcessGroup());
}
+ @Test
+ public void testIsModifiedReportsFalseForFreshlyCreatedConnector() throws FlowUpdateException {
+ final DefaultValueConnector connector = new DefaultValueConnector();
+ final StandardConnectorNode node = createConnectorNode(connector);
+
+ // A freshly created Connector has not been configured away from its defaults and has no component state, so it
+ // is not modified.
+ assertFalse(node.isModified());
+ }
+
+ @Test
+ public void testIsModifiedReportsFalseWhenConfigurationEqualsDefaults() throws FlowUpdateException {
+ final DefaultValueConnector connector = new DefaultValueConnector();
+ final StandardConnectorNode node = createConnectorNode(connector);
+
+ // Explicitly setting properties to the same values the connector declares as defaults leaves the Connector
+ // unmodified.
+ seedActiveConfiguration(node, "settings", Map.of(
+ "Greeting", new StringLiteralValue("Hello"),
+ "Repeat Count", new StringLiteralValue("1")));
+
+ assertFalse(node.isModified());
+ }
+
+ @Test
+ public void testIsModifiedReportsTrueWhenAStringPropertyDiffersFromDefault() throws FlowUpdateException {
+ final DefaultValueConnector connector = new DefaultValueConnector();
+ final StandardConnectorNode node = createConnectorNode(connector);
+
+ // Changing a single property value away from its default is enough to make the Connector modified, even though
+ // the managed flow structure is unchanged. This is the scenario that a flow-structure comparison misses.
+ seedActiveConfiguration(node, "settings", Map.of(
+ "Greeting", new StringLiteralValue("Goodbye"),
+ "Repeat Count", new StringLiteralValue("1")));
+
+ assertTrue(node.isModified());
+ }
+
+ @Test
+ public void testIsModifiedReportsTrueWhenPropertyConfiguredWithSecretReference() throws FlowUpdateException {
+ final DefaultValueConnector connector = new DefaultValueConnector();
+ final StandardConnectorNode node = createConnectorNode(connector);
+
+ // A Secret reference can never be a default value, so its presence means the Connector has been configured.
+ seedActiveConfiguration(node, "settings", Map.of(
+ "Greeting", new SecretReference("pid", "My Provider", "greeting-secret", "My Provider.greeting-secret"),
+ "Repeat Count", new StringLiteralValue("1")));
+
+ assertTrue(node.isModified());
+ }
+
+ @Test
+ public void testIsModifiedReportsFalseWhenPropertyConfiguredWithStructurallyEmptyTypedReference() throws FlowUpdateException {
+ final DefaultValueConnector connector = new DefaultValueConnector();
+ final StandardConnectorNode node = createConnectorNode(connector);
+
+ // A structurally-empty SecretReference or AssetReference (no provider/secret name, no asset identifiers) is a
+ // placeholder for an unset property, not a configured value, so it must not be treated as a modification.
+ seedActiveConfiguration(node, "settings", Map.of(
+ "Greeting", new SecretReference(null, "My Provider", null, null),
+ "Repeat Count", new StringLiteralValue("1")));
+
+ assertFalse(node.isModified());
+
+ seedActiveConfiguration(node, "settings", Map.of(
+ "Greeting", new AssetReference(Set.of()),
+ "Repeat Count", new StringLiteralValue("1")));
+
+ assertFalse(node.isModified());
+ }
+
+ @Test
+ public void testIsModifiedReportsTrueWhenPropertyConfiguredWithPopulatedAssetReference() throws FlowUpdateException {
+ final DefaultValueConnector connector = new DefaultValueConnector();
+ final StandardConnectorNode node = createConnectorNode(connector);
+
+ // An Asset reference that actually points at an asset can never be a default value, so its presence means the
+ // Connector has been configured.
+ seedActiveConfiguration(node, "settings", Map.of(
+ "Greeting", new AssetReference(Set.of("asset-1")),
+ "Repeat Count", new StringLiteralValue("1")));
+
+ assertTrue(node.isModified());
+ }
+
+ @Test
+ public void testIsModifiedReportsTrueWhenWorkingConfigurationDiffersFromDefault() throws FlowUpdateException {
+ final DefaultValueConnector connector = new DefaultValueConnector();
+ final StandardConnectorNode node = createConnectorNode(connector);
+
+ // A pending (working) configuration change that has not yet been applied to the active configuration is still a
+ // modification that must block migration.
+ node.getWorkingFlowContext().getConfigurationContext().setProperties("settings",
+ new StepConfiguration(Map.of("Greeting", new StringLiteralValue("Goodbye"))));
+
+ assertTrue(node.isModified());
+ }
+
+ @Test
+ public void testIsModifiedReportsTrueWhenAManagedComponentHasStoredState() throws FlowUpdateException {
+ stateManagerProvider = stateManagerProviderWithStoredState();
+
+ final ProcessorNode statefulProcessor = mock(ProcessorNode.class);
+ when(statefulProcessor.getIdentifier()).thenReturn("stateful-processor");
+ when(managedProcessGroup.findAllProcessors()).thenReturn(List.of(statefulProcessor));
+
+ final DefaultValueConnector connector = new DefaultValueConnector();
+ final StandardConnectorNode node = createConnectorNode(connector);
+
+ // Even with the configuration entirely at its defaults, a managed component that has accumulated state means the
+ // flow has been run and migration must not overwrite that state.
+ assertTrue(node.isModified());
+ }
+
+ private static void seedActiveConfiguration(final StandardConnectorNode node, final String stepName, final Map properties) {
+ node.getActiveFlowContext().getConfigurationContext().setProperties(stepName, new StepConfiguration(properties));
+ }
+
+ private StateManagerProvider stateManagerProviderWithStoredState() {
+ return new StateManagerProvider() {
+ @Override
+ public StateManager getStateManager(final String componentId) {
+ final StateManager stateManager = mock(StateManager.class);
+ final StateMap storedState = new StandardStateMap(Map.of("key", "value"), Optional.of("1"));
+ try {
+ when(stateManager.getState(any(Scope.class))).thenReturn(storedState);
+ } catch (final IOException e) {
+ throw new AssertionError(e);
+ }
+ return stateManager;
+ }
+
+ @Override
+ public StateManager getStateManager(final String componentId, final boolean dropStateKeySupported) {
+ return getStateManager(componentId);
+ }
+
+ @Override
+ public void shutdown() {
+ }
+
+ @Override
+ public void enableClusterProvider() {
+ }
+
+ @Override
+ public void disableClusterProvider() {
+ }
+
+ @Override
+ public boolean isClusterProviderEnabled() {
+ return false;
+ }
+
+ @Override
+ public void onComponentRemoved(final String componentId) {
+ }
+ };
+ }
+
private StandardConnectorNode createConnectorNode() throws FlowUpdateException {
final SleepingConnector sleepingConnector = new SleepingConnector(Duration.ofMillis(1));
return createConnectorNode(sleepingConnector);
@@ -931,6 +1174,7 @@ private StandardConnectorNode createConnectorNode(final Connector connector, fin
"test-connector-id",
mock(FlowManager.class),
extensionManager,
+ stateManagerProvider,
null,
createConnectorDetails(connector),
"TestConnector",
@@ -1258,6 +1502,69 @@ public List verifyConfigurationStep(final String stepN
}
}
+ /**
+ * Test connector declaring a single configuration step with two properties that have default values. Used to
+ * exercise the configuration-versus-default comparison performed by {@code isModified()}.
+ */
+ private static class DefaultValueConnector extends AbstractConnector {
+ @Override
+ public VersionedExternalFlow getInitialFlow() {
+ return null;
+ }
+
+ @Override
+ public VersionedExternalFlow getActiveFlow(final FlowContext activeFlowContext) {
+ return null;
+ }
+
+ @Override
+ public void prepareForUpdate(final FlowContext workingContext, final FlowContext activeContext) {
+ }
+
+ @Override
+ public List getConfigurationSteps() {
+ final ConnectorPropertyDescriptor greeting = new ConnectorPropertyDescriptor.Builder()
+ .name("Greeting")
+ .description("Greeting text")
+ .required(true)
+ .defaultValue("Hello")
+ .build();
+
+ final ConnectorPropertyDescriptor repeatCount = new ConnectorPropertyDescriptor.Builder()
+ .name("Repeat Count")
+ .description("Number of times to repeat the greeting")
+ .required(true)
+ .defaultValue("1")
+ .build();
+
+ final ConnectorPropertyGroup propertyGroup = ConnectorPropertyGroup.builder()
+ .name("General")
+ .description("General settings")
+ .properties(List.of(greeting, repeatCount))
+ .build();
+
+ final ConfigurationStep step = new ConfigurationStep.Builder()
+ .name("settings")
+ .propertyGroups(List.of(propertyGroup))
+ .build();
+
+ return List.of(step);
+ }
+
+ @Override
+ public void applyUpdate(final FlowContext workingContext, final FlowContext activeContext) {
+ }
+
+ @Override
+ protected void onStepConfigured(final String stepName, final FlowContext workingContext) {
+ }
+
+ @Override
+ public List verifyConfigurationStep(final String stepName, final Map overrides, final FlowContext flowContext) {
+ return List.of();
+ }
+ }
+
/**
* Test connector that allows control over when drainFlowFiles completes via a CompletableFuture
*/
diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestStandardConnectorRepository.java b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestStandardConnectorRepository.java
index 7381302ff1d7..5e496f18ebb4 100644
--- a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestStandardConnectorRepository.java
+++ b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestStandardConnectorRepository.java
@@ -24,6 +24,7 @@
import org.apache.nifi.components.connector.components.FlowContext;
import org.apache.nifi.components.connector.components.FlowContextType;
import org.apache.nifi.components.connector.secrets.SecretsManager;
+import org.apache.nifi.controller.MockStateManagerProvider;
import org.apache.nifi.controller.ParameterProviderNode;
import org.apache.nifi.controller.flow.FlowManager;
import org.apache.nifi.controller.queue.QueueSize;
@@ -131,6 +132,127 @@ public void testRestoreConnector() {
assertEquals(connector, repository.getConnector("connector-1", ConnectorSyncMode.SYNC_WITH_PROVIDER));
}
+ @Test
+ public void testRestoreConnectorDoesNotCleanUpAssetsBeforeSynchronization() {
+ final AssetManager assetManager = mock(AssetManager.class);
+ final StandardConnectorRepository repository = createRepositoryWithProviderAndAssetManager(null, assetManager);
+
+ // At restore time the Connector's managed flow has not yet been synchronized: its Managed Process Group
+ // and managed Parameter Context are empty, so its flow contexts reference no assets. Deleting assets here
+ // would discard assets that the Connector's not-yet-restored flow still references. A Connector restored in
+ // Troubleshooting mode relies on these assets surviving until restoreTroubleshootingFlow re-establishes the
+ // references, so restoreConnector must not delete any assets.
+ final Asset asset = mock(Asset.class);
+ when(asset.getIdentifier()).thenReturn("asset-1");
+ when(asset.getName()).thenReturn("asset-1.txt");
+
+ final ConnectorNode connector = mock(ConnectorNode.class);
+ when(connector.getIdentifier()).thenReturn("connector-1");
+ when(assetManager.getAssets("connector-1")).thenReturn(List.of(asset));
+
+ repository.restoreConnector(connector);
+
+ verify(assetManager, never()).deleteAsset(anyString());
+ }
+
+ @Test
+ public void testSyncConnectorCleansUpUnreferencedAssets() throws Exception {
+ final AssetManager assetManager = mock(AssetManager.class);
+ final StandardConnectorRepository repository = createRepositoryWithProviderAndAssetManager(null, assetManager);
+
+ final Asset referencedAsset = mock(Asset.class);
+ when(referencedAsset.getIdentifier()).thenReturn("referenced-asset");
+ when(referencedAsset.getName()).thenReturn("referenced.txt");
+
+ final Asset unreferencedAsset = mock(Asset.class);
+ when(unreferencedAsset.getIdentifier()).thenReturn("unreferenced-asset");
+ when(unreferencedAsset.getName()).thenReturn("unreferenced.txt");
+
+ final MutableConnectorConfigurationContext activeConfigContext = mock(MutableConnectorConfigurationContext.class);
+ final ConnectorConfiguration activeConfiguration = new ConnectorConfiguration(Set.of(
+ new NamedStepConfiguration("step1", new StepConfiguration(Map.of("property", new AssetReference(Set.of("referenced-asset")))))
+ ));
+ when(activeConfigContext.toConnectorConfiguration()).thenReturn(activeConfiguration);
+
+ final FrameworkFlowContext activeFlowContext = mock(FrameworkFlowContext.class);
+ when(activeFlowContext.getConfigurationContext()).thenReturn(activeConfigContext);
+
+ final MutableConnectorConfigurationContext workingConfigContext = mock(MutableConnectorConfigurationContext.class);
+ when(workingConfigContext.toConnectorConfiguration()).thenReturn(new ConnectorConfiguration(Set.of()));
+
+ final FrameworkFlowContext workingFlowContext = mock(FrameworkFlowContext.class);
+ when(workingFlowContext.getConfigurationContext()).thenReturn(workingConfigContext);
+
+ final ConnectorNode connector = mock(ConnectorNode.class);
+ when(connector.getIdentifier()).thenReturn("connector-1");
+ when(connector.getCurrentState()).thenReturn(ConnectorState.STOPPED);
+ when(connector.getActiveFlowContext()).thenReturn(activeFlowContext);
+ when(connector.getWorkingFlowContext()).thenReturn(workingFlowContext);
+
+ repository.restoreConnector(connector);
+
+ when(assetManager.getAssets("connector-1")).thenReturn(List.of(referencedAsset, unreferencedAsset));
+
+ final VersionedConnector versioned = createVersionedConnector("connector-1", "Test Connector", VersionedConnectorState.ENABLED, List.of());
+ repository.syncConnector(versioned);
+
+ verify(assetManager).deleteAsset("unreferenced-asset");
+ verify(assetManager, never()).deleteAsset("referenced-asset");
+ }
+
+ @Test
+ public void testSyncConnectorSkipsAssetCleanupWhileMigrationInProgress() throws Exception {
+ final AssetManager assetManager = mock(AssetManager.class);
+ final StandardConnectorRepository repository = createRepositoryWithProviderAndAssetManager(null, assetManager);
+
+ final Asset unreferencedAsset = mock(Asset.class);
+ when(unreferencedAsset.getIdentifier()).thenReturn("copied-during-migration");
+ when(unreferencedAsset.getName()).thenReturn("copied.txt");
+
+ final ConnectorNode connector = createConnectorNodeWithEmptyWorkingConfig("connector-1", "Test Connector");
+ when(connector.getCurrentState()).thenReturn(ConnectorState.STOPPED);
+ repository.restoreConnector(connector);
+
+ when(assetManager.getAssets("connector-1")).thenReturn(List.of(unreferencedAsset));
+
+ final VersionedConnector versioned = createVersionedConnector("connector-1", "Test Connector", VersionedConnectorState.ENABLED, List.of());
+
+ // While a migration is in progress, a sync must not reclaim assets the migration may have copied before the
+ // managed Process Group is rebuilt to reference them, even though they appear unreferenced.
+ repository.beginMigration("connector-1");
+ repository.syncConnector(versioned);
+ verify(assetManager, never()).deleteAsset(anyString());
+
+ // Once the migration completes, the next sync reclaims the now-genuinely-unreferenced asset.
+ repository.endMigration("connector-1");
+ repository.syncConnector(versioned);
+ verify(assetManager).deleteAsset("copied-during-migration");
+ }
+
+ @Test
+ public void testSyncConnectorInTroubleshootingDoesNotCleanUpAssets() throws Exception {
+ final AssetManager assetManager = mock(AssetManager.class);
+ final StandardConnectorRepository repository = createRepositoryWithProviderAndAssetManager(null, assetManager);
+
+ // An Asset uploaded while in Troubleshooting mode may not be referenced by the experimental flow yet, and the
+ // user may have temporarily overridden the authoritative Asset reference. Restoring the Connector in
+ // Troubleshooting mode must therefore retain every Asset rather than deleting those that appear unreferenced.
+ final Asset unreferencedAsset = mock(Asset.class);
+ when(unreferencedAsset.getIdentifier()).thenReturn("uploaded-during-troubleshooting");
+ when(unreferencedAsset.getName()).thenReturn("uploaded.txt");
+
+ final ConnectorNode connector = createConnectorNodeWithEmptyWorkingConfig("connector-1", "Test Connector");
+ when(connector.getCurrentState()).thenReturn(ConnectorState.STOPPED);
+ repository.restoreConnector(connector);
+
+ when(assetManager.getAssets("connector-1")).thenReturn(List.of(unreferencedAsset));
+
+ final VersionedConnector versioned = createVersionedConnector("connector-1", "Test Connector", VersionedConnectorState.TROUBLESHOOTING, List.of());
+ repository.syncConnector(versioned);
+
+ verify(assetManager, never()).deleteAsset(anyString());
+ }
+
@Test
public void testGetConnectorsReturnsNewListInstances() {
final StandardConnectorRepository repository = new StandardConnectorRepository();
@@ -1058,6 +1180,34 @@ public void testApplyUpdateFailureCallsAbortUpdateButNotMarkInvalid() throws Flo
verify(connector, never()).markInvalid(anyString(), anyString());
}
+ @Test
+ public void testApplyUpdateAbortsWhenClusterStateRepeatedlyUnavailable() throws Exception {
+ final StandardConnectorRepository repository = new StandardConnectorRepository();
+ final ConnectorRepositoryInitializationContext initContext = mock(ConnectorRepositoryInitializationContext.class);
+ when(initContext.getFlowManager()).thenReturn(mock(FlowManager.class));
+ when(initContext.getExtensionManager()).thenReturn(mock(ExtensionManager.class));
+
+ final AssetManager assetManager = mock(AssetManager.class);
+ when(assetManager.getAssets("connector-1")).thenReturn(List.of());
+ when(initContext.getAssetManager()).thenReturn(assetManager);
+
+ final ConnectorRequestReplicator requestReplicator = mock(ConnectorRequestReplicator.class);
+ when(requestReplicator.getState(anyString())).thenThrow(new IOException("cluster state unavailable"));
+ when(initContext.getRequestReplicator()).thenReturn(requestReplicator);
+ repository.initialize(initContext);
+
+ final ConnectorNode connector = mock(ConnectorNode.class);
+ when(connector.getIdentifier()).thenReturn("connector-1");
+ when(connector.getDesiredState()).thenReturn(ConnectorState.STOPPED);
+ repository.addConnector(connector);
+
+ repository.applyUpdate(connector, mock(ConnectorUpdateContext.class));
+
+ // An inability to read cluster state must abort the update rather than retry indefinitely. The IOException
+ // propagates out of waitForState and is handed to abortUpdate, rather than being swallowed by a retry loop.
+ verify(connector, timeout(5000)).abortUpdate(any(IOException.class));
+ }
+
@Test
public void testApplyUpdateInTroubleshootingThrowsBeforeProviderConsultation() {
final ConnectorConfigurationProvider provider = mock(ConnectorConfigurationProvider.class);
@@ -1728,6 +1878,61 @@ public void testRestoreConnectorAppliesAllAttributesWhenAboveCardinalityThreshol
verify(connector).setCustomLoggingAttributes(expected);
}
+ @Test
+ public void testVerifyMigrationDelegatesToProvider() {
+ final ConnectorConfigurationProvider provider = mock(ConnectorConfigurationProvider.class);
+ final StandardConnectorRepository repository = createRepositoryWithProvider(provider);
+
+ repository.verifyMigration("connector-1");
+ verify(provider).verifyMigration("connector-1");
+
+ doThrow(new ConnectorConfigurationProviderException("provider rejected")).when(provider).verifyMigration("connector-1");
+ assertThrows(ConnectorConfigurationProviderException.class, () -> repository.verifyMigration("connector-1"));
+ }
+
+ @Test
+ public void testVerifyMigrationWithNullProviderIsNoOp() {
+ final StandardConnectorRepository repository = createRepositoryWithProvider(null);
+ repository.verifyMigration("connector-1");
+ }
+
+ @Test
+ public void testNotifyMigrationCompleteDelegatesToProvider() {
+ final ConnectorConfigurationProvider provider = mock(ConnectorConfigurationProvider.class);
+ final StandardConnectorRepository repository = createRepositoryWithProvider(provider);
+
+ repository.notifyMigrationComplete("connector-1", "source-group");
+ verify(provider).migrationComplete("connector-1", "source-group");
+
+ repository.notifyMigrationComplete("connector-1", null);
+ verify(provider).migrationComplete("connector-1", null);
+ }
+
+ @Test
+ public void testNotifyMigrationCompleteWithNullProviderIsNoOp() {
+ final StandardConnectorRepository repository = createRepositoryWithProvider(null);
+ repository.notifyMigrationComplete("connector-1", "source-group");
+ }
+
+ @Test
+ public void testNotifyMigrationCompleteSavesWorkingConfigurationToProvider() {
+ final ConnectorConfigurationProvider provider = mock(ConnectorConfigurationProvider.class);
+ final StandardConnectorRepository repository = createRepositoryWithProvider(provider);
+
+ final ConnectorNode connector = createConnectorNodeWithEmptyWorkingConfig("connector-1", "Test Connector");
+ repository.addConnector(connector);
+
+ // notifyMigrationComplete runs after commitMigratedConfiguration has already written the merged configuration
+ // onto the active configuration and rebuilt the working flow context from it, so the working flow context
+ // reflects the migrated configuration by the time this call is made.
+ repository.notifyMigrationComplete("connector-1", "source-group");
+
+ final ArgumentCaptor configCaptor = ArgumentCaptor.forClass(ConnectorWorkingConfiguration.class);
+ verify(provider).save(eq("connector-1"), configCaptor.capture());
+ assertEquals("Test Connector", configCaptor.getValue().getName());
+ verify(provider).migrationComplete("connector-1", "source-group");
+ }
+
// --- Helper Methods ---
private StandardConnectorRepository createRepositoryWithProviderAndAssetManager(
@@ -1882,7 +2087,7 @@ public void trigger(final ConnectorNode node) {
final ConnectorDetails connectorDetails = new ConnectorDetails(connector, bundleCoordinate, componentLog);
final StandardConnectorNode node = new StandardConnectorNode(
- identifier, mock(FlowManager.class), extensionManager, null, connectorDetails,
+ identifier, mock(FlowManager.class), extensionManager, new MockStateManagerProvider(), null, connectorDetails,
"TestConnector", connector.getClass().getCanonicalName(),
new StandardConnectorConfigurationContext(assetManager, secretsManager),
stateTransition, flowContextFactory, validationTrigger, false);
diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestStandardFrameworkConnectorMigrationContext.java b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestStandardFrameworkConnectorMigrationContext.java
new file mode 100644
index 000000000000..43a20c794032
--- /dev/null
+++ b/nifi-framework-bundle/nifi-framework/nifi-framework-core/src/test/java/org/apache/nifi/components/connector/TestStandardFrameworkConnectorMigrationContext.java
@@ -0,0 +1,381 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.components.connector;
+
+import org.apache.nifi.asset.Asset;
+import org.apache.nifi.asset.AssetManager;
+import org.apache.nifi.components.connector.secrets.SecretsManager;
+import org.apache.nifi.components.state.StateManagerProvider;
+import org.apache.nifi.controller.ClusterTopologyProvider;
+import org.apache.nifi.flow.VersionedComponentState;
+import org.apache.nifi.flow.VersionedExternalFlow;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class TestStandardFrameworkConnectorMigrationContext {
+
+ private static final String CONNECTOR_ID = "connector-1";
+ private static final String SOURCE_ASSET_ID = "source-asset-1";
+ private static final String SOURCE_ASSET_NAME = "driver.jar";
+
+ private AssetManager sourceAssetManager;
+ private ConnectorRepository connectorRepository;
+ private StateManagerProvider stateManagerProvider;
+ private ClusterTopologyProvider clusterTopologyProvider;
+ private VersionedExternalFlow sourceFlow;
+
+ @BeforeEach
+ public void setup() {
+ sourceAssetManager = mock(AssetManager.class);
+ connectorRepository = mock(ConnectorRepository.class);
+ stateManagerProvider = mock(StateManagerProvider.class);
+ clusterTopologyProvider = mock(ClusterTopologyProvider.class);
+ sourceFlow = mock(VersionedExternalFlow.class);
+ }
+
+ @Test
+ public void testCopyAssetFromSourceReturnsReferenceToCopiedAsset(@TempDir final Path tempDir) throws Exception {
+ final File sourceFile = createAssetFile(tempDir, "source-contents");
+ final Asset sourceAsset = mockAsset(SOURCE_ASSET_ID, SOURCE_ASSET_NAME, sourceFile);
+ when(sourceAssetManager.getAsset(SOURCE_ASSET_ID)).thenReturn(Optional.of(sourceAsset));
+ when(connectorRepository.getAsset(anyString())).thenReturn(Optional.empty());
+
+ final File copiedFile = createAssetFile(tempDir, "copied-contents");
+ when(connectorRepository.storeAsset(eq(CONNECTOR_ID), anyString(), eq(SOURCE_ASSET_NAME), any(InputStream.class)))
+ .thenAnswer(invocation -> mockAsset(invocation.getArgument(1), SOURCE_ASSET_NAME, copiedFile));
+
+ final StandardFrameworkConnectorMigrationContext context = createContext(true);
+
+ final AssetReference reference = context.copyAssetFromSource(SOURCE_ASSET_ID);
+
+ assertNotNull(reference);
+ assertEquals(1, reference.getAssetIdentifiers().size());
+ final String copiedAssetId = reference.getAssetIdentifiers().iterator().next();
+ assertEquals(Set.of(copiedAssetId), context.getCopiedAssetIds());
+ verify(connectorRepository).storeAsset(eq(CONNECTOR_ID), eq(copiedAssetId), eq(SOURCE_ASSET_NAME), any(InputStream.class));
+ }
+
+ @Test
+ public void testCopyAssetFromSourceReturnsEmptyReferenceWhenSourceAssetIsMissing() throws Exception {
+ when(sourceAssetManager.getAsset(SOURCE_ASSET_ID)).thenReturn(Optional.empty());
+ when(connectorRepository.getAsset(anyString())).thenReturn(Optional.empty());
+
+ final StandardFrameworkConnectorMigrationContext context = createContext(true);
+
+ final AssetReference reference = context.copyAssetFromSource(SOURCE_ASSET_ID);
+
+ assertNotNull(reference);
+ assertTrue(reference.getAssetIdentifiers().isEmpty(),
+ "Asset reference must be empty when the source asset cannot be located");
+ assertTrue(context.getCopiedAssetIds().isEmpty(),
+ "Copied asset bookkeeping must remain empty when no asset is copied");
+ verify(connectorRepository, never()).storeAsset(anyString(), anyString(), anyString(), any(InputStream.class));
+ }
+
+ @Test
+ public void testCopyAssetFromSourceReusesPreviouslyCopiedAsset(@TempDir final Path tempDir) throws Exception {
+ final File copiedFile = createAssetFile(tempDir, "previously-copied");
+ final Asset existing = mockAsset("already-copied", SOURCE_ASSET_NAME, copiedFile);
+ when(connectorRepository.getAsset(anyString())).thenReturn(Optional.of(existing));
+
+ final StandardFrameworkConnectorMigrationContext context = createContext(true);
+
+ final AssetReference reference = context.copyAssetFromSource(SOURCE_ASSET_ID);
+
+ assertNotNull(reference);
+ assertEquals(1, reference.getAssetIdentifiers().size());
+ verify(sourceAssetManager, never()).getAsset(anyString());
+ verify(connectorRepository, never()).storeAsset(anyString(), anyString(), anyString(), any(InputStream.class));
+ }
+
+ @Test
+ public void testCopyAssetFromSourceRejectsUploadedPayloadMigration() {
+ final StandardFrameworkConnectorMigrationContext context = createContext(false);
+
+ final IllegalStateException thrown = assertThrows(IllegalStateException.class,
+ () -> context.copyAssetFromSource(SOURCE_ASSET_ID));
+ assertTrue(thrown.getMessage().contains("local Versioned Process Group"));
+ }
+
+ @Test
+ public void testCopyAssetFromSourceRejectsBlankSourceAssetIdentifier() {
+ final StandardFrameworkConnectorMigrationContext context = createContext(true);
+
+ assertThrows(IllegalArgumentException.class, () -> context.copyAssetFromSource(null));
+ assertThrows(IllegalArgumentException.class, () -> context.copyAssetFromSource(""));
+ assertThrows(IllegalArgumentException.class, () -> context.copyAssetFromSource(" "));
+ }
+
+ @Test
+ public void testSetPropertiesMergesOntoWorkingConfiguration() {
+ final StandardFrameworkConnectorMigrationContext context = createContext(true);
+
+ context.setProperties("Step One", Map.of("p1", "v1"));
+ context.setProperties("Step One", Map.of("p2", "v2"));
+
+ final MutableConnectorConfigurationContext merged = context.getMergedConfiguration();
+ assertEquals("v1", merged.getProperty("Step One", "p1").getValue());
+ assertEquals("v2", merged.getProperty("Step One", "p2").getValue());
+ assertEquals(Set.of("p1", "p2"), merged.getPropertyNames("Step One"));
+ }
+
+ @Test
+ public void testReplacePropertiesReplacesEntireStepOnWorkingConfiguration() {
+ final StandardFrameworkConnectorMigrationContext context = createContext(true);
+
+ context.setProperties("Step One", Map.of("p1", "v1"));
+ context.replaceProperties("Step One", Map.of("p2", "v2"));
+
+ final MutableConnectorConfigurationContext merged = context.getMergedConfiguration();
+ assertNull(merged.getProperty("Step One", "p1").getValue(),
+ "replaceProperties must drop properties not present in the replacement map");
+ assertEquals("v2", merged.getProperty("Step One", "p2").getValue());
+ assertEquals(Set.of("p2"), merged.getPropertyNames("Step One"));
+ }
+
+ @Test
+ public void testSetValueReferenceMergesOntoWorkingConfiguration(@TempDir final Path tempDir) throws IOException {
+ final File assetFile = createAssetFile(tempDir, "asset-value");
+ final Asset asset = mockAsset("asset-1", "driver.jar", assetFile);
+ when(sourceAssetManager.getAsset("asset-1")).thenReturn(Optional.of(asset));
+
+ final StandardFrameworkConnectorMigrationContext context = createContext(true);
+ context.setProperties("Step One", Map.of("p1", "v1"));
+ context.setValueReference("Step One", "asset-prop", new AssetReference(Set.of("asset-1")));
+
+ final MutableConnectorConfigurationContext merged = context.getMergedConfiguration();
+ assertEquals("v1", merged.getProperty("Step One", "p1").getValue());
+ assertEquals(assetFile.getAbsolutePath(), merged.getProperty("Step One", "asset-prop").getValue());
+ assertEquals(Set.of("p1", "asset-prop"), merged.getPropertyNames("Step One"));
+
+ // A null value reference removes the property from the step while leaving the other properties intact.
+ context.setValueReference("Step One", "asset-prop", null);
+ assertNull(merged.getProperty("Step One", "asset-prop").getValue());
+ assertEquals("v1", merged.getProperty("Step One", "p1").getValue());
+ assertEquals(Set.of("p1"), merged.getPropertyNames("Step One"));
+ }
+
+ @Test
+ public void testNullValueRemovesPropertyFromStep() {
+ final StandardFrameworkConnectorMigrationContext context = createContext(true);
+
+ context.setProperties("Step", Map.of("keep", "keep-value", "drop", "drop-value"));
+
+ final MutableConnectorConfigurationContext merged = context.getMergedConfiguration();
+ assertEquals(Set.of("keep", "drop"), merged.getPropertyNames("Step"));
+
+ // A null value in the String-based setProperties removes the property rather than storing a null value.
+ final Map stringUpdate = new HashMap<>();
+ stringUpdate.put("drop", null);
+ context.setProperties("Step", stringUpdate);
+ assertEquals(Set.of("keep"), merged.getPropertyNames("Step"));
+ assertNull(merged.getProperty("Step", "drop").getValue());
+
+ context.setValueReferences("Step", Map.of("keep2", new StringLiteralValue("v2")));
+ assertEquals(Set.of("keep", "keep2"), merged.getPropertyNames("Step"));
+
+ // A null value reference in setValueReferences likewise removes the property.
+ final Map referenceUpdate = new HashMap<>();
+ referenceUpdate.put("keep2", null);
+ context.setValueReferences("Step", referenceUpdate);
+ assertEquals(Set.of("keep"), merged.getPropertyNames("Step"));
+ }
+
+ @Test
+ public void testSetValueReferencesMergesMultipleReferences(@TempDir final Path tempDir) throws IOException {
+ final File assetFile = createAssetFile(tempDir, "multi-asset");
+ final Asset asset = mockAsset("asset-2", "lib.jar", assetFile);
+ when(sourceAssetManager.getAsset("asset-2")).thenReturn(Optional.of(asset));
+
+ final StandardFrameworkConnectorMigrationContext context = createContext(true);
+ final Map references = new HashMap<>();
+ references.put("literal-prop", new StringLiteralValue("literal-value"));
+ references.put("asset-prop", new AssetReference(Set.of("asset-2")));
+ context.setValueReferences("Step Two", references);
+
+ final MutableConnectorConfigurationContext merged = context.getMergedConfiguration();
+ assertEquals("literal-value", merged.getProperty("Step Two", "literal-prop").getValue());
+ assertEquals(assetFile.getAbsolutePath(), merged.getProperty("Step Two", "asset-prop").getValue());
+ assertEquals(Set.of("literal-prop", "asset-prop"), merged.getPropertyNames("Step Two"));
+ }
+
+ @Test
+ public void testSetComponentStateBlockedInConfigurationPhase() {
+ final StandardFrameworkConnectorMigrationContext context = createContext(true);
+ final VersionedComponentState desiredState = new VersionedComponentState();
+ desiredState.setClusterState(Map.of("k", "v"));
+ desiredState.setLocalNodeStates(List.of());
+
+ final IllegalStateException thrown = assertThrows(IllegalStateException.class,
+ () -> context.setComponentState("component-1", desiredState));
+ assertTrue(thrown.getMessage().contains("migrateState"), thrown.getMessage());
+ }
+
+ @Test
+ public void testConfigurationWritesBlockedInStatePhase() {
+ final StandardFrameworkConnectorMigrationContext context = createContext(true);
+ context.setPhase(StandardFrameworkConnectorMigrationContext.Phase.STATE);
+
+ final IllegalStateException setProps = assertThrows(IllegalStateException.class,
+ () -> context.setProperties("Step", Map.of("p", "v")));
+ assertTrue(setProps.getMessage().contains("migrateConfiguration"), setProps.getMessage());
+
+ final IllegalStateException replaceProps = assertThrows(IllegalStateException.class,
+ () -> context.replaceProperties("Step", Map.of("p", "v")));
+ assertTrue(replaceProps.getMessage().contains("migrateConfiguration"), replaceProps.getMessage());
+
+ final IllegalStateException setValueRef = assertThrows(IllegalStateException.class,
+ () -> context.setValueReference("Step", "p", new StringLiteralValue("v")));
+ assertTrue(setValueRef.getMessage().contains("migrateConfiguration"), setValueRef.getMessage());
+
+ final IllegalStateException setValueRefs = assertThrows(IllegalStateException.class,
+ () -> context.setValueReferences("Step", Map.of("p", new StringLiteralValue("v"))));
+ assertTrue(setValueRefs.getMessage().contains("migrateConfiguration"), setValueRefs.getMessage());
+ }
+
+ @Test
+ public void testSetComponentStateStagesAndDrains() {
+ final StandardFrameworkConnectorMigrationContext context = createContext(true);
+ context.setPhase(StandardFrameworkConnectorMigrationContext.Phase.STATE);
+
+ final VersionedComponentState clusterState = new VersionedComponentState();
+ clusterState.setClusterState(Map.of("k", "v"));
+ clusterState.setLocalNodeStates(List.of());
+ final VersionedComponentState replacement = new VersionedComponentState();
+ replacement.setClusterState(Map.of("k2", "v2"));
+ replacement.setLocalNodeStates(List.of());
+ context.setComponentState("component-1", clusterState);
+ context.setComponentState("component-1", replacement);
+
+ final Map drained = context.drainStagedComponentStates();
+ assertEquals(1, drained.size());
+ assertEquals(replacement, drained.get("component-1"));
+ assertTrue(context.drainStagedComponentStates().isEmpty());
+ }
+
+ @Test
+ public void testSetPhaseRejectsBackwardAndSelfTransitions() {
+ final StandardFrameworkConnectorMigrationContext context = createContext(true);
+
+ // Self-transition on the initial CONFIGURATION phase is rejected: phases must move strictly forward.
+ assertThrows(IllegalStateException.class,
+ () -> context.setPhase(StandardFrameworkConnectorMigrationContext.Phase.CONFIGURATION));
+
+ context.setPhase(StandardFrameworkConnectorMigrationContext.Phase.STATE);
+
+ // STATE -> CONFIGURATION is rejected (backward).
+ assertThrows(IllegalStateException.class,
+ () -> context.setPhase(StandardFrameworkConnectorMigrationContext.Phase.CONFIGURATION));
+ // STATE -> STATE is rejected (self).
+ assertThrows(IllegalStateException.class,
+ () -> context.setPhase(StandardFrameworkConnectorMigrationContext.Phase.STATE));
+
+ context.setPhase(StandardFrameworkConnectorMigrationContext.Phase.COMPLETED);
+
+ // COMPLETED is terminal; any further transition is rejected.
+ assertThrows(IllegalStateException.class,
+ () -> context.setPhase(StandardFrameworkConnectorMigrationContext.Phase.CONFIGURATION));
+ assertThrows(IllegalStateException.class,
+ () -> context.setPhase(StandardFrameworkConnectorMigrationContext.Phase.STATE));
+ assertThrows(IllegalStateException.class,
+ () -> context.setPhase(StandardFrameworkConnectorMigrationContext.Phase.COMPLETED));
+ }
+
+ @Test
+ public void testSetPhaseAllowsConfigurationToCompletedShortcutForFailureRollback() {
+ final StandardFrameworkConnectorMigrationContext context = createContext(true);
+ // Failure during the configuration phase moves the phase directly to COMPLETED without going through STATE.
+ context.setPhase(StandardFrameworkConnectorMigrationContext.Phase.COMPLETED);
+ assertEquals(StandardFrameworkConnectorMigrationContext.Phase.COMPLETED, context.getPhase());
+ }
+
+ @Test
+ public void testGetActiveFlowContextReturnsMigrationFlowContextWrapperWhenDelegatePresent() {
+ final FrameworkFlowContext delegate = mock(FrameworkFlowContext.class);
+ final StandardFrameworkConnectorMigrationContext context = new StandardFrameworkConnectorMigrationContext(
+ CONNECTOR_ID,
+ sourceFlow,
+ true,
+ delegate,
+ newWorkingConfiguration(),
+ sourceAssetManager,
+ connectorRepository,
+ stateManagerProvider,
+ clusterTopologyProvider);
+
+ final FrameworkFlowContext active = context.getActiveFlowContext();
+ assertNotNull(active);
+ assertTrue(active instanceof MigrationFlowContext,
+ "getActiveFlowContext() must return the read-only MigrationFlowContext wrapper");
+ }
+
+ private StandardFrameworkConnectorMigrationContext createContext(final boolean localMigration) {
+ return new StandardFrameworkConnectorMigrationContext(
+ CONNECTOR_ID,
+ sourceFlow,
+ localMigration,
+ null,
+ newWorkingConfiguration(),
+ sourceAssetManager,
+ connectorRepository,
+ stateManagerProvider,
+ clusterTopologyProvider);
+ }
+
+ private MutableConnectorConfigurationContext newWorkingConfiguration() {
+ return new StandardConnectorConfigurationContext(sourceAssetManager, mock(SecretsManager.class));
+ }
+
+ private File createAssetFile(final Path tempDir, final String contents) throws IOException {
+ final File file = tempDir.resolve("asset-" + contents + ".bin").toFile();
+ Files.writeString(file.toPath(), contents);
+ return file;
+ }
+
+ private Asset mockAsset(final String identifier, final String name, final File file) {
+ final Asset asset = mock(Asset.class);
+ when(asset.getIdentifier()).thenReturn(identifier);
+ when(asset.getName()).thenReturn(name);
+ when(asset.getFile()).thenReturn(file);
+ return asset;
+ }
+}
diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/audit/ConnectorAuditor.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/audit/ConnectorAuditor.java
index 9108e3a3b349..eae8d397509f 100644
--- a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/audit/ConnectorAuditor.java
+++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/audit/ConnectorAuditor.java
@@ -372,6 +372,29 @@ public void applyConnectorUpdateAdvice(final ProceedingJoinPoint proceedingJoinP
}
}
+ @Around("within(org.apache.nifi.web.dao.ConnectorDAO+) && "
+ + "execution(void migrateFromVersionedFlow(java.lang.String, java.lang.String, org.apache.nifi.flow.VersionedExternalFlow, java.util.function.BooleanSupplier)) && "
+ + "args(connectorId, processGroupId, sourceFlow, cancellationCheck) && "
+ + "target(connectorDAO)")
+ public void migrateConnectorAdvice(final ProceedingJoinPoint proceedingJoinPoint, final String connectorId, final String processGroupId,
+ final Object sourceFlow, final Object cancellationCheck, final ConnectorDAO connectorDAO) throws Throwable {
+ final ConnectorNode connector = connectorDAO.getConnector(connectorId);
+
+ proceedingJoinPoint.proceed();
+
+ if (isAuditable()) {
+ final FlowChangeConfigureDetails actionDetails = new FlowChangeConfigureDetails();
+ actionDetails.setName("Connector migrated from Versioned Process Group");
+ actionDetails.setPreviousValue(null);
+ actionDetails.setValue(processGroupId == null ? "Uploaded payload" : processGroupId);
+
+ final Action action = generateAuditRecord(connector, Operation.Configure, actionDetails);
+ if (action != null) {
+ saveAction(action, logger);
+ }
+ }
+ }
+
/**
* Generates an audit record for a connector.
*
diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiServiceFacade.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiServiceFacade.java
index 14b2e28ad957..e09900de82a9 100644
--- a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiServiceFacade.java
+++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiServiceFacade.java
@@ -167,6 +167,7 @@
import org.apache.nifi.web.api.entity.VersionControlComponentMappingEntity;
import org.apache.nifi.web.api.entity.VersionControlInformationEntity;
import org.apache.nifi.web.api.entity.VersionedFlowEntity;
+import org.apache.nifi.web.api.entity.VersionedFlowMigrationSourcesEntity;
import org.apache.nifi.web.api.entity.VersionedFlowSnapshotMetadataEntity;
import org.apache.nifi.web.api.entity.VersionedReportingTaskImportResponseEntity;
import org.apache.nifi.web.api.request.FlowMetricsRegistry;
@@ -181,6 +182,7 @@
import java.util.Map;
import java.util.Optional;
import java.util.Set;
+import java.util.function.BooleanSupplier;
import java.util.function.Function;
import java.util.function.Supplier;
@@ -311,6 +313,23 @@ Set getConnectorControllerServices(String connectorId,
Optional getConnectorAsset(String assetId);
+ VersionedFlowMigrationSourcesEntity getConnectorMigrationSources(String connectorId);
+
+ void verifyCanMigrateConnector(String connectorId, String processGroupId);
+
+ /**
+ * Verifies that the target Connector is itself ready to receive a migration, independent of any particular
+ * source. This asserts the Connector is stopped and that its active flow has not been modified from its initial
+ * flow, so that a migration request submitted for an uploaded-payload source reports an unready Connector
+ * immediately rather than only after the asynchronous migration task runs.
+ *
+ * @param connectorId the identifier of the target Connector
+ * @throws IllegalStateException if the Connector is not in a state that can receive a migration
+ */
+ void verifyConnectorReadyForMigration(String connectorId);
+
+ ConnectorEntity migrateConnector(String connectorId, String processGroupId, RegisteredFlowSnapshot flowSnapshot, BooleanSupplier cancellationCheck);
+
/**
* Verifies that the connector is in a state where FlowFiles can be purged.
*
@@ -1927,6 +1946,21 @@ VersionControlInformationEntity setVersionControlInformation(Revision processGro
*/
RegisteredFlowSnapshot getCurrentFlowSnapshotByGroupId(String processGroupId, boolean includeReferencedServices, boolean includeComponentState);
+ /**
+ * Get the current state of the Process Group with the given ID, converted to a Versioned Flow Snapshot for download.
+ * Optionally includes referenced controller services from parent groups, component state, and asset references.
+ * Asset references identify assets by their NiFi-internal identifiers, which are meaningful only on this instance;
+ * they should be mapped only for the Connector migration source capture and not for general process-group export.
+ *
+ * @param processGroupId the ID of the Process Group
+ * @param includeReferencedServices whether to include referenced controller services from parent groups
+ * @param includeComponentState whether to include component state in the export. When true, all processors must be stopped
+ * and all controller services must be disabled.
+ * @param mapAssetReferences whether to include asset references in the exported snapshot
+ * @return the current Process Group converted to a Versioned Flow Snapshot for download
+ */
+ RegisteredFlowSnapshot getCurrentFlowSnapshotByGroupId(String processGroupId, boolean includeReferencedServices, boolean includeComponentState, boolean mapAssetReferences);
+
/**
* Returns the name of the Flow Registry that is registered with the given ID. If no Flow Registry exists with the given ID, will return
* the ID itself as the name
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 be5f17459e77..4f4b10be8015 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
@@ -79,6 +79,7 @@
import org.apache.nifi.components.ValidationResult;
import org.apache.nifi.components.Validator;
import org.apache.nifi.components.connector.Connector;
+import org.apache.nifi.components.connector.ConnectorMigrationSource;
import org.apache.nifi.components.connector.ConnectorNode;
import org.apache.nifi.components.connector.ConnectorState;
import org.apache.nifi.components.connector.ConnectorSyncMode;
@@ -311,6 +312,7 @@
import org.apache.nifi.web.api.dto.UserGroupDTO;
import org.apache.nifi.web.api.dto.VersionControlInformationDTO;
import org.apache.nifi.web.api.dto.VersionedFlowDTO;
+import org.apache.nifi.web.api.dto.VersionedFlowMigrationSourceDTO;
import org.apache.nifi.web.api.dto.action.HistoryDTO;
import org.apache.nifi.web.api.dto.action.HistoryQueryDTO;
import org.apache.nifi.web.api.dto.diagnostics.ConnectionDiagnosticsDTO;
@@ -413,6 +415,7 @@
import org.apache.nifi.web.api.entity.VersionControlComponentMappingEntity;
import org.apache.nifi.web.api.entity.VersionControlInformationEntity;
import org.apache.nifi.web.api.entity.VersionedFlowEntity;
+import org.apache.nifi.web.api.entity.VersionedFlowMigrationSourcesEntity;
import org.apache.nifi.web.api.entity.VersionedFlowSnapshotMetadataEntity;
import org.apache.nifi.web.api.entity.VersionedReportingTaskImportResponseEntity;
import org.apache.nifi.web.api.request.FlowMetricsRegistry;
@@ -480,6 +483,7 @@
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
+import java.util.function.BooleanSupplier;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.Supplier;
@@ -4194,6 +4198,68 @@ public Optional getConnectorAsset(final String assetId) {
return connectorDAO.getAsset(assetId);
}
+ @Override
+ public VersionedFlowMigrationSourcesEntity getConnectorMigrationSources(final String connectorId) {
+ final List migrationSources = connectorDAO.getMigrationSources(connectorId).stream()
+ .map(this::createVersionedFlowMigrationSourceDto)
+ .toList();
+
+ final VersionedFlowMigrationSourcesEntity entity = new VersionedFlowMigrationSourcesEntity();
+ entity.setMigrationSources(migrationSources);
+ return entity;
+ }
+
+ @Override
+ public void verifyCanMigrateConnector(final String connectorId, final String processGroupId) {
+ Objects.requireNonNull(processGroupId, "Process Group identifier must be specified to verify a local-source migration");
+ connectorDAO.verifyCanMigrateFromVersionedFlow(connectorId, processGroupId);
+ }
+
+ @Override
+ public void verifyConnectorReadyForMigration(final String connectorId) {
+ connectorDAO.verifyConnectorReadyForMigration(connectorId);
+ }
+
+ @Override
+ public ConnectorEntity migrateConnector(final String connectorId, final String processGroupId, final RegisteredFlowSnapshot flowSnapshot,
+ final BooleanSupplier cancellationCheck) {
+ final NiFiUser user = NiFiUserUtils.getNiFiUser();
+ final Revision revision = revisionManager.getRevision(connectorId);
+ final RevisionClaim claim = new StandardRevisionClaim(revision);
+ final VersionedExternalFlow externalFlow = createVersionedExternalFlow(flowSnapshot);
+
+ final RevisionUpdate snapshot = revisionManager.updateRevision(claim, user, () -> {
+ connectorDAO.migrateFromVersionedFlow(connectorId, processGroupId, externalFlow, cancellationCheck);
+ controllerFacade.save();
+
+ final ConnectorNode connectorNode = connectorDAO.getConnector(connectorId, ConnectorSyncMode.LOCAL_ONLY);
+ final ConnectorDTO dto = dtoFactory.createConnectorDto(connectorNode);
+ final FlowModification lastMod = new FlowModification(revision.incrementRevision(revision.getClientId()), user.getIdentity());
+ return new StandardRevisionUpdate<>(dto, lastMod);
+ });
+
+ final ConnectorNode connectorNode = connectorDAO.getConnector(snapshot.getComponent().getId(), ConnectorSyncMode.LOCAL_ONLY);
+ final PermissionsDTO permissions = dtoFactory.createPermissionsDto(connectorNode);
+ final PermissionsDTO operatePermissions = dtoFactory.createPermissionsDto(new OperationAuthorizable(connectorNode));
+ final ConnectorStatusDTO statusDto = createConnectorStatusDto(connectorNode);
+ return entityFactory.createConnectorEntity(snapshot.getComponent(), dtoFactory.createRevisionDTO(snapshot.getLastModification()), permissions, operatePermissions, statusDto);
+ }
+
+ private VersionedFlowMigrationSourceDTO createVersionedFlowMigrationSourceDto(final ConnectorMigrationSource migrationSource) {
+ final VersionedFlowMigrationSourceDTO dto = new VersionedFlowMigrationSourceDTO();
+ dto.setProcessGroupId(migrationSource.getProcessGroupId());
+ dto.setProcessGroupName(migrationSource.getProcessGroupName());
+ dto.setParentProcessGroupId(migrationSource.getParentProcessGroupId());
+ dto.setRegistryClientId(migrationSource.getRegistryClientId());
+ dto.setBucketId(migrationSource.getBucketId());
+ dto.setFlowId(migrationSource.getFlowId());
+ dto.setFlowName(migrationSource.getFlowName());
+ dto.setVersion(migrationSource.getVersion());
+ dto.setReadyForMigration(migrationSource.isReadyForMigration());
+ dto.setIneligibilityReasons(migrationSource.getIneligibilityReasons());
+ return dto;
+ }
+
@Override
public void verifyPurgeConnectorFlowFiles(final String connectorId) {
connectorDAO.verifyPurgeFlowFiles(connectorId);
@@ -6254,6 +6320,12 @@ public RegisteredFlowSnapshot getCurrentFlowSnapshotByGroupIdWithReferencedContr
@Override
public RegisteredFlowSnapshot getCurrentFlowSnapshotByGroupId(final String processGroupId, final boolean includeReferencedServices, final boolean includeComponentState) {
+ return getCurrentFlowSnapshotByGroupId(processGroupId, includeReferencedServices, includeComponentState, false);
+ }
+
+ @Override
+ public RegisteredFlowSnapshot getCurrentFlowSnapshotByGroupId(final String processGroupId, final boolean includeReferencedServices, final boolean includeComponentState,
+ final boolean mapAssetReferences) {
if (!includeComponentState) {
return getCurrentFlowSnapshotByGroupId(processGroupId, includeReferencedServices);
}
@@ -6288,7 +6360,7 @@ public RegisteredFlowSnapshot getCurrentFlowSnapshotByGroupId(final String proce
.mapInstanceIdentifiers(false)
.mapControllerServiceReferencesToVersionedId(true)
.mapFlowRegistryClientId(false)
- .mapAssetReferences(false)
+ .mapAssetReferences(mapAssetReferences)
.mapComponentState(includeComponentState)
.stateManagerProvider(stateManagerProvider)
.localNodeOrdinal(clusterTopologyProvider.getLocalNodeOrdinal())
diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ConnectorResource.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ConnectorResource.java
index dd8cd557597d..c9ad83d7e35c 100644
--- a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ConnectorResource.java
+++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ConnectorResource.java
@@ -16,6 +16,7 @@
*/
package org.apache.nifi.web.api;
+import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
@@ -23,6 +24,8 @@
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
import jakarta.servlet.ServletContext;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
@@ -59,6 +62,7 @@
import org.apache.nifi.cluster.protocol.NodeIdentifier;
import org.apache.nifi.controller.ScheduledState;
import org.apache.nifi.processor.DataUnit;
+import org.apache.nifi.registry.flow.RegisteredFlowSnapshot;
import org.apache.nifi.stream.io.MaxLengthInputStream;
import org.apache.nifi.ui.extension.UiExtension;
import org.apache.nifi.ui.extension.UiExtensionMapping;
@@ -80,6 +84,10 @@
import org.apache.nifi.web.api.dto.ConfigurationStepConfigurationDTO;
import org.apache.nifi.web.api.dto.ConnectorDTO;
import org.apache.nifi.web.api.dto.DropRequestDTO;
+import org.apache.nifi.web.api.dto.MigrationPayloadDTO;
+import org.apache.nifi.web.api.dto.MigrationRequestDTO;
+import org.apache.nifi.web.api.dto.MigrationRequestLocalSourceDTO;
+import org.apache.nifi.web.api.dto.MigrationUpdateStepDTO;
import org.apache.nifi.web.api.dto.VerifyConnectorConfigStepRequestDTO;
import org.apache.nifi.web.api.dto.search.SearchResultsDTO;
import org.apache.nifi.web.api.entity.AssetEntity;
@@ -93,6 +101,8 @@
import org.apache.nifi.web.api.entity.ControllerServiceEntity;
import org.apache.nifi.web.api.entity.ControllerServicesEntity;
import org.apache.nifi.web.api.entity.DropRequestEntity;
+import org.apache.nifi.web.api.entity.MigrationPayloadEntity;
+import org.apache.nifi.web.api.entity.MigrationRequestEntity;
import org.apache.nifi.web.api.entity.ParameterContextEntity;
import org.apache.nifi.web.api.entity.ProcessGroupFlowEntity;
import org.apache.nifi.web.api.entity.ProcessGroupStatusEntity;
@@ -100,6 +110,7 @@
import org.apache.nifi.web.api.entity.SecretsEntity;
import org.apache.nifi.web.api.entity.StatusHistoryEntity;
import org.apache.nifi.web.api.entity.VerifyConnectorConfigStepRequestEntity;
+import org.apache.nifi.web.api.entity.VersionedFlowMigrationSourcesEntity;
import org.apache.nifi.web.api.request.ClientIdParameter;
import org.apache.nifi.web.api.request.LongParameter;
import org.apache.nifi.web.client.api.HttpResponseStatus;
@@ -118,6 +129,10 @@
import java.util.Map;
import java.util.Set;
import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
/**
@@ -129,12 +144,21 @@
public class ConnectorResource extends ApplicationResource {
private static final Logger logger = LoggerFactory.getLogger(ConnectorResource.class);
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private static final String VERIFICATION_REQUEST_TYPE = "verification-request";
+ private static final String MIGRATION_REQUEST_TYPE = "migration-request";
private static final String PURGE_REQUEST_TYPE = "purge-request";
private static final String FILENAME_HEADER = "Filename";
private static final String CONTENT_TYPE_HEADER = "Content-Type";
private static final String UPLOAD_CONTENT_TYPE = "application/octet-stream";
private static final long MAX_ASSET_SIZE_BYTES = (long) DataUnit.GB.toB(1);
+ // Exported flow snapshots are typically a few megabytes; cap uploads well below the 1 GB asset limit because the
+ // deserialized payload is held in memory for the payload TTL and a malformed or hostile upload should not be able
+ // to exhaust heap.
+ private static final long MAX_MIGRATION_PAYLOAD_SIZE_BYTES = (long) DataUnit.MB.toB(100);
+ private static final long MIGRATION_REQUEST_TTL_MILLIS = TimeUnit.MINUTES.toMillis(1L);
+ private static final long MIGRATION_PAYLOAD_TTL_MILLIS = TimeUnit.MINUTES.toMillis(10L);
+ private static final long MIGRATION_PAYLOAD_SWEEP_INTERVAL_SECONDS = 30L;
private NiFiServiceFacade serviceFacade;
private Authorizer authorizer;
@@ -144,9 +168,39 @@ public class ConnectorResource extends ApplicationResource {
private final RequestManager> configVerificationRequestManager =
new AsyncRequestManager<>(100, 1L, "Connector Configuration Step Verification");
+ private final RequestManager migrationRequestManager =
+ new AsyncRequestManager<>(100, MIGRATION_REQUEST_TTL_MILLIS, "Connector Migration");
private final RequestManager purgeRequestManager = new AsyncRequestManager<>(100, 1L, "Connector FlowFile Purge");
+ /**
+ * Uploaded migration payloads keyed by their server-assigned identifier. Each entry's age is tracked alongside
+ * the payload so that an upload that is never associated with a started migration request is evicted after
+ * {@link #MIGRATION_PAYLOAD_TTL_MILLIS}. A scheduled sweeper purges expired entries on a fixed cadence so that
+ * single-upload sessions do not leak payload state indefinitely.
+ */
+ private final Map migrationPayloadsById = new ConcurrentHashMap<>();
+
+ private final ScheduledExecutorService migrationPayloadEvictionExecutor = Executors.newSingleThreadScheduledExecutor(runnable -> {
+ final Thread thread = new Thread(runnable, "Connector Migration Payload Eviction");
+ thread.setDaemon(true);
+ return thread;
+ });
+
+ private record MigrationPayloadEntry(RegisteredFlowSnapshot snapshot, long uploadedAtMillis) {
+ }
+
+ @PostConstruct
+ public void startMigrationPayloadEviction() {
+ migrationPayloadEvictionExecutor.scheduleWithFixedDelay(this::evictExpiredMigrationPayloads,
+ MIGRATION_PAYLOAD_SWEEP_INTERVAL_SECONDS, MIGRATION_PAYLOAD_SWEEP_INTERVAL_SECONDS, TimeUnit.SECONDS);
+ }
+
+ @PreDestroy
+ public void stopMigrationPayloadEviction() {
+ migrationPayloadEvictionExecutor.shutdownNow();
+ }
+
@Context
private ServletContext servletContext;
@@ -2353,6 +2407,351 @@ public Response getConnectorRemoteProcessGroupStatusHistory(
return generateOkResponse(entity).build();
}
+ @GET
+ @Consumes(MediaType.WILDCARD)
+ @Produces(MediaType.APPLICATION_JSON)
+ @Path("{id}/migration-sources")
+ @Operation(
+ summary = "Lists the Versioned Process Groups that the Connector can be migrated from",
+ responses = {
+ @ApiResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = VersionedFlowMigrationSourcesEntity.class))),
+ @ApiResponse(responseCode = "400", description = "NiFi was unable to complete the request because it was invalid. The request should not be retried without modification."),
+ @ApiResponse(responseCode = "401", description = "Client could not be authenticated."),
+ @ApiResponse(responseCode = "403", description = "Client is not authorized to make this request."),
+ @ApiResponse(responseCode = "404", description = "The specified resource could not be found."),
+ @ApiResponse(responseCode = "409", description = "The request was valid but NiFi was not in the appropriate state to process it.")
+ },
+ security = {
+ @SecurityRequirement(name = "Read - /connectors/{uuid}")
+ }
+ )
+ public Response getMigrationSources(@PathParam("id") final String connectorId) {
+ authorizeReadConnector(connectorId);
+
+ if (isReplicateRequest()) {
+ return replicate(HttpMethod.GET);
+ }
+
+ final VersionedFlowMigrationSourcesEntity entity = serviceFacade.getConnectorMigrationSources(connectorId);
+ return generateOkResponse(entity).build();
+ }
+
+ @POST
+ @Consumes(MediaType.APPLICATION_OCTET_STREAM)
+ @Produces(MediaType.APPLICATION_JSON)
+ @Path("{id}/migration-payloads")
+ @Operation(
+ summary = "Uploads a flow snapshot payload for a later Connector migration request",
+ responses = {
+ @ApiResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = MigrationPayloadEntity.class))),
+ @ApiResponse(responseCode = "400", description = "NiFi was unable to complete the request because it was invalid. The request should not be retried without modification."),
+ @ApiResponse(responseCode = "401", description = "Client could not be authenticated."),
+ @ApiResponse(responseCode = "403", description = "Client is not authorized to make this request."),
+ @ApiResponse(responseCode = "404", description = "The specified resource could not be found."),
+ @ApiResponse(responseCode = "409", description = "The request was valid but NiFi was not in the appropriate state to process it.")
+ },
+ security = {
+ @SecurityRequirement(name = "Write - /connectors/{uuid}")
+ }
+ )
+ public Response createMigrationPayload(
+ @PathParam("id") final String connectorId,
+ @Parameter(description = "The migration payload snapshot", required = true) final InputStream payloadContents) throws IOException {
+ if (payloadContents == null) {
+ throw new IllegalArgumentException("Migration payload contents must be specified.");
+ }
+
+ final NiFiUser currentUser = NiFiUserUtils.getNiFiUser();
+ serviceFacade.authorizeAccess(lookup -> {
+ final Authorizable connector = lookup.getConnector(connectorId);
+ connector.authorize(authorizer, RequestAction.WRITE, currentUser);
+ });
+
+ if (isReplicateRequest()) {
+ final String uploadRequestId = UUID.randomUUID().toString();
+ final UploadRequest uploadRequest = new UploadRequest.Builder()
+ .user(currentUser)
+ .filename("migration-payload.json")
+ .identifier(uploadRequestId)
+ .contents(payloadContents)
+ .forwardRequestHeaders(getHeaders())
+ .header(CONTENT_TYPE_HEADER, UPLOAD_CONTENT_TYPE)
+ .header(RequestReplicationHeader.CLUSTER_ID_GENERATION_SEED.getHeader(), uploadRequestId)
+ .exampleRequestUri(getAbsolutePath())
+ .responseClass(MigrationPayloadEntity.class)
+ .successfulResponseStatus(HttpResponseStatus.OK.getCode())
+ .build();
+ final MigrationPayloadEntity entity = uploadRequestReplicator.upload(uploadRequest);
+ return generateOkResponse(entity).build();
+ }
+
+ // Cap the upload so a malformed or hostile payload cannot exhaust heap; the deserialized snapshot is pinned
+ // in memory for the payload TTL. MaxLengthInputStream throws once the limit is exceeded during deserialization.
+ final RegisteredFlowSnapshot flowSnapshot;
+ try {
+ flowSnapshot = OBJECT_MAPPER.readValue(new MaxLengthInputStream(payloadContents, MAX_MIGRATION_PAYLOAD_SIZE_BYTES), RegisteredFlowSnapshot.class);
+ } catch (final IOException e) {
+ throw new IllegalArgumentException("Deserialization of uploaded migration payload failed", e);
+ }
+
+ // Bundle discovery is deferred to the asynchronous migration task in performAsyncMigration so that
+ // the upload thread does not block on extension-manager work.
+ final String payloadId = getIdGenerationSeed().orElseGet(this::generateUuid);
+ migrationPayloadsById.put(payloadId, new MigrationPayloadEntry(flowSnapshot, System.currentTimeMillis()));
+ final MigrationPayloadDTO migrationPayload = new MigrationPayloadDTO();
+ migrationPayload.setPayloadId(payloadId);
+
+ final MigrationPayloadEntity entity = new MigrationPayloadEntity();
+ entity.setPayload(migrationPayload);
+ return generateOkResponse(entity).build();
+ }
+
+ @POST
+ @Consumes(MediaType.APPLICATION_JSON)
+ @Produces(MediaType.APPLICATION_JSON)
+ @Path("/{id}/migration-requests")
+ @Operation(
+ summary = "Creates a Connector migration request",
+ responses = {
+ @ApiResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = MigrationRequestEntity.class))),
+ @ApiResponse(responseCode = "400", description = "NiFi was unable to complete the request because it was invalid. The request should not be retried without modification."),
+ @ApiResponse(responseCode = "401", description = "Client could not be authenticated."),
+ @ApiResponse(responseCode = "403", description = "Client is not authorized to make this request."),
+ @ApiResponse(responseCode = "404", description = "The specified resource could not be found."),
+ @ApiResponse(responseCode = "409", description = "The request was valid but NiFi was not in the appropriate state to process it.")
+ },
+ security = {
+ @SecurityRequirement(name = "Write - /connectors/{uuid}")
+ }
+ )
+ public Response createMigrationRequest(@PathParam("id") final String connectorId, final MigrationRequestEntity requestEntity) {
+ if (requestEntity == null || requestEntity.getRequest() == null) {
+ throw new IllegalArgumentException("Migration request must be specified.");
+ }
+
+ final MigrationRequestDTO request = requestEntity.getRequest();
+ if (!connectorId.equals(request.getConnectorId())) {
+ throw new IllegalArgumentException("Connector identifier in the request must match the identifier provided in the URL.");
+ }
+
+ final boolean hasLocalSource = request.getLocalSource() != null && StringUtils.isNotBlank(request.getLocalSource().getProcessGroupId());
+ final boolean hasPayload = StringUtils.isNotBlank(request.getPayloadId());
+ if (hasLocalSource == hasPayload) {
+ throw new IllegalArgumentException("Migration request must specify exactly one source: either a local Process Group or an uploaded payload identifier.");
+ }
+
+ if (hasPayload && !migrationPayloadsById.containsKey(request.getPayloadId())) {
+ throw new ResourceNotFoundException("No uploaded migration payload exists with identifier " + request.getPayloadId());
+ }
+
+ // Reject the request when not all cluster nodes are connected, mirroring the asset-upload guard,
+ // because component state and assets cannot be synchronized to disconnected nodes after migration.
+ final ClusterCoordinator clusterCoordinator = getClusterCoordinator();
+ if (clusterCoordinator != null) {
+ final Set disconnectedNodes = clusterCoordinator.getNodeIdentifiers(NodeConnectionState.CONNECTING, NodeConnectionState.DISCONNECTED, NodeConnectionState.DISCONNECTING);
+ if (!disconnectedNodes.isEmpty()) {
+ throw new IllegalStateException("Cannot start a Connector migration because the following %s nodes are not currently connected: %s"
+ .formatted(disconnectedNodes.size(), disconnectedNodes));
+ }
+ }
+
+ if (isReplicateRequest()) {
+ return replicate(HttpMethod.POST, requestEntity);
+ }
+
+ final NiFiUser user = NiFiUserUtils.getNiFiUser();
+ return withWriteLock(
+ serviceFacade,
+ requestEntity,
+ lookup -> {
+ final Authorizable connector = lookup.getConnector(connectorId);
+ connector.authorize(authorizer, RequestAction.WRITE, user);
+ },
+ () -> {
+ // Verify the target Connector is ready (stopped and unmodified from its initial flow) for every
+ // source type, so an unready Connector is reported on submission rather than only after the
+ // asynchronous migration task runs. The uploaded-payload path has no source Process Group to
+ // verify, so this is the only synchronous readiness check it receives.
+ serviceFacade.verifyConnectorReadyForMigration(connectorId);
+ if (hasLocalSource) {
+ serviceFacade.verifyCanMigrateConnector(connectorId, request.getLocalSource().getProcessGroupId());
+ }
+ },
+ entity -> performAsyncMigration(entity, user)
+ );
+ }
+
+ @GET
+ @Consumes(MediaType.WILDCARD)
+ @Produces(MediaType.APPLICATION_JSON)
+ @Path("/{id}/migration-requests/{requestId}")
+ @Operation(
+ summary = "Gets the Connector migration request with the given ID",
+ responses = {
+ @ApiResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = MigrationRequestEntity.class))),
+ @ApiResponse(responseCode = "400", description = "NiFi was unable to complete the request because it was invalid. The request should not be retried without modification."),
+ @ApiResponse(responseCode = "401", description = "Client could not be authenticated."),
+ @ApiResponse(responseCode = "403", description = "Client is not authorized to make this request."),
+ @ApiResponse(responseCode = "404", description = "The specified resource could not be found."),
+ @ApiResponse(responseCode = "409", description = "The request was valid but NiFi was not in the appropriate state to process it.")
+ }
+ )
+ public Response getMigrationRequest(@PathParam("id") final String connectorId, @PathParam("requestId") final String requestId) {
+ if (isReplicateRequest()) {
+ return replicate(HttpMethod.GET);
+ }
+
+ final NiFiUser user = NiFiUserUtils.getNiFiUser();
+ final AsynchronousWebRequest asyncRequest = migrationRequestManager.getRequest(MIGRATION_REQUEST_TYPE, requestId, user);
+ return generateOkResponse(createMigrationRequestEntity(asyncRequest, connectorId, requestId)).build();
+ }
+
+ @DELETE
+ @Consumes(MediaType.WILDCARD)
+ @Produces(MediaType.APPLICATION_JSON)
+ @Path("/{id}/migration-requests/{requestId}")
+ @Operation(
+ summary = "Deletes the Connector migration request with the given ID",
+ responses = {
+ @ApiResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = MigrationRequestEntity.class))),
+ @ApiResponse(responseCode = "400", description = "NiFi was unable to complete the request because it was invalid. The request should not be retried without modification."),
+ @ApiResponse(responseCode = "401", description = "Client could not be authenticated."),
+ @ApiResponse(responseCode = "403", description = "Client is not authorized to make this request."),
+ @ApiResponse(responseCode = "404", description = "The specified resource could not be found."),
+ @ApiResponse(responseCode = "409", description = "The request was valid but NiFi was not in the appropriate state to process it.")
+ }
+ )
+ public Response deleteMigrationRequest(@PathParam("id") final String connectorId, @PathParam("requestId") final String requestId) {
+ if (isReplicateRequest()) {
+ return replicate(HttpMethod.DELETE);
+ }
+
+ final NiFiUser user = NiFiUserUtils.getNiFiUser();
+ final boolean twoPhaseRequest = isTwoPhaseRequest(httpServletRequest);
+ final boolean executionPhase = isExecutionPhase(httpServletRequest);
+
+ if (!twoPhaseRequest || executionPhase) {
+ final AsynchronousWebRequest asyncRequest =
+ migrationRequestManager.removeRequest(MIGRATION_REQUEST_TYPE, requestId, user);
+ if (!asyncRequest.isComplete()) {
+ asyncRequest.cancel();
+ }
+
+ final MigrationRequestDTO request = asyncRequest.getRequest().getRequest();
+ if (StringUtils.isNotBlank(request.getPayloadId())) {
+ migrationPayloadsById.remove(request.getPayloadId());
+ }
+
+ return generateOkResponse(createMigrationRequestEntity(asyncRequest, connectorId, requestId)).build();
+ }
+
+ if (isValidationPhase(httpServletRequest)) {
+ migrationRequestManager.getRequest(MIGRATION_REQUEST_TYPE, requestId, user);
+ return generateContinueResponse().build();
+ } else if (isCancellationPhase(httpServletRequest)) {
+ return generateOkResponse().build();
+ } else {
+ throw new IllegalStateException("This request does not appear to be part of the two phase commit.");
+ }
+ }
+
+ private Response performAsyncMigration(final MigrationRequestEntity requestEntity, final NiFiUser user) {
+ final String requestId = generateUuid();
+ final MigrationRequestDTO migrationRequest = requestEntity.getRequest();
+ final List updateSteps = Collections.singletonList(new StandardUpdateStep("Migrate Versioned Flow"));
+
+ final AsynchronousWebRequest request =
+ new StandardAsynchronousWebRequest<>(requestId, requestEntity, migrationRequest.getConnectorId(), user, updateSteps);
+
+ final Consumer> updateTask = asyncRequest -> {
+ final String payloadId = migrationRequest.getPayloadId();
+ try {
+ final RegisteredFlowSnapshot flowSnapshot = getMigrationSnapshot(migrationRequest);
+ final String processGroupId = migrationRequest.getLocalSource() == null ? null : migrationRequest.getLocalSource().getProcessGroupId();
+ final ConnectorEntity migratedConnector = serviceFacade.migrateConnector(migrationRequest.getConnectorId(), processGroupId, flowSnapshot, asyncRequest::isCancelled);
+ asyncRequest.markStepComplete(migratedConnector);
+ } catch (final Exception e) {
+ logger.error("Failed to migrate Connector {}", migrationRequest.getConnectorId(), e);
+ asyncRequest.fail("Failed to migrate Connector due to " + e);
+ } finally {
+ if (StringUtils.isNotBlank(payloadId)) {
+ migrationPayloadsById.remove(payloadId);
+ }
+ }
+ };
+
+ request.setCancelCallback(() -> {
+ final String payloadId = migrationRequest.getPayloadId();
+ if (StringUtils.isNotBlank(payloadId)) {
+ migrationPayloadsById.remove(payloadId);
+ }
+ });
+
+ migrationRequestManager.submitRequest(MIGRATION_REQUEST_TYPE, requestId, request, updateTask);
+ final MigrationRequestEntity resultsEntity = createMigrationRequestEntity(request, migrationRequest.getConnectorId(), requestId);
+ return generateOkResponse(resultsEntity).build();
+ }
+
+ private RegisteredFlowSnapshot getMigrationSnapshot(final MigrationRequestDTO migrationRequest) {
+ final MigrationRequestLocalSourceDTO localSource = migrationRequest.getLocalSource();
+ if (localSource != null && StringUtils.isNotBlank(localSource.getProcessGroupId())) {
+ // The local-source path uses the framework's snapshot facility, which already returns a snapshot
+ // whose bundles match this NiFi instance. discoverCompatibleBundles is intentionally not invoked
+ // here because it is reserved for snapshots that originate from external sources (uploaded payloads).
+ // Asset references are mapped because the migration must carry them so the target Connector can copy
+ // the referenced assets; this is the only caller of this path that maps asset references.
+ return serviceFacade.getCurrentFlowSnapshotByGroupId(localSource.getProcessGroupId(), true, true, true);
+ }
+
+ final String payloadId = migrationRequest.getPayloadId();
+ final MigrationPayloadEntry entry = migrationPayloadsById.get(payloadId);
+ if (entry == null) {
+ throw new ResourceNotFoundException("No uploaded migration payload exists with identifier " + payloadId);
+ }
+
+ // Bundle discovery is performed once when the migration task picks up the uploaded payload, not on
+ // the upload thread, so that a slow extension manager does not block the upload-replication path.
+ final RegisteredFlowSnapshot flowSnapshot = entry.snapshot();
+ serviceFacade.discoverCompatibleBundles(flowSnapshot.getFlowContents());
+ serviceFacade.discoverCompatibleBundles(flowSnapshot.getParameterProviders());
+ return flowSnapshot;
+ }
+
+ private void evictExpiredMigrationPayloads() {
+ final long cutoffMillis = System.currentTimeMillis() - MIGRATION_PAYLOAD_TTL_MILLIS;
+ migrationPayloadsById.entrySet().removeIf(entry -> entry.getValue().uploadedAtMillis() < cutoffMillis);
+ }
+
+ private MigrationRequestEntity createMigrationRequestEntity(
+ final AsynchronousWebRequest asyncRequest,
+ final String connectorId,
+ final String requestId) {
+ final MigrationRequestDTO requestedMigration = asyncRequest.getRequest().getRequest();
+
+ final MigrationRequestDTO migrationRequest = new MigrationRequestDTO();
+ migrationRequest.setConnectorId(requestedMigration.getConnectorId());
+ migrationRequest.setLocalSource(requestedMigration.getLocalSource());
+ migrationRequest.setPayloadId(requestedMigration.getPayloadId());
+ migrationRequest.setComplete(asyncRequest.isComplete());
+ migrationRequest.setFailureReason(asyncRequest.getFailureReason());
+ migrationRequest.setLastUpdated(asyncRequest.getLastUpdated());
+ migrationRequest.setPercentCompleted(asyncRequest.getPercentComplete());
+ migrationRequest.setRequestId(requestId);
+ migrationRequest.setState(asyncRequest.getState());
+ migrationRequest.setUri(generateResourceUri("connectors", connectorId, "migration-requests", requestId));
+ migrationRequest.setUpdateSteps(asyncRequest.getUpdateSteps().stream().map(updateStep -> {
+ final MigrationUpdateStepDTO migrationUpdateStep = new MigrationUpdateStepDTO();
+ migrationUpdateStep.setDescription(updateStep.getDescription());
+ migrationUpdateStep.setComplete(updateStep.isComplete());
+ migrationUpdateStep.setFailureReason(updateStep.getFailureReason());
+ return migrationUpdateStep;
+ }).toList());
+
+ final MigrationRequestEntity entity = new MigrationRequestEntity();
+ entity.setRequest(migrationRequest);
+ return entity;
+ }
+
@POST
@Consumes(MediaType.APPLICATION_OCTET_STREAM)
@Produces(MediaType.APPLICATION_JSON)
diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/dao/ConnectorDAO.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/dao/ConnectorDAO.java
index 34add0677862..f32dde5126b8 100644
--- a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/dao/ConnectorDAO.java
+++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/dao/ConnectorDAO.java
@@ -20,9 +20,11 @@
import org.apache.nifi.bundle.BundleCoordinate;
import org.apache.nifi.components.ConfigVerificationResult;
import org.apache.nifi.components.DescribedValue;
+import org.apache.nifi.components.connector.ConnectorMigrationSource;
import org.apache.nifi.components.connector.ConnectorNode;
import org.apache.nifi.components.connector.ConnectorSyncMode;
import org.apache.nifi.components.connector.ConnectorUpdateContext;
+import org.apache.nifi.flow.VersionedExternalFlow;
import org.apache.nifi.web.api.dto.ConfigurationStepConfigurationDTO;
import org.apache.nifi.web.api.dto.ConnectorDTO;
@@ -30,6 +32,7 @@
import java.io.InputStream;
import java.util.List;
import java.util.Optional;
+import java.util.function.BooleanSupplier;
public interface ConnectorDAO {
@@ -93,5 +96,13 @@ public interface ConnectorDAO {
Optional getAsset(String assetId);
+ List getMigrationSources(String id);
+
+ void verifyCanMigrateFromVersionedFlow(String connectorId, String processGroupId);
+
+ void verifyConnectorReadyForMigration(String connectorId);
+
+ void migrateFromVersionedFlow(String connectorId, String processGroupId, VersionedExternalFlow sourceFlow, BooleanSupplier cancellationCheck);
+
}
diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/dao/impl/StandardConnectorDAO.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/dao/impl/StandardConnectorDAO.java
index 9dabadd1235f..542c4c316cca 100644
--- a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/dao/impl/StandardConnectorDAO.java
+++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/dao/impl/StandardConnectorDAO.java
@@ -16,11 +16,15 @@
*/
package org.apache.nifi.web.dao.impl;
+import jakarta.annotation.PostConstruct;
import org.apache.nifi.asset.Asset;
import org.apache.nifi.bundle.BundleCoordinate;
import org.apache.nifi.components.ConfigVerificationResult;
import org.apache.nifi.components.DescribedValue;
import org.apache.nifi.components.connector.AssetReference;
+import org.apache.nifi.components.connector.ConnectorFlowSnapshotProvider;
+import org.apache.nifi.components.connector.ConnectorMigrationManager;
+import org.apache.nifi.components.connector.ConnectorMigrationSource;
import org.apache.nifi.components.connector.ConnectorNode;
import org.apache.nifi.components.connector.ConnectorRepository;
import org.apache.nifi.components.connector.ConnectorSyncMode;
@@ -29,11 +33,14 @@
import org.apache.nifi.components.connector.ConnectorValueType;
import org.apache.nifi.components.connector.FlowUpdateException;
import org.apache.nifi.components.connector.SecretReference;
+import org.apache.nifi.components.connector.StandardConnectorMigrationManager;
import org.apache.nifi.components.connector.StepConfiguration;
import org.apache.nifi.components.connector.StringLiteralValue;
import org.apache.nifi.controller.FlowController;
import org.apache.nifi.controller.flow.FlowManager;
+import org.apache.nifi.flow.VersionedExternalFlow;
import org.apache.nifi.web.NiFiCoreException;
+import org.apache.nifi.web.NiFiServiceFacade;
import org.apache.nifi.web.ResourceNotFoundException;
import org.apache.nifi.web.api.dto.AssetReferenceDTO;
import org.apache.nifi.web.api.dto.ConfigurationStepConfigurationDTO;
@@ -44,6 +51,7 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Repository;
import java.io.IOException;
@@ -55,6 +63,7 @@
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ExecutionException;
+import java.util.function.BooleanSupplier;
import java.util.stream.Collectors;
@Repository
@@ -63,12 +72,31 @@ public class StandardConnectorDAO implements ConnectorDAO {
private static final Logger logger = LoggerFactory.getLogger(StandardConnectorDAO.class);
private FlowController flowController;
+ private NiFiServiceFacade serviceFacade;
+ private ConnectorMigrationManager connectorMigrationManager;
@Autowired
public void setFlowController(final FlowController flowController) {
this.flowController = flowController;
}
+ /**
+ * Injected with {@link Lazy @Lazy} to break the circular dependency between this DAO and
+ * {@link NiFiServiceFacade}. The service facade is used solely as a {@link ConnectorFlowSnapshotProvider}
+ * for the migration manager constructed in {@link #initialize()}; the proxy is invoked lazily at
+ * migration time, so the real service-facade bean is not required at DAO construction.
+ */
+ @Autowired
+ public void setServiceFacade(@Lazy final NiFiServiceFacade serviceFacade) {
+ this.serviceFacade = serviceFacade;
+ }
+
+ @PostConstruct
+ public void initialize() {
+ final ConnectorFlowSnapshotProvider snapshotProvider = serviceFacade::getCurrentFlowSnapshotByGroupId;
+ this.connectorMigrationManager = new StandardConnectorMigrationManager(flowController, snapshotProvider);
+ }
+
private FlowManager getFlowManager() {
return flowController.getFlowManager();
}
@@ -329,6 +357,35 @@ public List getAssets(final String id) {
public Optional getAsset(final String assetId) {
return getConnectorRepository().getAsset(assetId);
}
+
+ @Override
+ public List getMigrationSources(final String id) {
+ requireConnector(id, ConnectorSyncMode.LOCAL_ONLY);
+ return connectorMigrationManager.listMigrationSources(id);
+ }
+
+ @Override
+ public void verifyCanMigrateFromVersionedFlow(final String connectorId, final String processGroupId) {
+ requireConnector(connectorId, ConnectorSyncMode.LOCAL_ONLY);
+ connectorMigrationManager.verifyEligibility(connectorId, processGroupId);
+ }
+
+ @Override
+ public void verifyConnectorReadyForMigration(final String connectorId) {
+ requireConnector(connectorId, ConnectorSyncMode.LOCAL_ONLY);
+ connectorMigrationManager.verifyConnectorReadyForMigration(connectorId);
+ }
+
+ @Override
+ public void migrateFromVersionedFlow(final String connectorId, final String processGroupId, final VersionedExternalFlow sourceFlow,
+ final BooleanSupplier cancellationCheck) {
+ requireConnector(connectorId, ConnectorSyncMode.LOCAL_ONLY);
+ try {
+ connectorMigrationManager.migrateFromVersionedFlow(connectorId, processGroupId, sourceFlow, cancellationCheck);
+ } catch (final Exception e) {
+ throw new NiFiCoreException("Failed to migrate Connector from Versioned Flow: " + e.getMessage(), e);
+ }
+ }
}
diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/StandardNiFiServiceFacadeTest.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/StandardNiFiServiceFacadeTest.java
index 4cfce89af711..a80f8a411d0b 100644
--- a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/StandardNiFiServiceFacadeTest.java
+++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/StandardNiFiServiceFacadeTest.java
@@ -43,7 +43,9 @@
import org.apache.nifi.components.connector.Secret;
import org.apache.nifi.components.connector.secrets.AuthorizableSecret;
import org.apache.nifi.components.state.Scope;
+import org.apache.nifi.components.state.StateManagerProvider;
import org.apache.nifi.components.state.StateMap;
+import org.apache.nifi.controller.ClusterTopologyProvider;
import org.apache.nifi.controller.ControllerService;
import org.apache.nifi.controller.Counter;
import org.apache.nifi.controller.FlowController;
@@ -718,6 +720,39 @@ public void testGetCurrentFlowSnapshotByGroupId() {
assertNull(versionedFlowSnapshot.getSnapshotMetadata());
}
+ @Test
+ public void testGetCurrentFlowSnapshotMapsAssetReferencesOnlyWhenRequested() {
+ // Asset references are NiFi-internal identifiers that are meaningful only on this instance, so the general
+ // export path must leave them out while the Connector migration source-capture path must include them.
+ assertTrue(captureComponentStateSnapshotMappingOptions(true).isMapAssetReferences());
+ assertFalse(captureComponentStateSnapshotMappingOptions(false).isMapAssetReferences());
+ }
+
+ private FlowMappingOptions captureComponentStateSnapshotMappingOptions(final boolean mapAssetReferences) {
+ final String groupId = UUID.randomUUID().toString();
+ final ProcessGroup processGroup = mock(ProcessGroup.class);
+ when(processGroupDAO.getProcessGroup(groupId)).thenReturn(processGroup);
+
+ final ExtensionManager extensionManager = mock(ExtensionManager.class);
+ when(flowController.getExtensionManager()).thenReturn(extensionManager);
+
+ final ClusterTopologyProvider clusterTopologyProvider = mock(ClusterTopologyProvider.class);
+ serviceFacade.setClusterTopologyProvider(clusterTopologyProvider);
+ serviceFacade.setStateManagerProvider(mock(StateManagerProvider.class));
+
+ final StandardNiFiServiceFacade serviceFacadeSpy = spy(serviceFacade);
+ final VersionedComponentFlowMapper flowMapper = mock(VersionedComponentFlowMapper.class);
+ final ArgumentCaptor optionsCaptor = ArgumentCaptor.forClass(FlowMappingOptions.class);
+ doReturn(flowMapper).when(serviceFacadeSpy).makeNiFiRegistryFlowMapper(eq(extensionManager), optionsCaptor.capture());
+
+ final InstantiatedVersionedProcessGroup nonVersionedProcessGroup = mock(InstantiatedVersionedProcessGroup.class);
+ when(flowMapper.mapNonVersionedProcessGroup(eq(processGroup), any())).thenReturn(nonVersionedProcessGroup);
+
+ serviceFacadeSpy.getCurrentFlowSnapshotByGroupId(groupId, false, true, mapAssetReferences);
+
+ return optionsCaptor.getValue();
+ }
+
@Test
public void testGetCurrentFlowSnapshotByGroupIdWithReferencedControllerServices() {
final String groupId = UUID.randomUUID().toString();
diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/TestConnectorResource.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/TestConnectorResource.java
index 2fcac1d5dcdc..fb34278ea291 100644
--- a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/TestConnectorResource.java
+++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/TestConnectorResource.java
@@ -32,6 +32,8 @@
import org.apache.nifi.web.api.dto.AllowableValueDTO;
import org.apache.nifi.web.api.dto.ComponentStateDTO;
import org.apache.nifi.web.api.dto.ConnectorDTO;
+import org.apache.nifi.web.api.dto.MigrationRequestDTO;
+import org.apache.nifi.web.api.dto.MigrationRequestLocalSourceDTO;
import org.apache.nifi.web.api.dto.ParameterContextDTO;
import org.apache.nifi.web.api.dto.ParameterDTO;
import org.apache.nifi.web.api.dto.RevisionDTO;
@@ -43,10 +45,12 @@
import org.apache.nifi.web.api.entity.ConnectorRunStatusEntity;
import org.apache.nifi.web.api.entity.ControllerServiceEntity;
import org.apache.nifi.web.api.entity.ControllerServicesEntity;
+import org.apache.nifi.web.api.entity.MigrationRequestEntity;
import org.apache.nifi.web.api.entity.ParameterContextEntity;
import org.apache.nifi.web.api.entity.ParameterEntity;
import org.apache.nifi.web.api.entity.ProcessGroupFlowEntity;
import org.apache.nifi.web.api.entity.SecretsEntity;
+import org.apache.nifi.web.api.entity.VersionedFlowMigrationSourcesEntity;
import org.apache.nifi.web.api.request.ClientIdParameter;
import org.apache.nifi.web.api.request.LongParameter;
import org.junit.jupiter.api.AfterEach;
@@ -58,12 +62,16 @@
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.core.context.SecurityContextHolder;
+import java.io.IOException;
+import java.io.InputStream;
import java.net.URI;
+import java.util.Arrays;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
@@ -162,6 +170,80 @@ public void testGetConnector() {
verify(serviceFacade).getConnector(CONNECTOR_ID, false);
}
+ @Test
+ public void testGetMigrationSources() {
+ final VersionedFlowMigrationSourcesEntity migrationSourcesEntity = new VersionedFlowMigrationSourcesEntity();
+ when(serviceFacade.getConnectorMigrationSources(CONNECTOR_ID)).thenReturn(migrationSourcesEntity);
+
+ try (final Response response = connectorResource.getMigrationSources(CONNECTOR_ID)) {
+ assertEquals(200, response.getStatus());
+ assertEquals(migrationSourcesEntity, response.getEntity());
+ }
+
+ verify(serviceFacade).authorizeAccess(any(AuthorizeAccess.class));
+ verify(serviceFacade).getConnectorMigrationSources(CONNECTOR_ID);
+ }
+
+ @Test
+ public void testCreateMigrationRequestRejectsMismatchedConnectorId() {
+ final MigrationRequestDTO requestDto = new MigrationRequestDTO();
+ requestDto.setConnectorId("different-connector");
+ requestDto.setLocalSource(createLocalMigrationSource(PROCESS_GROUP_ID));
+
+ final MigrationRequestEntity requestEntity = new MigrationRequestEntity();
+ requestEntity.setRequest(requestDto);
+
+ assertThrows(IllegalArgumentException.class, () -> connectorResource.createMigrationRequest(CONNECTOR_ID, requestEntity));
+ }
+
+ @Test
+ public void testCreateMigrationRequestRequiresExactlyOneSource() {
+ final MigrationRequestDTO requestDto = new MigrationRequestDTO();
+ requestDto.setConnectorId(CONNECTOR_ID);
+ requestDto.setLocalSource(createLocalMigrationSource(PROCESS_GROUP_ID));
+ requestDto.setPayloadId("payload-1");
+
+ final MigrationRequestEntity requestEntity = new MigrationRequestEntity();
+ requestEntity.setRequest(requestDto);
+
+ assertThrows(IllegalArgumentException.class, () -> connectorResource.createMigrationRequest(CONNECTOR_ID, requestEntity));
+ }
+
+ @Test
+ public void testCreateMigrationRequestVerifiesConnectorReadinessBeforeSubmission() {
+ final ConnectorResource spyResource = spy(connectorResource);
+ doReturn(false).when(spyResource).isReplicateRequest();
+
+ final MigrationRequestDTO requestDto = new MigrationRequestDTO();
+ requestDto.setConnectorId(CONNECTOR_ID);
+ requestDto.setLocalSource(createLocalMigrationSource(PROCESS_GROUP_ID));
+
+ final MigrationRequestEntity requestEntity = new MigrationRequestEntity();
+ requestEntity.setRequest(requestDto);
+
+ doThrow(new IllegalStateException("Connector must be stopped before it can be migrated"))
+ .when(serviceFacade).verifyConnectorReadyForMigration(CONNECTOR_ID);
+
+ final IllegalStateException thrown = assertThrows(IllegalStateException.class,
+ () -> spyResource.createMigrationRequest(CONNECTOR_ID, requestEntity));
+ assertEquals("Connector must be stopped before it can be migrated", thrown.getMessage());
+
+ // Readiness is checked first, so an unready Connector aborts before the source-specific verification.
+ verify(serviceFacade).verifyConnectorReadyForMigration(CONNECTOR_ID);
+ verify(serviceFacade, never()).verifyCanMigrateConnector(anyString(), anyString());
+ }
+
+ @Test
+ public void testCreateMigrationRequestRequiresLocalSourceOrPayload() {
+ final MigrationRequestDTO requestDto = new MigrationRequestDTO();
+ requestDto.setConnectorId(CONNECTOR_ID);
+
+ final MigrationRequestEntity requestEntity = new MigrationRequestEntity();
+ requestEntity.setRequest(requestDto);
+
+ assertThrows(IllegalArgumentException.class, () -> connectorResource.createMigrationRequest(CONNECTOR_ID, requestEntity));
+ }
+
@Test
public void testGetConnectorClusterNodeRequestBypassesAuth() {
final ConnectorResource spyResource = spy(connectorResource);
@@ -878,4 +960,37 @@ public void testClearConnectorControllerServiceStateNotAuthorized() {
verify(serviceFacade, never()).verifyCanClearConnectorControllerServiceState(anyString(), anyString());
verify(serviceFacade, never()).clearConnectorControllerServiceState(anyString(), anyString(), any());
}
+
+ @Test
+ public void testCreateMigrationPayloadRejectsContentExceedingSizeCap() {
+ final ConnectorResource spyResource = spy(connectorResource);
+ doReturn(false).when(spyResource).isReplicateRequest();
+
+ // A stream that never ends will surpass the payload size cap, at which point the read fails and the upload is
+ // rejected rather than buffering an unbounded amount of content in memory.
+ final InputStream unboundedWhitespace = new InputStream() {
+ @Override
+ public int read() {
+ return ' ';
+ }
+
+ @Override
+ public int read(final byte[] b, final int off, final int len) {
+ Arrays.fill(b, off, off + len, (byte) ' ');
+ return len;
+ }
+ };
+
+ final IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class,
+ () -> spyResource.createMigrationPayload(CONNECTOR_ID, unboundedWhitespace));
+ assertEquals("Deserialization of uploaded migration payload failed", thrown.getMessage());
+ assertEquals(IOException.class, thrown.getCause().getClass());
+ assertTrue(thrown.getCause().getMessage().contains("maximum allowed length"), thrown.getCause().getMessage());
+ }
+
+ private MigrationRequestLocalSourceDTO createLocalMigrationSource(final String processGroupId) {
+ final MigrationRequestLocalSourceDTO localSource = new MigrationRequestLocalSourceDTO();
+ localSource.setProcessGroupId(processGroupId);
+ return localSource;
+ }
}
diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/types/index.ts b/nifi-frontend/src/main/frontend/libs/shared/src/types/index.ts
index b8e82ac3f617..6a8f5098ec71 100644
--- a/nifi-frontend/src/main/frontend/libs/shared/src/types/index.ts
+++ b/nifi-frontend/src/main/frontend/libs/shared/src/types/index.ts
@@ -234,6 +234,7 @@ export type ConnectorActionName =
| 'DRAIN_FLOWFILES'
| 'CANCEL_DRAIN_FLOWFILES'
| 'APPLY_UPDATES'
+ | 'MIGRATE'
| 'DELETE';
export interface ConnectorAction {
diff --git a/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/java/org/apache/nifi/connectors/tests/system/AsymmetricFailureMigrationConnector.java b/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/java/org/apache/nifi/connectors/tests/system/AsymmetricFailureMigrationConnector.java
new file mode 100644
index 000000000000..3247b61ccf89
--- /dev/null
+++ b/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/java/org/apache/nifi/connectors/tests/system/AsymmetricFailureMigrationConnector.java
@@ -0,0 +1,99 @@
+/*
+ * 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.connectors.tests.system;
+
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.connector.AbstractConnector;
+import org.apache.nifi.components.connector.ConfigurationStep;
+import org.apache.nifi.components.connector.FlowUpdateException;
+import org.apache.nifi.components.connector.components.FlowContext;
+import org.apache.nifi.components.connector.migration.ConnectorMigrationContext;
+import org.apache.nifi.components.connector.migration.MigratableConnector;
+import org.apache.nifi.flow.VersionedExternalFlow;
+import org.apache.nifi.flow.VersionedProcessGroup;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Test connector whose {@code migrateConfiguration} succeeds on every cluster node except the one whose
+ * {@code -DnodeNumber=} JVM argument matches {@value #FAILING_NODE_NUMBER}. Used to validate that the cluster
+ * migration-request endpoint merger surfaces a per-node failure even when other nodes report success.
+ */
+public class AsymmetricFailureMigrationConnector extends AbstractConnector implements MigratableConnector {
+ private static final String FAILING_NODE_NUMBER = "2";
+
+ @Override
+ public VersionedExternalFlow getInitialFlow() {
+ final VersionedProcessGroup processGroup = new VersionedProcessGroup();
+ processGroup.setName("Asymmetric Migration Flow");
+ processGroup.setProcessors(new HashSet<>());
+ processGroup.setConnections(new HashSet<>());
+ processGroup.setProcessGroups(new HashSet<>());
+ processGroup.setControllerServices(new HashSet<>());
+
+ final VersionedExternalFlow flow = new VersionedExternalFlow();
+ flow.setFlowContents(processGroup);
+ flow.setParameterContexts(Collections.emptyMap());
+ return flow;
+ }
+
+ @Override
+ public VersionedExternalFlow getActiveFlow(final FlowContext activeFlowContext) {
+ return getInitialFlow();
+ }
+
+ @Override
+ public boolean isMigrationSupported(final ConnectorMigrationContext context) {
+ return true;
+ }
+
+ @Override
+ public void migrateConfiguration(final ConnectorMigrationContext context) throws FlowUpdateException {
+ // Fail on the targeted node before recording any configuration so the merger sees a clean per-node failure
+ // while the other nodes succeed.
+ final String currentNodeNumber = System.getProperty("nodeNumber");
+ if (FAILING_NODE_NUMBER.equals(currentNodeNumber)) {
+ throw new FlowUpdateException("Simulated migration failure on node " + currentNodeNumber);
+ }
+ }
+
+ @Override
+ public void migrateState(final ConnectorMigrationContext context) {
+ // No state to migrate; success on every non-failing node.
+ }
+
+ @Override
+ protected void onStepConfigured(final String stepName, final FlowContext workingContext) {
+ }
+
+ @Override
+ public List verifyConfigurationStep(final String stepName, final Map propertyValueOverrides, final FlowContext flowContext) {
+ return List.of();
+ }
+
+ @Override
+ public List getConfigurationSteps() {
+ return List.of();
+ }
+
+ @Override
+ public void applyUpdate(final FlowContext workingFlowContext, final FlowContext activeFlowContext) {
+ }
+}
diff --git a/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/java/org/apache/nifi/connectors/tests/system/FailingConfigurationMigrationConnector.java b/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/java/org/apache/nifi/connectors/tests/system/FailingConfigurationMigrationConnector.java
new file mode 100644
index 000000000000..d41cd032c99f
--- /dev/null
+++ b/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/java/org/apache/nifi/connectors/tests/system/FailingConfigurationMigrationConnector.java
@@ -0,0 +1,136 @@
+/*
+ * 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.connectors.tests.system;
+
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.connector.AbstractConnector;
+import org.apache.nifi.components.connector.ConfigurationStep;
+import org.apache.nifi.components.connector.FlowUpdateException;
+import org.apache.nifi.components.connector.components.FlowContext;
+import org.apache.nifi.components.connector.migration.ConnectorMigrationContext;
+import org.apache.nifi.components.connector.migration.MigratableConnector;
+import org.apache.nifi.flow.VersionedAsset;
+import org.apache.nifi.flow.VersionedExternalFlow;
+import org.apache.nifi.flow.VersionedParameter;
+import org.apache.nifi.flow.VersionedParameterContext;
+import org.apache.nifi.flow.VersionedProcessGroup;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Test connector that fails during the configuration phase of migration so the framework rolls the migration back
+ * as a whole. Used to verify rollback semantics: copied assets are deleted, the initial flow is restored, and the
+ * connector remains in a state that allows a subsequent migration to succeed.
+ */
+public class FailingConfigurationMigrationConnector extends AbstractConnector implements MigratableConnector {
+ private static final long FAILURE_DELAY_SECONDS = 5L;
+
+ @Override
+ public VersionedExternalFlow getInitialFlow() {
+ final VersionedProcessGroup processGroup = new VersionedProcessGroup();
+ processGroup.setName("Failing Migration Flow");
+ processGroup.setProcessors(new HashSet<>());
+ processGroup.setConnections(new HashSet<>());
+ processGroup.setProcessGroups(new HashSet<>());
+ processGroup.setControllerServices(new HashSet<>());
+
+ final VersionedExternalFlow flow = new VersionedExternalFlow();
+ flow.setFlowContents(processGroup);
+ flow.setParameterContexts(Collections.emptyMap());
+ return flow;
+ }
+
+ @Override
+ public VersionedExternalFlow getActiveFlow(final FlowContext activeFlowContext) {
+ return getInitialFlow();
+ }
+
+ @Override
+ public boolean isMigrationSupported(final ConnectorMigrationContext context) {
+ return true;
+ }
+
+ @Override
+ public void migrateConfiguration(final ConnectorMigrationContext context) throws FlowUpdateException {
+ // Copy an asset and sleep so the rollback path has observable side effects to undo, then throw to drive the
+ // framework's "phase-1 failure" rollback branch.
+ copyFirstAsset(context);
+ try {
+ TimeUnit.SECONDS.sleep(FAILURE_DELAY_SECONDS);
+ } catch (final InterruptedException interruptedException) {
+ Thread.currentThread().interrupt();
+ throw new FlowUpdateException("Interrupted while waiting to fail the migration", interruptedException);
+ }
+
+ throw new FlowUpdateException("Intended migration failure for rollback testing");
+ }
+
+ @Override
+ public void migrateState(final ConnectorMigrationContext context) {
+ // Failure is intentionally triggered from migrateConfiguration so migrateState is never reached.
+ }
+
+ @Override
+ protected void onStepConfigured(final String stepName, final FlowContext workingContext) {
+ }
+
+ @Override
+ public List verifyConfigurationStep(final String stepName, final Map propertyValueOverrides, final FlowContext flowContext) {
+ return List.of();
+ }
+
+ @Override
+ public List getConfigurationSteps() {
+ return List.of();
+ }
+
+ @Override
+ public void applyUpdate(final FlowContext workingFlowContext, final FlowContext activeFlowContext) {
+ }
+
+ private void copyFirstAsset(final ConnectorMigrationContext context) {
+ if (!context.isLocalMigration()) {
+ return;
+ }
+
+ final VersionedExternalFlow sourceFlow = context.getSourceFlow();
+ if (sourceFlow.getParameterContexts() == null) {
+ return;
+ }
+
+ for (final VersionedParameterContext parameterContext : sourceFlow.getParameterContexts().values()) {
+ if (parameterContext.getParameters() == null) {
+ continue;
+ }
+
+ for (final VersionedParameter parameter : parameterContext.getParameters()) {
+ if (parameter.getReferencedAssets() == null) {
+ continue;
+ }
+
+ for (final VersionedAsset referencedAsset : parameter.getReferencedAssets()) {
+ context.copyAssetFromSource(referencedAsset.getIdentifier());
+ return;
+ }
+ }
+ }
+ }
+}
diff --git a/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/java/org/apache/nifi/connectors/tests/system/FailingStateMigrationConnector.java b/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/java/org/apache/nifi/connectors/tests/system/FailingStateMigrationConnector.java
new file mode 100644
index 000000000000..a12f19366c94
--- /dev/null
+++ b/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/java/org/apache/nifi/connectors/tests/system/FailingStateMigrationConnector.java
@@ -0,0 +1,120 @@
+/*
+ * 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.connectors.tests.system;
+
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.connector.AbstractConnector;
+import org.apache.nifi.components.connector.ConfigurationStep;
+import org.apache.nifi.components.connector.ConnectorPropertyDescriptor;
+import org.apache.nifi.components.connector.ConnectorPropertyGroup;
+import org.apache.nifi.components.connector.FlowUpdateException;
+import org.apache.nifi.components.connector.components.FlowContext;
+import org.apache.nifi.components.connector.migration.ConnectorMigrationContext;
+import org.apache.nifi.components.connector.migration.MigratableConnector;
+import org.apache.nifi.flow.VersionedExternalFlow;
+import org.apache.nifi.flow.VersionedProcessGroup;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Test connector that records a configuration change during the configuration phase of migration and then throws from
+ * the state phase. Used to verify that a failure in {@code migrateState} prevents the framework from committing the
+ * recorded configuration onto active and that the connector's persisted configuration therefore matches the
+ * pre-migration state after a restart.
+ */
+public class FailingStateMigrationConnector extends AbstractConnector implements MigratableConnector {
+
+ static final String MARKER_STEP_NAME = "State Migration Marker";
+ static final String MARKER_PROPERTY_NAME = "Marker";
+ static final String MARKER_VALUE_STAGED_BY_FAILED_MIGRATION = "staged-by-failed-migration";
+
+ private static final ConnectorPropertyDescriptor MARKER_PROPERTY = new ConnectorPropertyDescriptor.Builder()
+ .name(MARKER_PROPERTY_NAME)
+ .description("Marker the test sets during migrateConfiguration to verify it is not persisted when migrateState fails.")
+ .build();
+
+ private static final ConnectorPropertyGroup MARKER_PROPERTY_GROUP = new ConnectorPropertyGroup.Builder()
+ .name("State Migration Marker")
+ .description("Holds a marker used by the failed-state-migration rollback system test.")
+ .addProperty(MARKER_PROPERTY)
+ .build();
+
+ private static final ConfigurationStep MARKER_STEP = new ConfigurationStep.Builder()
+ .name(MARKER_STEP_NAME)
+ .description("Marker step updated by migrateConfiguration. The matching migrateState always throws, so the framework must not commit this step onto active.")
+ .propertyGroups(List.of(MARKER_PROPERTY_GROUP))
+ .build();
+
+ @Override
+ public VersionedExternalFlow getInitialFlow() {
+ final VersionedProcessGroup processGroup = new VersionedProcessGroup();
+ processGroup.setName("Failing State Migration Flow");
+ processGroup.setProcessors(new HashSet<>());
+ processGroup.setConnections(new HashSet<>());
+ processGroup.setProcessGroups(new HashSet<>());
+ processGroup.setControllerServices(new HashSet<>());
+
+ final VersionedExternalFlow flow = new VersionedExternalFlow();
+ flow.setFlowContents(processGroup);
+ flow.setParameterContexts(Collections.emptyMap());
+ return flow;
+ }
+
+ @Override
+ public VersionedExternalFlow getActiveFlow(final FlowContext activeFlowContext) {
+ return getInitialFlow();
+ }
+
+ @Override
+ public boolean isMigrationSupported(final ConnectorMigrationContext context) {
+ return true;
+ }
+
+ @Override
+ public void migrateConfiguration(final ConnectorMigrationContext context) {
+ // Record a configuration change so the framework has something to potentially commit. The matching
+ // migrateState below always throws, so the framework must roll the migration back and this change must not
+ // appear on the active configuration after a restart.
+ context.setProperties(MARKER_STEP_NAME, Map.of(MARKER_PROPERTY_NAME, MARKER_VALUE_STAGED_BY_FAILED_MIGRATION));
+ }
+
+ @Override
+ public void migrateState(final ConnectorMigrationContext context) throws FlowUpdateException {
+ throw new FlowUpdateException("Intended state-migration failure for rollback testing");
+ }
+
+ @Override
+ protected void onStepConfigured(final String stepName, final FlowContext workingContext) {
+ }
+
+ @Override
+ public List verifyConfigurationStep(final String stepName, final Map propertyValueOverrides, final FlowContext flowContext) {
+ return List.of();
+ }
+
+ @Override
+ public List getConfigurationSteps() {
+ return List.of(MARKER_STEP);
+ }
+
+ @Override
+ public void applyUpdate(final FlowContext workingFlowContext, final FlowContext activeFlowContext) {
+ }
+}
diff --git a/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/java/org/apache/nifi/connectors/tests/system/MigrationTargetConnector.java b/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/java/org/apache/nifi/connectors/tests/system/MigrationTargetConnector.java
new file mode 100644
index 000000000000..aa22eb5562fd
--- /dev/null
+++ b/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/java/org/apache/nifi/connectors/tests/system/MigrationTargetConnector.java
@@ -0,0 +1,402 @@
+/*
+ * 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.connectors.tests.system;
+
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.ConfigVerificationResult.Outcome;
+import org.apache.nifi.components.connector.AbstractConnector;
+import org.apache.nifi.components.connector.AssetReference;
+import org.apache.nifi.components.connector.BundleCompatibility;
+import org.apache.nifi.components.connector.ConfigurationStep;
+import org.apache.nifi.components.connector.ConnectorConfigurationContext;
+import org.apache.nifi.components.connector.ConnectorPropertyDescriptor;
+import org.apache.nifi.components.connector.ConnectorPropertyGroup;
+import org.apache.nifi.components.connector.ConnectorPropertyValue;
+import org.apache.nifi.components.connector.FlowUpdateException;
+import org.apache.nifi.components.connector.PropertyType;
+import org.apache.nifi.components.connector.components.FlowContext;
+import org.apache.nifi.components.connector.components.ProcessGroupFacade;
+import org.apache.nifi.components.connector.components.ProcessorFacade;
+import org.apache.nifi.components.connector.migration.ConnectorMigrationContext;
+import org.apache.nifi.components.connector.migration.MigratableConnector;
+import org.apache.nifi.components.connector.util.VersionedFlowUtils;
+import org.apache.nifi.flow.Bundle;
+import org.apache.nifi.flow.Position;
+import org.apache.nifi.flow.VersionedAsset;
+import org.apache.nifi.flow.VersionedComponentState;
+import org.apache.nifi.flow.VersionedExternalFlow;
+import org.apache.nifi.flow.VersionedExternalFlowMetadata;
+import org.apache.nifi.flow.VersionedParameter;
+import org.apache.nifi.flow.VersionedParameterContext;
+import org.apache.nifi.flow.VersionedProcessGroup;
+import org.apache.nifi.flow.VersionedProcessor;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * Test connector used to verify migration of a version-controlled Process Group into a Connector.
+ *
+ *
+ * The connector models one specific flow: a {@code GenerateFlowFile} source feeds a
+ * {@code StatefulCountProcessor}, which in turn feeds an {@code AssetReadingProcessor} that copies the bytes of a
+ * file-backed asset to an output file. The connector understands this exact flow, so migration is not a generic
+ * copy of arbitrary components: each configuration value, asset, and piece of component state is mapped from the
+ * source flow onto the specific component the connector rebuilds.
+ *
+ *
+ * Persistence model
+ *
+ *
+ * The framework persists a Connector's configuration (the properties of its {@link ConfigurationStep
+ * ConfigurationSteps}) to {@code flow.json.gz}. It does not persist the managed Process Group. On restart the
+ * framework rehydrates the configuration and calls {@link #applyUpdate(FlowContext, FlowContext)}, which rebuilds the
+ * managed Process Group from that configuration. The connector therefore stores everything it needs to rebuild the
+ * flow as ordinary configuration properties.
+ *
+ *
+ * Migration
+ *
+ *
+ * - {@link #migrateConfiguration(ConnectorMigrationContext)} reads the source flow's {@code AssetReadingProcessor}
+ * and {@code GenerateFlowFile} to determine the output file, the source file (or referenced asset), and the
+ * generate schedule, and records them as this connector's configuration. When the source references an asset
+ * and the migration is from a local Versioned Process Group, the asset is copied via
+ * {@link ConnectorMigrationContext#copyAssetFromSource(String)} and the returned {@link AssetReference} is
+ * recorded on the {@code Asset File} property.
+ * - {@link #migrateState(ConnectorMigrationContext)} copies the {@link VersionedComponentState} of the source
+ * {@code GenerateFlowFile} and {@code StatefulCountProcessor} onto the matching managed components, located by
+ * component name rather than by identifier.
+ *
+ */
+public class MigrationTargetConnector extends AbstractConnector implements MigratableConnector {
+ static final String EXPECTED_FLOW_NAME = "Asset Ingest Flow";
+
+ static final String STEP_NAME = "Flow Configuration";
+
+ static final ConnectorPropertyDescriptor ASSET_FILE = new ConnectorPropertyDescriptor.Builder()
+ .name("Asset File")
+ .description("The file-backed asset whose contents the flow reads. Set during migration when the source flow references an asset.")
+ .type(PropertyType.ASSET)
+ .required(false)
+ .build();
+
+ static final ConnectorPropertyDescriptor SOURCE_FILE = new ConnectorPropertyDescriptor.Builder()
+ .name("Source File")
+ .description("A literal path to the file whose contents the flow reads. Used when the source flow references a file by path rather than by asset.")
+ .type(PropertyType.STRING)
+ .required(false)
+ .build();
+
+ static final ConnectorPropertyDescriptor OUTPUT_FILE = new ConnectorPropertyDescriptor.Builder()
+ .name("Output File")
+ .description("The path where the flow writes the contents that were read.")
+ .type(PropertyType.STRING)
+ .required(true)
+ .build();
+
+ static final ConnectorPropertyDescriptor GENERATE_SCHEDULE = new ConnectorPropertyDescriptor.Builder()
+ .name("Generate Schedule")
+ .description("The scheduling period of the GenerateFlowFile source processor.")
+ .type(PropertyType.STRING)
+ .required(true)
+ .defaultValue("10 sec")
+ .build();
+
+ private static final ConnectorPropertyGroup PROPERTY_GROUP = new ConnectorPropertyGroup.Builder()
+ .name(STEP_NAME)
+ .description("Configuration for the modeled asset-ingest flow.")
+ .properties(List.of(ASSET_FILE, SOURCE_FILE, OUTPUT_FILE, GENERATE_SCHEDULE))
+ .build();
+
+ private static final ConfigurationStep CONFIGURATION_STEP = new ConfigurationStep.Builder()
+ .name(STEP_NAME)
+ .description("Configuration for the modeled asset-ingest flow.")
+ .propertyGroups(List.of(PROPERTY_GROUP))
+ .build();
+
+ private static final Bundle SYSTEM_TEST_EXTENSIONS_BUNDLE = new Bundle("org.apache.nifi", "nifi-system-test-extensions-nar", "2.11.0-SNAPSHOT");
+
+ private static final String ROOT_GROUP_ID = "migration-target-root";
+ private static final String GENERATE_NAME = "GenerateFlowFile";
+ private static final String COUNT_NAME = "StatefulCountProcessor";
+ private static final String ASSET_READER_NAME = "AssetReadingProcessor";
+ private static final String PROCESSORS_PACKAGE = "org.apache.nifi.processors.tests.system";
+ private static final String GENERATE_TYPE = PROCESSORS_PACKAGE + ".GenerateFlowFile";
+ private static final String COUNT_TYPE = PROCESSORS_PACKAGE + ".StatefulCountProcessor";
+ private static final String ASSET_READER_TYPE = PROCESSORS_PACKAGE + ".AssetReadingProcessor";
+ private static final String SOURCE_FILE_PROPERTY = "Source File";
+ private static final String OUTPUT_FILE_PROPERTY = "Output File";
+
+ // The modeled source flow references its asset through a Parameter Context parameter with this exact name. The
+ // connector understands the flow it is migrating, so it looks this parameter up by name rather than discovering
+ // parameter references generically.
+ private static final String SOURCE_ASSET_PARAMETER_NAME = "Asset File";
+
+ @Override
+ public List getConfigurationSteps() {
+ return List.of(CONFIGURATION_STEP);
+ }
+
+ @Override
+ public VersionedExternalFlow getInitialFlow() {
+ return createEmptyFlow();
+ }
+
+ @Override
+ public VersionedExternalFlow getActiveFlow(final FlowContext activeFlowContext) {
+ return buildFlow(activeFlowContext.getConfigurationContext());
+ }
+
+ @Override
+ public void applyUpdate(final FlowContext workingFlowContext, final FlowContext activeFlowContext) throws FlowUpdateException {
+ final VersionedExternalFlow flow = buildFlow(workingFlowContext.getConfigurationContext());
+ getInitializationContext().updateFlow(activeFlowContext, flow, BundleCompatibility.RESOLVE_BUNDLE);
+ }
+
+ @Override
+ protected void onStepConfigured(final String stepName, final FlowContext workingContext) throws FlowUpdateException {
+ final VersionedExternalFlow flow = buildFlow(workingContext.getConfigurationContext());
+ getInitializationContext().updateFlow(workingContext, flow, BundleCompatibility.RESOLVE_BUNDLE);
+ }
+
+ @Override
+ public boolean isMigrationSupported(final ConnectorMigrationContext context) {
+ final VersionedExternalFlow sourceFlow = context.getSourceFlow();
+ if (sourceFlow == null) {
+ return false;
+ }
+
+ final VersionedExternalFlowMetadata metadata = sourceFlow.getMetadata();
+ return metadata != null && EXPECTED_FLOW_NAME.equals(metadata.getFlowName());
+ }
+
+ @Override
+ public void migrateConfiguration(final ConnectorMigrationContext context) throws FlowUpdateException {
+ final VersionedExternalFlow sourceFlow = context.getSourceFlow();
+ if (sourceFlow == null) {
+ throw new FlowUpdateException("A source flow is required for migration");
+ }
+
+ final VersionedProcessor assetReader = VersionedFlowUtils.findProcessor(sourceFlow.getFlowContents(), processor -> ASSET_READER_TYPE.equals(processor.getType()))
+ .orElseThrow(() -> new FlowUpdateException("Source flow does not contain the expected " + ASSET_READER_NAME));
+ final VersionedProcessor generate = VersionedFlowUtils.findProcessor(sourceFlow.getFlowContents(), processor -> GENERATE_TYPE.equals(processor.getType()))
+ .orElseThrow(() -> new FlowUpdateException("Source flow does not contain the expected " + GENERATE_NAME));
+
+ final Map stringProperties = new HashMap<>();
+ final String outputFile = assetReader.getProperties().get(OUTPUT_FILE_PROPERTY);
+ if (outputFile != null) {
+ stringProperties.put(OUTPUT_FILE.getName(), outputFile);
+ }
+
+ if (generate.getSchedulingPeriod() != null) {
+ stringProperties.put(GENERATE_SCHEDULE.getName(), generate.getSchedulingPeriod());
+ }
+
+ final AssetReference assetReference = copyReferencedAsset(sourceFlow, context);
+ if (assetReference == null) {
+ final String sourceFileValue = assetReader.getProperties().get(SOURCE_FILE_PROPERTY);
+ if (sourceFileValue != null) {
+ stringProperties.put(SOURCE_FILE.getName(), sourceFileValue);
+ }
+ } else {
+ context.setValueReferences(STEP_NAME, Map.of(ASSET_FILE.getName(), assetReference));
+ }
+
+ if (!stringProperties.isEmpty()) {
+ context.setProperties(STEP_NAME, stringProperties);
+ }
+ }
+
+ @Override
+ public void migrateState(final ConnectorMigrationContext context) {
+ final VersionedExternalFlow sourceFlow = context.getSourceFlow();
+ if (sourceFlow == null || sourceFlow.getFlowContents() == null) {
+ return;
+ }
+
+ final ProcessGroupFacade managedGroup = context.getActiveFlowContext().getRootGroup();
+ copyComponentState(sourceFlow, managedGroup, GENERATE_NAME, context);
+ copyComponentState(sourceFlow, managedGroup, COUNT_NAME, context);
+ }
+
+ @Override
+ public List verifyConfigurationStep(final String stepName, final Map propertyValueOverrides, final FlowContext flowContext) {
+ final ConnectorConfigurationContext configurationContext = flowContext.getConfigurationContext().createWithOverrides(stepName, propertyValueOverrides);
+ final List results = new ArrayList<>();
+
+ final String sourceFile = resolveSourceFile(configurationContext);
+ if (sourceFile == null || sourceFile.isBlank()) {
+ results.add(new ConfigVerificationResult.Builder()
+ .verificationStepName("Source File Readable")
+ .outcome(Outcome.FAILED)
+ .explanation("No Asset File or Source File is configured")
+ .build());
+ } else {
+ final File file = new File(sourceFile);
+ final boolean readable = file.isFile() && file.canRead();
+ results.add(new ConfigVerificationResult.Builder()
+ .verificationStepName("Source File Readable")
+ .outcome(readable ? Outcome.SUCCESSFUL : Outcome.FAILED)
+ .explanation(readable ? "Source file exists and is readable: " + sourceFile : "Source file does not exist or is not readable: " + sourceFile)
+ .build());
+ }
+
+ final String outputFile = configurationContext.getProperty(STEP_NAME, OUTPUT_FILE.getName()).getValue();
+ if (outputFile == null || outputFile.isBlank()) {
+ results.add(new ConfigVerificationResult.Builder()
+ .verificationStepName("Output File Writable")
+ .outcome(Outcome.FAILED)
+ .explanation("No Output File is configured")
+ .build());
+ } else {
+ final File parent = new File(outputFile).getAbsoluteFile().getParentFile();
+ final boolean writable = parent != null && (parent.isDirectory() || parent.mkdirs());
+ results.add(new ConfigVerificationResult.Builder()
+ .verificationStepName("Output File Writable")
+ .outcome(writable ? Outcome.SUCCESSFUL : Outcome.FAILED)
+ .explanation(writable ? "Output directory is writable: " + parent : "Output directory could not be created: " + parent)
+ .build());
+ }
+
+ return results;
+ }
+
+ private VersionedExternalFlow buildFlow(final ConnectorConfigurationContext configurationContext) {
+ final String sourceFile = resolveSourceFile(configurationContext);
+ final String outputFile = configurationContext.getProperty(STEP_NAME, OUTPUT_FILE.getName()).getValue();
+ if (sourceFile == null || sourceFile.isBlank() || outputFile == null || outputFile.isBlank()) {
+ return createEmptyFlow();
+ }
+
+ final String generateSchedule = configurationContext.getProperty(STEP_NAME, GENERATE_SCHEDULE.getName()).getValue();
+
+ final VersionedProcessGroup rootGroup = VersionedFlowUtils.createProcessGroup(ROOT_GROUP_ID, EXPECTED_FLOW_NAME);
+
+ final VersionedProcessor generate = VersionedFlowUtils.addProcessor(rootGroup, GENERATE_TYPE, SYSTEM_TEST_EXTENSIONS_BUNDLE, GENERATE_NAME, new Position(0, 0));
+ generate.getProperties().put("Max FlowFiles", "1");
+ generate.getProperties().put("File Size", "0 B");
+ generate.setSchedulingPeriod(generateSchedule);
+
+ final VersionedProcessor count = VersionedFlowUtils.addProcessor(rootGroup, COUNT_TYPE, SYSTEM_TEST_EXTENSIONS_BUNDLE, COUNT_NAME, new Position(0, 200));
+
+ final VersionedProcessor assetReader = VersionedFlowUtils.addProcessor(rootGroup, ASSET_READER_TYPE, SYSTEM_TEST_EXTENSIONS_BUNDLE, ASSET_READER_NAME, new Position(0, 400));
+ assetReader.getProperties().put(SOURCE_FILE_PROPERTY, sourceFile);
+ assetReader.getProperties().put(OUTPUT_FILE_PROPERTY, outputFile);
+ assetReader.setAutoTerminatedRelationships(Set.of("success", "failure"));
+
+ VersionedFlowUtils.addConnection(rootGroup, VersionedFlowUtils.createConnectableComponent(generate),
+ VersionedFlowUtils.createConnectableComponent(count), Set.of("success"));
+ VersionedFlowUtils.addConnection(rootGroup, VersionedFlowUtils.createConnectableComponent(count),
+ VersionedFlowUtils.createConnectableComponent(assetReader), Set.of("success"));
+
+ final VersionedExternalFlow flow = new VersionedExternalFlow();
+ flow.setFlowContents(rootGroup);
+ flow.setParameterContexts(Collections.emptyMap());
+ return flow;
+ }
+
+ private String resolveSourceFile(final ConnectorConfigurationContext configurationContext) {
+ final ConnectorPropertyValue assetValue = configurationContext.getProperty(STEP_NAME, ASSET_FILE.getName());
+ final String assetPath = assetValue == null ? null : assetValue.getValue();
+ if (assetPath != null && !assetPath.isBlank()) {
+ return assetPath;
+ }
+
+ final ConnectorPropertyValue sourceValue = configurationContext.getProperty(STEP_NAME, SOURCE_FILE.getName());
+ return sourceValue == null ? null : sourceValue.getValue();
+ }
+
+ private AssetReference copyReferencedAsset(final VersionedExternalFlow sourceFlow, final ConnectorMigrationContext context) {
+ // Assets can only be copied from a local source; uploaded payloads do not carry the asset binaries.
+ if (!context.isLocalMigration()) {
+ return null;
+ }
+
+ final VersionedParameter assetParameter = findParameter(sourceFlow, SOURCE_ASSET_PARAMETER_NAME);
+ if (assetParameter == null || assetParameter.getReferencedAssets() == null || assetParameter.getReferencedAssets().isEmpty()) {
+ return null;
+ }
+
+ final VersionedAsset referencedAsset = assetParameter.getReferencedAssets().getFirst();
+ return context.copyAssetFromSource(referencedAsset.getIdentifier());
+ }
+
+ private VersionedParameter findParameter(final VersionedExternalFlow sourceFlow, final String parameterName) {
+ final Map parameterContexts = sourceFlow.getParameterContexts();
+ if (parameterContexts == null) {
+ return null;
+ }
+
+ for (final VersionedParameterContext parameterContext : parameterContexts.values()) {
+ if (parameterContext.getParameters() == null) {
+ continue;
+ }
+
+ for (final VersionedParameter parameter : parameterContext.getParameters()) {
+ if (parameterName.equals(parameter.getName())) {
+ return parameter;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ private void copyComponentState(final VersionedExternalFlow sourceFlow, final ProcessGroupFacade managedGroup, final String componentName, final ConnectorMigrationContext context) {
+ final Optional sourceProcessor = VersionedFlowUtils.findProcessor(sourceFlow.getFlowContents(), processor -> componentName.equals(processor.getName()));
+ if (sourceProcessor.isEmpty()) {
+ return;
+ }
+
+ final VersionedComponentState componentState = sourceProcessor.get().getComponentState();
+ if (componentState == null) {
+ return;
+ }
+
+ final ProcessorFacade managedProcessor = findManagedProcessor(managedGroup, componentName);
+ if (managedProcessor == null) {
+ return;
+ }
+
+ context.setComponentState(managedProcessor.getDefinition().getIdentifier(), componentState);
+ }
+
+ private ProcessorFacade findManagedProcessor(final ProcessGroupFacade group, final String componentName) {
+ for (final ProcessorFacade processor : group.getProcessors()) {
+ if (componentName.equals(processor.getDefinition().getName())) {
+ return processor;
+ }
+ }
+
+ return null;
+ }
+
+ private VersionedExternalFlow createEmptyFlow() {
+ final VersionedProcessGroup rootGroup = VersionedFlowUtils.createProcessGroup(ROOT_GROUP_ID, EXPECTED_FLOW_NAME);
+ final VersionedExternalFlow flow = new VersionedExternalFlow();
+ flow.setFlowContents(rootGroup);
+ flow.setParameterContexts(Collections.emptyMap());
+ return flow;
+ }
+}
diff --git a/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/java/org/apache/nifi/connectors/tests/system/NonMigratingConnector.java b/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/java/org/apache/nifi/connectors/tests/system/NonMigratingConnector.java
new file mode 100644
index 000000000000..fa65f3997dbe
--- /dev/null
+++ b/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/java/org/apache/nifi/connectors/tests/system/NonMigratingConnector.java
@@ -0,0 +1,64 @@
+/*
+ * 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.connectors.tests.system;
+
+import org.apache.nifi.components.ConfigVerificationResult;
+import org.apache.nifi.components.connector.AbstractConnector;
+import org.apache.nifi.components.connector.ConfigurationStep;
+import org.apache.nifi.components.connector.components.FlowContext;
+import org.apache.nifi.flow.VersionedExternalFlow;
+import org.apache.nifi.flow.VersionedProcessGroup;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+public class NonMigratingConnector extends AbstractConnector {
+ @Override
+ public VersionedExternalFlow getInitialFlow() {
+ final VersionedProcessGroup processGroup = new VersionedProcessGroup();
+ processGroup.setName("Non Migrating Flow");
+
+ final VersionedExternalFlow flow = new VersionedExternalFlow();
+ flow.setFlowContents(processGroup);
+ flow.setParameterContexts(Collections.emptyMap());
+ return flow;
+ }
+
+ @Override
+ public VersionedExternalFlow getActiveFlow(final FlowContext activeFlowContext) {
+ return getInitialFlow();
+ }
+
+ @Override
+ protected void onStepConfigured(final String stepName, final FlowContext workingContext) {
+ }
+
+ @Override
+ public List verifyConfigurationStep(final String stepName, final Map propertyValueOverrides, final FlowContext flowContext) {
+ return List.of();
+ }
+
+ @Override
+ public List getConfigurationSteps() {
+ return List.of();
+ }
+
+ @Override
+ public void applyUpdate(final FlowContext workingFlowContext, final FlowContext activeFlowContext) {
+ }
+}
diff --git a/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/java/org/apache/nifi/processors/tests/system/AssetReadingProcessor.java b/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/java/org/apache/nifi/processors/tests/system/AssetReadingProcessor.java
new file mode 100644
index 000000000000..54053d1e8be3
--- /dev/null
+++ b/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/java/org/apache/nifi/processors/tests/system/AssetReadingProcessor.java
@@ -0,0 +1,111 @@
+/*
+ * 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.documentation.CapabilityDescription;
+import org.apache.nifi.annotation.documentation.Tags;
+import org.apache.nifi.components.PropertyDescriptor;
+import org.apache.nifi.flowfile.FlowFile;
+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 org.apache.nifi.processor.util.StandardValidators;
+import org.apache.nifi.stream.io.StreamUtils;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.file.AtomicMoveNotSupportedException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.util.List;
+import java.util.Set;
+
+@Tags({"asset", "test"})
+@CapabilityDescription("Reads the contents of a file-backed asset parameter and writes the asset contents to a configured output file.")
+public class AssetReadingProcessor extends AbstractProcessor {
+ static final PropertyDescriptor SOURCE_FILE = new PropertyDescriptor.Builder()
+ .name("Source File")
+ .description("The path of the asset file to read")
+ .required(true)
+ .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+ .build();
+
+ static final PropertyDescriptor OUTPUT_FILE = new PropertyDescriptor.Builder()
+ .name("Output File")
+ .description("The file where the asset contents will be written")
+ .required(true)
+ .addValidator(StandardValidators.NON_EMPTY_VALIDATOR)
+ .build();
+
+ static final Relationship REL_SUCCESS = new Relationship.Builder()
+ .name("success")
+ .build();
+
+ static final Relationship REL_FAILURE = new Relationship.Builder()
+ .name("failure")
+ .build();
+
+ @Override
+ protected List getSupportedPropertyDescriptors() {
+ return List.of(SOURCE_FILE, OUTPUT_FILE);
+ }
+
+ @Override
+ public Set getRelationships() {
+ return Set.of(REL_SUCCESS, REL_FAILURE);
+ }
+
+ @Override
+ public void onTrigger(final ProcessContext context, final ProcessSession session) throws ProcessException {
+ final FlowFile incomingFlowFile = session.get();
+ final FlowFile flowFile = incomingFlowFile == null ? session.create() : incomingFlowFile;
+
+ final File sourceFile = new File(context.getProperty(SOURCE_FILE).getValue());
+ final File outputFile = new File(context.getProperty(OUTPUT_FILE).getValue());
+ final File parentFile = outputFile.getParentFile();
+ if (parentFile != null && !parentFile.exists() && !parentFile.mkdirs()) {
+ getLogger().error("Could not create directory {}", parentFile.getAbsolutePath());
+ session.transfer(flowFile, REL_FAILURE);
+ return;
+ }
+
+ final Path outputPath = outputFile.toPath();
+ final Path temporaryPath = outputPath.resolveSibling(outputFile.getName() + ".tmp");
+ try {
+ try (final InputStream inputStream = new FileInputStream(sourceFile);
+ final OutputStream outputStream = Files.newOutputStream(temporaryPath)) {
+ StreamUtils.copy(inputStream, outputStream);
+ }
+
+ try {
+ Files.move(temporaryPath, outputPath, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
+ } catch (final AtomicMoveNotSupportedException e) {
+ Files.move(temporaryPath, outputPath, StandardCopyOption.REPLACE_EXISTING);
+ }
+
+ session.transfer(flowFile, REL_SUCCESS);
+ } catch (final Exception e) {
+ getLogger().error("Failed to read asset file {} to {}", sourceFile.getAbsolutePath(), outputFile.getAbsolutePath(), e);
+ session.transfer(flowFile, REL_FAILURE);
+ }
+ }
+}
diff --git a/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/resources/META-INF/services/org.apache.nifi.components.connector.Connector b/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/resources/META-INF/services/org.apache.nifi.components.connector.Connector
index a2d46427433a..db4fb6be33c7 100644
--- a/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/resources/META-INF/services/org.apache.nifi.components.connector.Connector
+++ b/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/resources/META-INF/services/org.apache.nifi.components.connector.Connector
@@ -14,12 +14,17 @@
# limitations under the License.
org.apache.nifi.connectors.tests.system.AssetConnector
+org.apache.nifi.connectors.tests.system.AsymmetricFailureMigrationConnector
org.apache.nifi.connectors.tests.system.BundleResolutionConnector
org.apache.nifi.connectors.tests.system.CalculateConnector
org.apache.nifi.connectors.tests.system.ComponentLifecycleConnector
org.apache.nifi.connectors.tests.system.DataQueuingConnector
org.apache.nifi.connectors.tests.system.GatedDataQueuingConnector
+org.apache.nifi.connectors.tests.system.FailingConfigurationMigrationConnector
+org.apache.nifi.connectors.tests.system.FailingStateMigrationConnector
+org.apache.nifi.connectors.tests.system.MigrationTargetConnector
org.apache.nifi.connectors.tests.system.NestedProcessGroupConnector
+org.apache.nifi.connectors.tests.system.NonMigratingConnector
org.apache.nifi.connectors.tests.system.NopConnector
org.apache.nifi.connectors.tests.system.ParameterContextConnector
org.apache.nifi.connectors.tests.system.SelectiveDropConnector
diff --git a/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/resources/META-INF/services/org.apache.nifi.processor.Processor b/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/resources/META-INF/services/org.apache.nifi.processor.Processor
index 80655da6f369..5c1de2753838 100644
--- a/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/resources/META-INF/services/org.apache.nifi.processor.Processor
+++ b/nifi-system-tests/nifi-system-test-extensions-bundle/nifi-system-test-extensions/src/main/resources/META-INF/services/org.apache.nifi.processor.Processor
@@ -13,6 +13,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
+org.apache.nifi.processors.tests.system.AssetReadingProcessor
org.apache.nifi.processors.tests.system.ClassloaderIsolationWithServiceProperty
org.apache.nifi.processors.tests.system.CountEvents
org.apache.nifi.processors.tests.system.CountFlowFiles
diff --git a/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/NiFiClientUtil.java b/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/NiFiClientUtil.java
index 3cb5d07dbdd2..0deeb16f0d09 100644
--- a/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/NiFiClientUtil.java
+++ b/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/NiFiClientUtil.java
@@ -16,14 +16,19 @@
*/
package org.apache.nifi.tests.system;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.nifi.cluster.coordination.node.NodeConnectionState;
import org.apache.nifi.components.connector.ConnectorState;
import org.apache.nifi.controller.AbstractPort;
import org.apache.nifi.controller.queue.LoadBalanceCompression;
import org.apache.nifi.controller.queue.LoadBalanceStrategy;
import org.apache.nifi.controller.queue.QueueSize;
+import org.apache.nifi.flow.VersionedNodeState;
+import org.apache.nifi.flow.VersionedProcessor;
import org.apache.nifi.parameter.ParameterProviderConfiguration;
import org.apache.nifi.provenance.search.SearchableField;
+import org.apache.nifi.registry.flow.RegisteredFlowSnapshot;
import org.apache.nifi.remote.protocol.SiteToSiteTransportProtocol;
import org.apache.nifi.scheduling.ExecutionNode;
import org.apache.nifi.stream.io.StreamUtils;
@@ -49,6 +54,8 @@
import org.apache.nifi.web.api.dto.FlowFileSummaryDTO;
import org.apache.nifi.web.api.dto.FlowRegistryClientDTO;
import org.apache.nifi.web.api.dto.FlowSnippetDTO;
+import org.apache.nifi.web.api.dto.MigrationRequestDTO;
+import org.apache.nifi.web.api.dto.MigrationRequestLocalSourceDTO;
import org.apache.nifi.web.api.dto.NodeDTO;
import org.apache.nifi.web.api.dto.ParameterContextDTO;
import org.apache.nifi.web.api.dto.ParameterContextReferenceDTO;
@@ -95,6 +102,8 @@
import org.apache.nifi.web.api.entity.FlowFileEntity;
import org.apache.nifi.web.api.entity.FlowRegistryClientEntity;
import org.apache.nifi.web.api.entity.ListingRequestEntity;
+import org.apache.nifi.web.api.entity.MigrationPayloadEntity;
+import org.apache.nifi.web.api.entity.MigrationRequestEntity;
import org.apache.nifi.web.api.entity.NodeEntity;
import org.apache.nifi.web.api.entity.ParameterContextEntity;
import org.apache.nifi.web.api.entity.ParameterContextReferenceEntity;
@@ -126,12 +135,14 @@
import org.apache.nifi.web.api.entity.VerifyConfigRequestEntity;
import org.apache.nifi.web.api.entity.VerifyConnectorConfigStepRequestEntity;
import org.apache.nifi.web.api.entity.VersionControlInformationEntity;
+import org.apache.nifi.web.api.entity.VersionedFlowMigrationSourcesEntity;
import org.apache.nifi.web.api.entity.VersionedFlowUpdateRequestEntity;
import org.junit.jupiter.api.Assertions;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.ByteArrayOutputStream;
+import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
@@ -336,6 +347,124 @@ public ConnectorValueReferenceDTO createSecretValueReference(final String secret
return valueRef;
}
+ public VersionedFlowMigrationSourcesEntity listMigrationSources(final String connectorId) throws NiFiClientException, IOException {
+ return getConnectorClient().listMigrationSources(connectorId);
+ }
+
+ public MigrationPayloadEntity uploadMigrationPayload(final String connectorId, final File file) throws NiFiClientException, IOException {
+ return getConnectorClient().uploadMigrationPayload(connectorId, file);
+ }
+
+ public MigrationRequestEntity startMigrationFromLocalSource(final String connectorId, final String processGroupId) throws NiFiClientException, IOException {
+ final MigrationRequestLocalSourceDTO localSource = new MigrationRequestLocalSourceDTO();
+ localSource.setProcessGroupId(processGroupId);
+
+ final MigrationRequestDTO requestDto = new MigrationRequestDTO();
+ requestDto.setConnectorId(connectorId);
+ requestDto.setLocalSource(localSource);
+
+ final MigrationRequestEntity requestEntity = new MigrationRequestEntity();
+ requestEntity.setRequest(requestDto);
+ return getConnectorClient().startMigration(requestEntity);
+ }
+
+ public MigrationRequestEntity startMigrationFromPayload(final String connectorId, final String payloadId) throws NiFiClientException, IOException {
+ final MigrationRequestDTO requestDto = new MigrationRequestDTO();
+ requestDto.setConnectorId(connectorId);
+ requestDto.setPayloadId(payloadId);
+
+ final MigrationRequestEntity requestEntity = new MigrationRequestEntity();
+ requestEntity.setRequest(requestDto);
+ return getConnectorClient().startMigration(requestEntity);
+ }
+
+ private static final long MIGRATION_WAIT_TIMEOUT_MILLIS = 60_000L;
+
+ public MigrationRequestEntity waitForMigrationToComplete(final String connectorId, final String requestId) throws NiFiClientException, IOException, InterruptedException {
+ final long deadlineMillis = System.currentTimeMillis() + MIGRATION_WAIT_TIMEOUT_MILLIS;
+ int iteration = 0;
+ while (true) {
+ final MigrationRequestEntity requestEntity = getConnectorClient().getMigrationStatus(connectorId, requestId);
+ final MigrationRequestDTO request = requestEntity.getRequest();
+ if (request.isComplete()) {
+ return requestEntity;
+ }
+
+ if (System.currentTimeMillis() >= deadlineMillis) {
+ throw new IllegalStateException("Migration request " + requestId + " for Connector " + connectorId
+ + " did not complete within " + MIGRATION_WAIT_TIMEOUT_MILLIS + "ms; last state " + request.getState()
+ + " (" + request.getPercentCompleted() + "% complete)");
+ }
+
+ if (iteration++ % 30 == 0) {
+ logger.info("Migration request {} for Connector {} is in state {} ({}% complete)",
+ requestId, connectorId, request.getState(), request.getPercentCompleted());
+ }
+
+ Thread.sleep(100L);
+ }
+ }
+
+ public MigrationRequestEntity waitForMigrationSuccess(final String connectorId, final String requestId) throws NiFiClientException, IOException, InterruptedException {
+ final MigrationRequestEntity completedRequest = waitForMigrationToComplete(connectorId, requestId);
+ final String failureReason = completedRequest.getRequest().getFailureReason();
+ if (failureReason != null) {
+ throw new IllegalStateException("Migration failed: " + failureReason);
+ }
+
+ return completedRequest;
+ }
+
+ public MigrationRequestEntity waitForMigrationFailure(final String connectorId, final String requestId) throws NiFiClientException, IOException, InterruptedException {
+ final MigrationRequestEntity completedRequest = waitForMigrationToComplete(connectorId, requestId);
+ if (completedRequest.getRequest().getFailureReason() == null) {
+ throw new IllegalStateException("Expected migration failure but request completed successfully");
+ }
+
+ return completedRequest;
+ }
+
+ public MigrationRequestEntity cancelMigration(final String connectorId, final String requestId) throws NiFiClientException, IOException {
+ return getConnectorClient().cancelMigration(connectorId, requestId);
+ }
+
+ private static final ObjectMapper MIGRATION_PAYLOAD_OBJECT_MAPPER =
+ new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
+
+ /**
+ * Reads an exported flow snapshot, expands the LOCAL component state of the processor whose type ends
+ * with the given suffix to the requested number of node states, and writes the modified snapshot back
+ * to the same file. Used by clustered migration tests that need to construct a payload whose
+ * cluster-topology rule violation is unambiguous.
+ *
+ * @param exportFile the file containing a previously-exported {@link RegisteredFlowSnapshot}
+ * @param processorTypeSuffix a suffix that uniquely identifies the stateful processor inside the snapshot
+ * @param nodeStateValues the count values to assign to each synthetic node state, one per source node
+ * @throws IOException when the file cannot be read or written
+ * @throws IllegalStateException when no processor matches the given suffix or has no exported state
+ */
+ public void rewriteMigrationPayloadWithNodeStates(final File exportFile, final String processorTypeSuffix, final List nodeStateValues) throws IOException {
+ final RegisteredFlowSnapshot flowSnapshot = MIGRATION_PAYLOAD_OBJECT_MAPPER.readValue(exportFile, RegisteredFlowSnapshot.class);
+ final VersionedProcessor statefulProcessor = flowSnapshot.getFlowContents().getProcessors().stream()
+ .filter(processor -> processor.getType().endsWith(processorTypeSuffix))
+ .findFirst()
+ .orElseThrow(() -> new IllegalStateException("No processor in snapshot ends with type " + processorTypeSuffix));
+ if (statefulProcessor.getComponentState() == null) {
+ throw new IllegalStateException("Processor " + statefulProcessor.getName() + " has no exported component state");
+ }
+
+ final List nodeStates = new ArrayList<>();
+ for (final String value : nodeStateValues) {
+ nodeStates.add(new VersionedNodeState(Map.of("count", value)));
+ }
+ statefulProcessor.getComponentState().setLocalNodeStates(nodeStates);
+
+ if (!exportFile.delete() && exportFile.exists()) {
+ throw new IOException("Could not delete prior export file " + exportFile.getAbsolutePath() + " before writing the modified payload");
+ }
+ MIGRATION_PAYLOAD_OBJECT_MAPPER.writeValue(exportFile, flowSnapshot);
+ }
+
public ConfigurationStepEntity configureConnectorWithReferences(final String connectorId, final String configurationStepName,
final Map propertyValues) throws NiFiClientException, IOException {
final ConnectorEntity connectorEntity = getConnectorClient().getConnector(connectorId);
diff --git a/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/connectors/AbstractConnectorVersionedFlowMigrationIT.java b/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/connectors/AbstractConnectorVersionedFlowMigrationIT.java
new file mode 100644
index 000000000000..8dd0b4ebc447
--- /dev/null
+++ b/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/connectors/AbstractConnectorVersionedFlowMigrationIT.java
@@ -0,0 +1,444 @@
+/*
+ * 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.connectors;
+
+import org.apache.nifi.tests.system.NiFiSystemIT;
+import org.apache.nifi.toolkit.client.ConnectorClient;
+import org.apache.nifi.toolkit.client.NiFiClientException;
+import org.apache.nifi.web.api.dto.ConfigVerificationResultDTO;
+import org.apache.nifi.web.api.dto.ConfigurationStepConfigurationDTO;
+import org.apache.nifi.web.api.dto.ConnectorActionDTO;
+import org.apache.nifi.web.api.dto.ConnectorConfigurationDTO;
+import org.apache.nifi.web.api.dto.ConnectorValueReferenceDTO;
+import org.apache.nifi.web.api.dto.PropertyGroupConfigurationDTO;
+import org.apache.nifi.web.api.dto.VersionedFlowMigrationSourceDTO;
+import org.apache.nifi.web.api.entity.AssetEntity;
+import org.apache.nifi.web.api.entity.AssetsEntity;
+import org.apache.nifi.web.api.entity.ComponentStateEntity;
+import org.apache.nifi.web.api.entity.ConnectionEntity;
+import org.apache.nifi.web.api.entity.ConnectorEntity;
+import org.apache.nifi.web.api.entity.FlowRegistryClientEntity;
+import org.apache.nifi.web.api.entity.MigrationRequestEntity;
+import org.apache.nifi.web.api.entity.ParameterContextEntity;
+import org.apache.nifi.web.api.entity.ParameterContextUpdateRequestEntity;
+import org.apache.nifi.web.api.entity.ProcessGroupEntity;
+import org.apache.nifi.web.api.entity.ProcessGroupFlowEntity;
+import org.apache.nifi.web.api.entity.ProcessorEntity;
+import org.apache.nifi.web.api.entity.VersionedFlowMigrationSourcesEntity;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Shared infrastructure for the Versioned-Process-Group-to-Connector migration system tests. It builds the modeled
+ * source flow ({@code GenerateFlowFile} -> {@code StatefulCountProcessor} -> {@code AssetReadingProcessor}), runs it,
+ * and exposes assertions used by the concrete migration tests. It intentionally declares no {@code @Test} methods; the
+ * end-to-end migration scenario is exposed through {@link #verifyMigrationFromVersionedFlow(File)} so that the
+ * single-node and clustered tests can each run it against their own topology without inheriting one another's tests.
+ */
+public abstract class AbstractConnectorVersionedFlowMigrationIT extends NiFiSystemIT {
+ protected static final String TEST_BUCKET = "test-flows";
+ protected static final String MIGRATABLE_FLOW_NAME = "Asset Ingest Flow";
+ protected static final File SAMPLE_ASSET_FILE = new File("src/test/resources/sample-assets/helloworld.txt");
+
+ protected static final String ASSET_PARAMETER_NAME = "Asset File";
+ protected static final String GENERATE_SCHEDULE = "1 sec";
+ protected static final String FLOW_CONFIGURATION_STEP = "Flow Configuration";
+
+ protected static final String GENERATE_TYPE = "GenerateFlowFile";
+ protected static final String COUNT_TYPE = "StatefulCountProcessor";
+ protected static final String ASSET_READER_TYPE = "AssetReadingProcessor";
+
+ protected static final String MIGRATE_ACTION_NAME = "MIGRATE";
+
+ /**
+ * Runs the full end-to-end migration of the modeled versioned flow into a Connector and asserts that configuration,
+ * assets, and component state were copied, that the source Process Group was renamed and disabled, that the
+ * Connector is valid and passes configuration verification, and that running the migrated Connector reproduces the
+ * source behavior by reading the migrated asset and writing it to {@code outputFile}. The output file is supplied by
+ * the caller so that the single-node and clustered tests write to distinct locations.
+ */
+ protected void verifyMigrationFromVersionedFlow(final File outputFile) throws Exception {
+ deleteFile(outputFile);
+
+ final FlowRegistryClientEntity registryClient = registerClient();
+ final SourceFixture sourceFixture = createSourceFixture(MIGRATABLE_FLOW_NAME, registryClient, true, outputFile, true);
+ prepareSourceForMigration(sourceFixture, outputFile);
+
+ final ConnectorEntity connector = getClientUtil().createConnector("MigrationTargetConnector");
+ final String connectorId = connector.getId();
+ final VersionedFlowMigrationSourcesEntity sourcesEntity = getClientUtil().listMigrationSources(connectorId);
+ assertTrue(isSourceListed(sourcesEntity, sourceFixture.processGroup().getId()));
+
+ // Before migrating, the fresh Connector is still at its initial flow, so MIGRATE must be an available action.
+ assertMigrateActionAllowed(connectorId);
+
+ // Remove the file the source produced so that, once the migrated connector runs, its recreation proves the
+ // migrated flow reads the same asset and writes it to the same output file.
+ deleteFile(outputFile);
+ migrateFromLocalSource(connectorId, sourceFixture.processGroup().getId());
+ waitForClusterToStabilizeAfterMigration();
+
+ assertSourceRenamedAndDisabled(sourceFixture, MIGRATABLE_FLOW_NAME);
+
+ // Migration modifies the Connector's configuration away from its initial flow, so MIGRATE must no longer be
+ // offered as an available action.
+ assertMigrateActionDisallowed(connectorId);
+
+ final ConnectorClient connectorClient = getNifiClient().getConnectorClient();
+ final AssetsEntity connectorAssets = connectorClient.getAssets(connectorId);
+ assertNotNull(connectorAssets.getAssets());
+ assertFalse(connectorAssets.getAssets().isEmpty());
+ assertMigratedAssetReferenceRecorded(connectorId);
+
+ final String managedGroupId = connectorClient.getConnector(connectorId).getComponent().getManagedProcessGroupId();
+ assertEquals(outputFile.getAbsolutePath(), getManagedProcessorProperty(connectorId, managedGroupId, ASSET_READER_TYPE, "Output File"));
+ assertEquals(GENERATE_SCHEDULE, getManagedProcessorSchedule(connectorId, managedGroupId, GENERATE_TYPE));
+
+ assertMigratedState(connectorId, managedGroupId, GENERATE_TYPE, false);
+ assertMigratedState(connectorId, managedGroupId, COUNT_TYPE, true);
+
+ getClientUtil().waitForValidConnector(connectorId);
+ assertConfigurationVerified(connectorId);
+
+ getClientUtil().startConnector(connectorId);
+ waitFor(() -> outputFile.exists() && outputFile.length() > 0);
+ assertEquals(Files.readString(SAMPLE_ASSET_FILE.toPath()).trim(), Files.readString(outputFile.toPath()).trim());
+ }
+
+ protected SourceFixture createSourceFixture(final String flowName, final FlowRegistryClientEntity registryClient, final boolean includeAsset, final File outputFile,
+ final boolean versionControlled) throws Exception {
+ final ProcessGroupEntity processGroup = getClientUtil().createProcessGroup(flowName, "root");
+
+ final String sourceFilePropertyValue;
+ if (includeAsset) {
+ final ParameterContextEntity assetContext = getClientUtil().createParameterContext(flowName + "-asset", Map.of());
+ final AssetEntity assetEntity = getNifiClient().getParamContextClient().createAsset(assetContext.getId(), SAMPLE_ASSET_FILE.getName(), SAMPLE_ASSET_FILE);
+ final Map> assetReferences = Map.of(ASSET_PARAMETER_NAME, List.of(assetEntity.getAsset().getId()));
+ final ParameterContextUpdateRequestEntity requestEntity = getClientUtil().updateParameterAssetReferences(assetContext, assetReferences);
+ getClientUtil().waitForParameterContextRequestToComplete(assetContext.getId(), requestEntity.getRequest().getRequestId());
+ getClientUtil().setParameterContext(processGroup.getId(), assetContext);
+ sourceFilePropertyValue = "#{" + ASSET_PARAMETER_NAME + "}";
+ } else {
+ sourceFilePropertyValue = SAMPLE_ASSET_FILE.getAbsolutePath();
+ }
+
+ final ProcessorEntity createdGenerate = getClientUtil().createProcessor(GENERATE_TYPE, processGroup.getId());
+ final ProcessorEntity generate = getClientUtil().updateProcessorProperties(createdGenerate, Map.of("Max FlowFiles", "1", "File Size", "0 B"));
+ getClientUtil().updateProcessorSchedulingPeriod(generate, GENERATE_SCHEDULE);
+ final ProcessorEntity count = getClientUtil().createProcessor(COUNT_TYPE, processGroup.getId());
+ final ProcessorEntity assetReader = getClientUtil().updateProcessorProperties(getClientUtil().createProcessor(ASSET_READER_TYPE, processGroup.getId()),
+ Map.of("Source File", sourceFilePropertyValue, "Output File", outputFile.getAbsolutePath()));
+ getClientUtil().setAutoTerminatedRelationships(assetReader, Set.of("success", "failure"));
+
+ final ConnectionEntity sourceConnection = getClientUtil().createConnection(generate, count, "success");
+ getClientUtil().createConnection(count, assetReader, "success");
+
+ if (versionControlled) {
+ assertNotNull(getClientUtil().startVersionControl(processGroup, registryClient, TEST_BUCKET, flowName));
+ }
+
+ return new SourceFixture(processGroup, sourceConnection);
+ }
+
+ protected void prepareSourceForMigration(final SourceFixture sourceFixture, final File outputFile) throws Exception {
+ // Run the source so its processors accumulate component state, then stop and drain it so the group is eligible
+ // for migration (no running processors, no queued FlowFiles). The accumulated state is what migration copies.
+ runSource(sourceFixture, outputFile);
+ getClientUtil().assertFlowUpToDate(sourceFixture.processGroup().getId());
+ }
+
+ protected void runSource(final SourceFixture sourceFixture, final File outputFile) throws Exception {
+ getClientUtil().startProcessGroupComponents(sourceFixture.processGroup().getId());
+ waitFor(() -> outputFile.exists() && outputFile.length() > 0);
+ getClientUtil().stopProcessGroupComponents(sourceFixture.processGroup().getId());
+ drainAllQueues(sourceFixture.processGroup().getId());
+ }
+
+ protected void migrateFromLocalSource(final String connectorId, final String processGroupId) throws Exception {
+ final MigrationRequestEntity requestEntity = getClientUtil().startMigrationFromLocalSource(connectorId, processGroupId);
+ getClientUtil().waitForMigrationSuccess(connectorId, requestEntity.getRequest().getRequestId());
+ }
+
+ /**
+ * Waits for the cluster to hold full connectivity continuously for several seconds. A connector migration is
+ * applied independently on each node; when it succeeds on some nodes but fails on others, the nodes end up with
+ * divergent revision-update counts and the coordinator forces the divergent node to reconnect on a subsequent
+ * heartbeat. That reconnect can be requested after the migration request has already reported completion, so a
+ * later flow-mutating request (configuration verification, starting the connector, or the next test's setup) can
+ * race the reconnect and be rejected while a node is connecting. Requiring sustained full connectivity here
+ * ensures any such reconnect has been observed and completed before the test proceeds. This is a no-op on a
+ * single-node instance.
+ */
+ protected void waitForClusterToStabilizeAfterMigration() {
+ if (!getNiFiInstance().isClustered()) {
+ return;
+ }
+
+ final int expectedNodeCount = getNumberOfNodes(true);
+ final long stabilityWindowMillis = TimeUnit.SECONDS.toMillis(5);
+ final long deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(120);
+
+ while (System.currentTimeMillis() < deadline) {
+ waitForAllNodesConnected(expectedNodeCount);
+
+ final long windowEnd = System.currentTimeMillis() + stabilityWindowMillis;
+ boolean stable = true;
+ while (System.currentTimeMillis() < windowEnd) {
+ try {
+ Thread.sleep(500L);
+ } catch (final InterruptedException ie) {
+ Thread.currentThread().interrupt();
+ return;
+ }
+
+ int connectedNodeCount = -1;
+ try {
+ connectedNodeCount = getNifiClient().getFlowClient().getClusterSummary().getClusterSummary().getConnectedNodeCount();
+ } catch (final Exception ignored) {
+ }
+
+ if (connectedNodeCount != expectedNodeCount) {
+ stable = false;
+ break;
+ }
+ }
+
+ if (stable) {
+ return;
+ }
+ }
+
+ throw new IllegalStateException("Cluster did not remain fully connected for " + stabilityWindowMillis + " ms within the allotted time after migration");
+ }
+
+ protected void drainAllQueues(final String processGroupId) throws NiFiClientException, IOException {
+ final ProcessGroupFlowEntity flowEntity = getNifiClient().getFlowClient().getProcessGroup(processGroupId);
+ for (final ConnectionEntity connection : flowEntity.getProcessGroupFlow().getFlow().getConnections()) {
+ getClientUtil().emptyQueue(connection.getId());
+ }
+ }
+
+ protected void exportSource(final String processGroupId, final File exportFile) throws NiFiClientException, IOException {
+ getNifiClient().getProcessGroupClient().exportProcessGroup(processGroupId, true, true, exportFile);
+ }
+
+ protected void deleteFile(final File file) {
+ assertTrue(file.delete() || !file.exists(), "Failed to delete file " + file.getAbsolutePath());
+ }
+
+ protected void assertSourceRenamedAndDisabled(final SourceFixture sourceFixture, final String originalName) throws NiFiClientException, IOException {
+ final ProcessGroupEntity migratedSourceGroup = getNifiClient().getProcessGroupClient().getProcessGroup(sourceFixture.processGroup().getId());
+ assertEquals("(Migrated) " + originalName, migratedSourceGroup.getComponent().getName());
+
+ final ProcessGroupFlowEntity migratedSourceFlow = getNifiClient().getFlowClient().getProcessGroup(sourceFixture.processGroup().getId());
+ for (final ProcessorEntity sourceProcessor : migratedSourceFlow.getProcessGroupFlow().getFlow().getProcessors()) {
+ assertEquals("DISABLED", sourceProcessor.getComponent().getState(),
+ "Migrated source processor " + sourceProcessor.getComponent().getName() + " must be DISABLED after successful migration");
+ }
+ }
+
+ protected void assertMigratedAssetReferenceRecorded(final String connectorId) throws NiFiClientException, IOException {
+ final ConnectorEntity connectorEntity = getNifiClient().getConnectorClient().getConnector(connectorId);
+ final ConnectorConfigurationDTO activeConfiguration = connectorEntity.getComponent().getActiveConfiguration();
+ assertNotNull(activeConfiguration);
+ assertNotNull(activeConfiguration.getConfigurationStepConfigurations());
+
+ ConnectorValueReferenceDTO assetReference = null;
+ for (final ConfigurationStepConfigurationDTO step : activeConfiguration.getConfigurationStepConfigurations()) {
+ if (!FLOW_CONFIGURATION_STEP.equals(step.getConfigurationStepName()) || step.getPropertyGroupConfigurations() == null) {
+ continue;
+ }
+
+ for (final PropertyGroupConfigurationDTO group : step.getPropertyGroupConfigurations()) {
+ if (group.getPropertyValues() != null && group.getPropertyValues().get(ASSET_PARAMETER_NAME) != null) {
+ assetReference = group.getPropertyValues().get(ASSET_PARAMETER_NAME);
+ }
+ }
+ }
+
+ assertNotNull(assetReference, "Asset File property must be recorded on the Flow Configuration step");
+ assertEquals("ASSET_REFERENCE", assetReference.getValueType());
+ assertNotNull(assetReference.getAssetReferences());
+ assertFalse(assetReference.getAssetReferences().isEmpty());
+ }
+
+ protected String getManagedProcessorProperty(final String connectorId, final String groupId, final String processorTypeSuffix, final String propertyName) throws NiFiClientException, IOException {
+ return findManagedProcessor(connectorId, groupId, processorTypeSuffix).getComponent().getConfig().getProperties().get(propertyName);
+ }
+
+ protected String getManagedProcessorSchedule(final String connectorId, final String groupId, final String processorTypeSuffix) throws NiFiClientException, IOException {
+ return findManagedProcessor(connectorId, groupId, processorTypeSuffix).getComponent().getConfig().getSchedulingPeriod();
+ }
+
+ protected String getProcessorId(final String connectorId, final String groupId, final String processorTypeSuffix) throws NiFiClientException, IOException {
+ return findManagedProcessor(connectorId, groupId, processorTypeSuffix).getId();
+ }
+
+ private ProcessorEntity findManagedProcessor(final String connectorId, final String groupId, final String processorTypeSuffix) throws NiFiClientException, IOException {
+ final ProcessGroupFlowEntity flowEntity = getNifiClient().getConnectorClient().getFlow(connectorId, groupId);
+ for (final ProcessorEntity processor : flowEntity.getProcessGroupFlow().getFlow().getProcessors()) {
+ if (processor.getComponent().getType().endsWith(processorTypeSuffix)) {
+ return processor;
+ }
+ }
+
+ throw new IllegalStateException("Could not find managed processor ending with type " + processorTypeSuffix);
+ }
+
+ protected boolean isSourceListed(final VersionedFlowMigrationSourcesEntity sourcesEntity, final String processGroupId) {
+ return findListedSource(sourcesEntity, processGroupId) != null;
+ }
+
+ protected VersionedFlowMigrationSourceDTO findListedSource(final VersionedFlowMigrationSourcesEntity sourcesEntity, final String processGroupId) {
+ if (sourcesEntity.getMigrationSources() == null) {
+ return null;
+ }
+
+ for (final VersionedFlowMigrationSourceDTO migrationSource : sourcesEntity.getMigrationSources()) {
+ if (processGroupId.equals(migrationSource.getProcessGroupId())) {
+ return migrationSource;
+ }
+ }
+
+ return null;
+ }
+
+ protected ProcessorEntity findCanvasProcessor(final String processGroupId, final String processorTypeSuffix) throws NiFiClientException, IOException {
+ final ProcessGroupFlowEntity flowEntity = getNifiClient().getFlowClient().getProcessGroup(processGroupId);
+ for (final ProcessorEntity processor : flowEntity.getProcessGroupFlow().getFlow().getProcessors()) {
+ if (processor.getComponent().getType().endsWith(processorTypeSuffix)) {
+ return getNifiClient().getProcessorClient().getProcessor(processor.getId());
+ }
+ }
+
+ throw new IllegalStateException("Could not find processor with type ending in " + processorTypeSuffix + " in Process Group " + processGroupId);
+ }
+
+ protected void assertMigratedState(final String connectorId, final String managedGroupId, final String processorTypeSuffix, final boolean expectClusterState) throws Exception {
+ final String processorId = getProcessorId(connectorId, managedGroupId, processorTypeSuffix);
+ if (!getNiFiInstance().isClustered()) {
+ assertLocalStatePresent(getNifiClient().getConnectorClient().getProcessorState(connectorId, processorId));
+ return;
+ }
+
+ for (int nodeIndex = 1; nodeIndex <= getNumberOfNodes(); nodeIndex++) {
+ switchClientToNode(nodeIndex);
+ final ComponentStateEntity stateEntity = getNifiClient().getConnectorClient(DO_NOT_REPLICATE).getProcessorState(connectorId, processorId);
+ assertLocalStatePresent(stateEntity);
+ if (expectClusterState) {
+ assertNotNull(stateEntity.getComponentState().getClusterState());
+ assertFalse(stateEntity.getComponentState().getClusterState().getState().isEmpty());
+ }
+ }
+ }
+
+ private void assertLocalStatePresent(final ComponentStateEntity stateEntity) {
+ assertNotNull(stateEntity.getComponentState());
+ assertNotNull(stateEntity.getComponentState().getLocalState());
+ assertFalse(stateEntity.getComponentState().getLocalState().getState().isEmpty());
+ }
+
+ protected void assertConfigurationVerified(final String connectorId) throws Exception {
+ final List results = getClientUtil().verifyConnectorStepConfig(connectorId, FLOW_CONFIGURATION_STEP, Map.of());
+ assertFalse(results.isEmpty());
+ for (final ConfigVerificationResultDTO result : results) {
+ assertEquals("SUCCESSFUL", result.getOutcome(), "Configuration verification result was not successful: " + result.getExplanation());
+ }
+ }
+
+ protected void assertConnectorFresh(final String connectorId) throws NiFiClientException, IOException {
+ final ConnectorClient connectorClient = getNifiClient().getConnectorClient();
+ final String managedGroupId = connectorClient.getConnector(connectorId).getComponent().getManagedProcessGroupId();
+ final ProcessGroupFlowEntity flowEntity = connectorClient.getFlow(connectorId, managedGroupId);
+ assertTrue(flowEntity.getProcessGroupFlow().getFlow().getProcessors() == null || flowEntity.getProcessGroupFlow().getFlow().getProcessors().isEmpty());
+ assertTrue(flowEntity.getProcessGroupFlow().getFlow().getConnections() == null || flowEntity.getProcessGroupFlow().getFlow().getConnections().isEmpty());
+
+ final AssetsEntity assetsEntity = connectorClient.getAssets(connectorId);
+ assertTrue(assetsEntity.getAssets() == null || assetsEntity.getAssets().isEmpty());
+ }
+
+ protected void assertMigrateActionAllowed(final String connectorId) throws NiFiClientException, IOException {
+ final ConnectorActionDTO migrateAction = findMigrateAction(getNifiClient().getConnectorClient().getConnector(connectorId));
+ assertNotNull(migrateAction, "MIGRATE must be an available action on a Connector that has not yet been migrated");
+ assertEquals(Boolean.TRUE, migrateAction.getAllowed(),
+ "MIGRATE must be allowed before migration while the Connector's flow still matches its initial flow; reason: " + migrateAction.getReasonNotAllowed());
+ }
+
+ protected void assertMigrateActionDisallowed(final String connectorId) throws NiFiClientException, IOException {
+ final ConnectorActionDTO migrateAction = findMigrateAction(getNifiClient().getConnectorClient().getConnector(connectorId));
+ assertNotNull(migrateAction, "MIGRATE must remain present after migration so its reasonNotAllowed can explain why it is unavailable");
+ assertEquals(Boolean.FALSE, migrateAction.getAllowed(),
+ "MIGRATE must be disallowed after migration because the Connector's configuration has been modified from its initial flow");
+ assertNotNull(migrateAction.getReasonNotAllowed(), "MIGRATE must report a reasonNotAllowed once it is disallowed");
+ assertTrue(migrateAction.getReasonNotAllowed().toLowerCase().contains("modified"),
+ "reasonNotAllowed must explain that the Connector has been modified; got: " + migrateAction.getReasonNotAllowed());
+ }
+
+ protected static ConnectorActionDTO findMigrateAction(final ConnectorEntity connector) {
+ final List actions = connector.getComponent() == null ? null : connector.getComponent().getAvailableActions();
+ if (actions == null) {
+ return null;
+ }
+
+ for (final ConnectorActionDTO action : actions) {
+ if (MIGRATE_ACTION_NAME.equals(action.getName())) {
+ return action;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Asserts that the source process group has been left in the same state it was in before a failed migration attempt:
+ * its original name, its original version-control state, its originally-applied parameter context, and processors
+ * that remain STOPPED.
+ */
+ protected void assertSourceUntouched(final SourceFixture sourceFixture, final String expectedName) throws NiFiClientException, IOException {
+ final ProcessGroupEntity processGroupEntity = getNifiClient().getProcessGroupClient().getProcessGroup(sourceFixture.processGroup().getId());
+ assertEquals(expectedName, processGroupEntity.getComponent().getName());
+
+ assertNotNull(processGroupEntity.getComponent().getVersionControlInformation(), "Source process group must remain under version control after a failed migration attempt");
+ getClientUtil().assertFlowUpToDate(sourceFixture.processGroup().getId());
+
+ final ProcessGroupEntity originalProcessGroupEntity = sourceFixture.processGroup();
+ if (originalProcessGroupEntity.getComponent().getParameterContext() != null) {
+ assertNotNull(processGroupEntity.getComponent().getParameterContext(), "Source process group must retain its parameter context after a failed migration attempt");
+ assertEquals(originalProcessGroupEntity.getComponent().getParameterContext().getId(), processGroupEntity.getComponent().getParameterContext().getId());
+ }
+
+ final ProcessGroupFlowEntity flowEntity = getNifiClient().getFlowClient().getProcessGroup(sourceFixture.processGroup().getId());
+ for (final ProcessorEntity sourceProcessor : flowEntity.getProcessGroupFlow().getFlow().getProcessors()) {
+ assertEquals("STOPPED", sourceProcessor.getComponent().getState(),
+ "Source processor " + sourceProcessor.getComponent().getName() + " must remain STOPPED after a failed migration attempt");
+ }
+ }
+
+ protected record SourceFixture(ProcessGroupEntity processGroup, ConnectionEntity sourceConnection) {
+ }
+}
diff --git a/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/connectors/ClusteredConnectorVersionedFlowMigrationIT.java b/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/connectors/ClusteredConnectorVersionedFlowMigrationIT.java
new file mode 100644
index 000000000000..152213ed1ea0
--- /dev/null
+++ b/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/connectors/ClusteredConnectorVersionedFlowMigrationIT.java
@@ -0,0 +1,100 @@
+/*
+ * 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.connectors;
+
+import org.apache.nifi.tests.system.NiFiInstanceFactory;
+import org.apache.nifi.web.api.entity.ConnectorEntity;
+import org.apache.nifi.web.api.entity.FlowRegistryClientEntity;
+import org.apache.nifi.web.api.entity.MigrationPayloadEntity;
+import org.apache.nifi.web.api.entity.MigrationRequestEntity;
+import org.junit.jupiter.api.Test;
+
+import java.io.File;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Two-node clustered system tests for migrating a versioned Process Group into a Connector. Beyond confirming that the
+ * end-to-end migration works on a cluster, this covers the cluster-specific concerns: rejecting a payload whose LOCAL
+ * state carries more node states than the destination cluster has connected nodes, and surfacing a per-node migration
+ * failure in the merged cluster response.
+ */
+public class ClusteredConnectorVersionedFlowMigrationIT extends AbstractConnectorVersionedFlowMigrationIT {
+ @Override
+ public NiFiInstanceFactory getInstanceFactory() {
+ return createTwoNodeInstanceFactory();
+ }
+
+ @Test
+ public void testMigrateConnectorFromVersionedFlow() throws Exception {
+ verifyMigrationFromVersionedFlow(new File("target/migration/cluster-output.txt"));
+ }
+
+ @Test
+ public void testUploadedPayloadWithTooManyLocalNodeStatesFails() throws Exception {
+ final File outputFile = new File("target/migration/cluster-topology-output.txt");
+ final File exportFile = new File("target/migration/cluster-topology-export.json");
+ deleteFile(outputFile);
+ deleteFile(exportFile);
+
+ final SourceFixture sourceFixture = createSourceFixture(MIGRATABLE_FLOW_NAME, registerClient(), false, outputFile, false);
+ runSource(sourceFixture, outputFile);
+ exportSource(sourceFixture.processGroup().getId(), exportFile);
+
+ // Inflate the LOCAL state to three node states even though the destination cluster only has two
+ // connected nodes; the cluster-topology rule must reject the payload.
+ getClientUtil().rewriteMigrationPayloadWithNodeStates(exportFile, "StatefulCountProcessor", List.of("1", "2", "3"));
+
+ final ConnectorEntity connector = getClientUtil().createConnector("MigrationTargetConnector");
+ final MigrationPayloadEntity payloadEntity = getClientUtil().uploadMigrationPayload(connector.getId(), exportFile);
+ final MigrationRequestEntity requestEntity = getClientUtil().startMigrationFromPayload(connector.getId(), payloadEntity.getPayload().getPayloadId());
+ final MigrationRequestEntity completedRequest = getClientUtil().waitForMigrationFailure(connector.getId(), requestEntity.getRequest().getRequestId());
+ assertTrue(completedRequest.getRequest().getFailureReason().contains("connected node"));
+
+ assertConnectorFresh(connector.getId());
+ }
+
+ @Test
+ public void testAsymmetricPerNodeMigrationFailureSurfacesInMergedResponse() throws Exception {
+ final File outputFile = new File("target/migration/asymmetric-failure-output.txt");
+ deleteFile(outputFile);
+
+ final FlowRegistryClientEntity registryClient = registerClient();
+ final SourceFixture sourceFixture = createSourceFixture("AsymmetricFailureSource", registryClient, false, outputFile, true);
+ prepareSourceForMigration(sourceFixture, outputFile);
+
+ // AsymmetricFailureMigrationConnector throws on node 2 only; node 1 succeeds. The merged response
+ // returned by the migration-request endpoint must surface node 2's failure, otherwise an operator polling
+ // the cluster would mistakenly see success.
+ final ConnectorEntity connector = getClientUtil().createConnector("AsymmetricFailureMigrationConnector");
+ final String connectorId = connector.getId();
+
+ final MigrationRequestEntity requestEntity = getClientUtil().startMigrationFromLocalSource(connectorId, sourceFixture.processGroup().getId());
+ final MigrationRequestEntity completedRequest = getClientUtil().waitForMigrationFailure(connectorId, requestEntity.getRequest().getRequestId());
+
+ final String failureReason = completedRequest.getRequest().getFailureReason();
+ assertNotNull(failureReason);
+ assertTrue(failureReason.contains("node 2"), failureReason);
+
+ // Node 1 committed the migration while node 2 failed, so their revision-update counts diverge and the
+ // coordinator will force the divergent node to reconnect on a subsequent heartbeat. Wait for that reconnect
+ // to occur and complete so it does not race this test's teardown or the next test's flow-mutating requests.
+ waitForClusterToStabilizeAfterMigration();
+ }
+}
diff --git a/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/connectors/ConnectorVersionedFlowMigrationIT.java b/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/connectors/ConnectorVersionedFlowMigrationIT.java
new file mode 100644
index 000000000000..4b89be04acb8
--- /dev/null
+++ b/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/connectors/ConnectorVersionedFlowMigrationIT.java
@@ -0,0 +1,201 @@
+/*
+ * 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.connectors;
+
+import org.apache.nifi.toolkit.client.NiFiClientException;
+import org.apache.nifi.web.api.dto.VersionedFlowMigrationSourceDTO;
+import org.apache.nifi.web.api.entity.ConnectorEntity;
+import org.apache.nifi.web.api.entity.FlowRegistryClientEntity;
+import org.apache.nifi.web.api.entity.MigrationPayloadEntity;
+import org.apache.nifi.web.api.entity.MigrationRequestEntity;
+import org.apache.nifi.web.api.entity.ProcessGroupEntity;
+import org.apache.nifi.web.api.entity.ProcessorEntity;
+import org.apache.nifi.web.api.entity.VersionedFlowMigrationSourcesEntity;
+import org.junit.jupiter.api.Test;
+
+import java.io.File;
+import java.nio.file.Files;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Single-node system tests for migrating a versioned Process Group into a Connector. This covers the end-to-end happy
+ * path, migration-failure rollback, migration-source eligibility listing, and migration from an uploaded payload.
+ * Tests that require restarting NiFi live in {@link ConnectorVersionedFlowMigrationRestartIT}, and the clustered
+ * variants live in {@link ClusteredConnectorVersionedFlowMigrationIT}.
+ */
+public class ConnectorVersionedFlowMigrationIT extends AbstractConnectorVersionedFlowMigrationIT {
+ private static final String UNSUPPORTED_FLOW_NAME = "Unsupported Ingest Flow";
+
+ @Test
+ public void testMigrateConnectorFromVersionedFlow() throws Exception {
+ verifyMigrationFromVersionedFlow(new File("target/migration/local-output.txt"));
+ }
+
+ @Test
+ public void testMigrationFailureRollsBackConnectorAndLeavesSourceUntouched() throws Exception {
+ final File outputFile = new File("target/migration/failure-local-output.txt");
+ deleteFile(outputFile);
+
+ final FlowRegistryClientEntity registryClient = registerClient();
+ final SourceFixture sourceFixture = createSourceFixture("FailureSource", registryClient, true, outputFile, true);
+ prepareSourceForMigration(sourceFixture, outputFile);
+
+ final String sourceGroupId = sourceFixture.processGroup().getId();
+ final String originalName = getNifiClient().getProcessGroupClient().getProcessGroup(sourceGroupId).getComponent().getName();
+
+ final ConnectorEntity connector = getClientUtil().createConnector("FailingConfigurationMigrationConnector");
+ final String connectorId = connector.getId();
+ final MigrationRequestEntity requestEntity = getClientUtil().startMigrationFromLocalSource(connectorId, sourceGroupId);
+ final MigrationRequestEntity completedRequest = getClientUtil().waitForMigrationFailure(connectorId, requestEntity.getRequest().getRequestId());
+ assertNotNull(completedRequest.getRequest().getFailureReason());
+
+ assertConnectorFresh(connectorId);
+ assertSourceUntouched(sourceFixture, originalName);
+ }
+
+ @Test
+ public void testCorruptPayloadUploadRejectedAndConnectorRemainsFresh() throws Exception {
+ final File corruptPayload = new File("target/migration/corrupt-payload.json");
+ corruptPayload.getParentFile().mkdirs();
+ Files.writeString(corruptPayload.toPath(), "{ this is not valid json");
+
+ final ConnectorEntity connector = getClientUtil().createConnector("MigrationTargetConnector");
+ assertThrows(NiFiClientException.class, () -> getClientUtil().uploadMigrationPayload(connector.getId(), corruptPayload));
+
+ assertConnectorFresh(connector.getId());
+ }
+
+ @Test
+ public void testMigrationSourcesListingFiltersSourcesWithNonMatchingFlowName() throws Exception {
+ final FlowRegistryClientEntity registryClient = registerClient();
+ final SourceFixture matchingFixture = createSourceFixture(MIGRATABLE_FLOW_NAME, registryClient, false, new File("target/migration/eligibility-matching.txt"), true);
+ final SourceFixture nonMatchingFixture = createSourceFixture(UNSUPPORTED_FLOW_NAME, registryClient, false, new File("target/migration/eligibility-non-matching.txt"), true);
+
+ final ConnectorEntity targetConnector = getClientUtil().createConnector("MigrationTargetConnector");
+ final VersionedFlowMigrationSourcesEntity sourcesEntity = getClientUtil().listMigrationSources(targetConnector.getId());
+ assertTrue(isSourceListed(sourcesEntity, matchingFixture.processGroup().getId()));
+ assertFalse(isSourceListed(sourcesEntity, nonMatchingFixture.processGroup().getId()));
+
+ final ConnectorEntity nonMigratingConnector = getClientUtil().createConnector("NonMigratingConnector");
+ final VersionedFlowMigrationSourcesEntity nonMigratingSources = getClientUtil().listMigrationSources(nonMigratingConnector.getId());
+ assertTrue(nonMigratingSources.getMigrationSources() == null || nonMigratingSources.getMigrationSources().isEmpty());
+ }
+
+ @Test
+ public void testLocalMigrationRejectsNonVersionControlledSource() throws Exception {
+ final SourceFixture sourceFixture = createSourceFixture(MIGRATABLE_FLOW_NAME, registerClient(), false, new File("target/migration/eligibility-local-failure.txt"), false);
+ final ConnectorEntity connector = getClientUtil().createConnector("MigrationTargetConnector");
+
+ assertThrows(NiFiClientException.class, () -> getClientUtil().startMigrationFromLocalSource(connector.getId(), sourceFixture.processGroup().getId()));
+ }
+
+ @Test
+ public void testMigrationSourcesListingReportsAllConcurrentIneligibilityReasons() throws Exception {
+ final FlowRegistryClientEntity registryClient = registerClient();
+ final SourceFixture sourceFixture = createSourceFixture(MIGRATABLE_FLOW_NAME, registryClient, false, new File("target/migration/eligibility-not-ready.txt"), true);
+
+ // Start only the GenerateFlowFile source so a backlog builds in the source-to-consumer connection while the
+ // downstream processors stay stopped. This puts the Process Group into a state where two remediable conditions
+ // hold at once: a running processor and queued FlowFiles.
+ final ProcessorEntity producer = findCanvasProcessor(sourceFixture.processGroup().getId(), "GenerateFlowFile");
+ getClientUtil().startProcessor(producer);
+ waitForMinQueueCount(sourceFixture.sourceConnection().getId(), 1);
+
+ try {
+ final ConnectorEntity targetConnector = getClientUtil().createConnector("MigrationTargetConnector");
+ final VersionedFlowMigrationSourcesEntity sourcesEntity = getClientUtil().listMigrationSources(targetConnector.getId());
+
+ final VersionedFlowMigrationSourceDTO listedSource = findListedSource(sourcesEntity, sourceFixture.processGroup().getId());
+ assertNotNull(listedSource, "A flow with a matching name must still be listed even when it is not currently ready for migration");
+ assertFalse(listedSource.isReadyForMigration());
+ assertEquals(sourceFixture.processGroup().getId(), listedSource.getProcessGroupId());
+ assertEquals(MIGRATABLE_FLOW_NAME, listedSource.getProcessGroupName());
+ assertNotNull(listedSource.getParentProcessGroupId(), "The parent Process Group identifier must be populated so callers know where the source lives on the canvas");
+
+ final List ineligibilityReasons = listedSource.getIneligibilityReasons();
+ assertNotNull(ineligibilityReasons);
+ assertTrue(containsSubstring(ineligibilityReasons, "Running or enabled processor"), "Ineligibility reasons must surface running processors: " + ineligibilityReasons);
+ assertTrue(containsSubstring(ineligibilityReasons, "Queued FlowFile"), "Ineligibility reasons must surface queued FlowFiles: " + ineligibilityReasons);
+ assertTrue(ineligibilityReasons.size() >= 2, "All applicable ineligibility reasons must be reported together, not just the first one. Found: " + ineligibilityReasons);
+ } finally {
+ getClientUtil().stopProcessor(producer);
+ }
+ }
+
+ @Test
+ public void testUploadedPayloadAcceptsNonVersionControlledSource() throws Exception {
+ final File outputFile = new File("target/migration/eligibility-uploaded.txt");
+ final File exportFile = new File("target/migration/eligibility-uploaded.json");
+ deleteFile(outputFile);
+ deleteFile(exportFile);
+
+ final SourceFixture sourceFixture = createSourceFixture(MIGRATABLE_FLOW_NAME, registerClient(), false, outputFile, false);
+ runSource(sourceFixture, outputFile);
+ exportSource(sourceFixture.processGroup().getId(), exportFile);
+ deleteFile(outputFile);
+
+ final ConnectorEntity connector = getClientUtil().createConnector("MigrationTargetConnector");
+ final MigrationPayloadEntity payloadEntity = getClientUtil().uploadMigrationPayload(connector.getId(), exportFile);
+ final MigrationRequestEntity requestEntity = getClientUtil().startMigrationFromPayload(connector.getId(), payloadEntity.getPayload().getPayloadId());
+ getClientUtil().waitForMigrationSuccess(connector.getId(), requestEntity.getRequest().getRequestId());
+ }
+
+ @Test
+ public void testMigrateConnectorFromUploadedPayload() throws Exception {
+ final File outputFile = new File("target/migration/uploaded-output.txt");
+ final File exportFile = new File("target/migration/uploaded-source.json");
+ deleteFile(outputFile);
+ deleteFile(exportFile);
+
+ final SourceFixture sourceFixture = createSourceFixture(MIGRATABLE_FLOW_NAME, registerClient(), false, outputFile, false);
+ runSource(sourceFixture, outputFile);
+
+ exportSource(sourceFixture.processGroup().getId(), exportFile);
+ final ProcessGroupEntity sourceProcessGroup = getNifiClient().getProcessGroupClient().getProcessGroup(sourceFixture.processGroup().getId());
+ getNifiClient().getProcessGroupClient().deleteProcessGroup(sourceProcessGroup);
+
+ deleteFile(outputFile);
+
+ final ConnectorEntity connector = getClientUtil().createConnector("MigrationTargetConnector");
+ final String connectorId = connector.getId();
+ final MigrationPayloadEntity payloadEntity = getClientUtil().uploadMigrationPayload(connectorId, exportFile);
+ assertNotNull(payloadEntity.getPayload());
+
+ final MigrationRequestEntity requestEntity = getClientUtil().startMigrationFromPayload(connectorId, payloadEntity.getPayload().getPayloadId());
+ getClientUtil().waitForMigrationSuccess(connectorId, requestEntity.getRequest().getRequestId());
+
+ getClientUtil().startConnector(connectorId);
+ waitFor(() -> outputFile.exists() && outputFile.length() > 0);
+ assertEquals(Files.readString(SAMPLE_ASSET_FILE.toPath()).trim(), Files.readString(outputFile.toPath()).trim());
+ }
+
+ private static boolean containsSubstring(final List values, final String substring) {
+ for (final String value : values) {
+ if (value.contains(substring)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/connectors/ConnectorVersionedFlowMigrationRestartIT.java b/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/connectors/ConnectorVersionedFlowMigrationRestartIT.java
new file mode 100644
index 000000000000..e659e9f2ee25
--- /dev/null
+++ b/nifi-system-tests/nifi-system-test-suite/src/test/java/org/apache/nifi/tests/system/connectors/ConnectorVersionedFlowMigrationRestartIT.java
@@ -0,0 +1,348 @@
+/*
+ * 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.connectors;
+
+import org.apache.nifi.toolkit.client.NiFiClientException;
+import org.apache.nifi.web.api.dto.ConfigurationStepConfigurationDTO;
+import org.apache.nifi.web.api.dto.ConnectorActionDTO;
+import org.apache.nifi.web.api.dto.ConnectorConfigurationDTO;
+import org.apache.nifi.web.api.dto.ConnectorValueReferenceDTO;
+import org.apache.nifi.web.api.dto.PropertyGroupConfigurationDTO;
+import org.apache.nifi.web.api.dto.StateEntryDTO;
+import org.apache.nifi.web.api.entity.ComponentStateEntity;
+import org.apache.nifi.web.api.entity.ConnectorEntity;
+import org.apache.nifi.web.api.entity.FlowRegistryClientEntity;
+import org.apache.nifi.web.api.entity.MigrationRequestEntity;
+import org.apache.nifi.web.api.entity.ProcessGroupFlowEntity;
+import org.junit.jupiter.api.Test;
+
+import java.io.File;
+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.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * System tests for the structural initial-flow check that gates the {@code MIGRATE} allowable action.
+ *
+ * Two restart-related properties must hold:
+ *
+ * - If a Connector is created and the framework is restarted before any migration is attempted, the
+ * check must still report the Connector as being at its initial flow. {@code MIGRATE} must remain
+ * allowed because both inputs to the comparison are recomputed from durable state after the restart:
+ * {@code getInitialFlow()} is rebuilt from the Connector's persisted configuration, and the current
+ * managed flow is repopulated from the same configuration.
+ * - If a Connector has been modified by a successful migration, the check must report it as modified
+ * and {@code MIGRATE} must be reported as disallowed with a reason that explains why. A second
+ * migration attempt must be rejected by the framework with the same diagnostic.
+ *
+ */
+public class ConnectorVersionedFlowMigrationRestartIT extends AbstractConnectorVersionedFlowMigrationIT {
+
+ @Override
+ protected boolean isDestroyEnvironmentAfterEachTest() {
+ return true;
+ }
+
+ @Override
+ protected boolean isAllowFactoryReuse() {
+ return false;
+ }
+
+ @Test
+ public void testMigrateActionAllowedAfterRestartWhenConnectorIsStillAtInitialFlow() throws Exception {
+ final ConnectorEntity connector = getClientUtil().createConnector("MigrationTargetConnector");
+ final String connectorId = connector.getId();
+
+ final ConnectorActionDTO migrateBeforeRestart = findMigrateAction(getNifiClient().getConnectorClient().getConnector(connectorId));
+ assertNotNull(migrateBeforeRestart, "MIGRATE action must be present on a fresh Connector");
+ assertEquals(Boolean.TRUE, migrateBeforeRestart.getAllowed(),
+ "MIGRATE must be allowed on a fresh Connector whose flow matches getInitialFlow(); reason: "
+ + migrateBeforeRestart.getReasonNotAllowed());
+
+ getNiFiInstance().stop();
+ getNiFiInstance().start();
+ setupClient();
+
+ final ConnectorActionDTO migrateAfterRestart = findMigrateAction(getNifiClient().getConnectorClient().getConnector(connectorId));
+ assertNotNull(migrateAfterRestart, "MIGRATE action must remain present after a restart");
+ assertEquals(Boolean.TRUE, migrateAfterRestart.getAllowed(),
+ "Restarting NiFi without modifying the Connector must not cause the initial-flow check to flip; reason: "
+ + migrateAfterRestart.getReasonNotAllowed());
+ }
+
+ @Test
+ public void testMigrateActionDisallowedAfterConnectorFlowIsModified() throws Exception {
+ final File outputFile = new File("target/migration/modified-after-migration.txt");
+ deleteFile(outputFile);
+
+ final FlowRegistryClientEntity registryClient = registerClient();
+ final SourceFixture sourceFixture = createSourceFixture(MIGRATABLE_FLOW_NAME, registryClient, true, outputFile, true);
+ prepareSourceForMigration(sourceFixture, outputFile);
+
+ final ConnectorEntity connector = getClientUtil().createConnector("MigrationTargetConnector");
+ final String connectorId = connector.getId();
+
+ final ConnectorActionDTO migrateBeforeMigration = findMigrateAction(getNifiClient().getConnectorClient().getConnector(connectorId));
+ assertNotNull(migrateBeforeMigration, "MIGRATE action must be present on a fresh, stopped Connector");
+ assertEquals(Boolean.TRUE, migrateBeforeMigration.getAllowed(),
+ "MIGRATE must be allowed on a fresh Connector whose flow still matches getInitialFlow(); reason: "
+ + migrateBeforeMigration.getReasonNotAllowed());
+
+ final MigrationRequestEntity requestEntity = getClientUtil().startMigrationFromLocalSource(connectorId, sourceFixture.processGroup().getId());
+ getClientUtil().waitForMigrationSuccess(connectorId, requestEntity.getRequest().getRequestId());
+
+ final ConnectorActionDTO migrateAfterMigration = findMigrateAction(getNifiClient().getConnectorClient().getConnector(connectorId));
+ assertNotNull(migrateAfterMigration, "MIGRATE action must remain present so its reasonNotAllowed can explain why it is unavailable");
+ assertEquals(Boolean.FALSE, migrateAfterMigration.getAllowed(),
+ "MIGRATE must be disallowed once the Connector's flow has been modified from its initial state");
+ assertNotNull(migrateAfterMigration.getReasonNotAllowed(),
+ "MIGRATE must report a reasonNotAllowed when it is disallowed");
+ assertTrue(migrateAfterMigration.getReasonNotAllowed().toLowerCase().contains("modified"),
+ "reasonNotAllowed must explain that the Connector has been modified; got: " + migrateAfterMigration.getReasonNotAllowed());
+
+ // The migrate-time guard must also reject a second migration attempt; this is the safety net that prevents
+ // the action gate from being bypassed by a direct REST call. The readiness check runs synchronously when the
+ // migration request is created, so the second attempt is rejected immediately rather than failing later.
+ final NiFiClientException secondAttemptException = assertThrows(NiFiClientException.class,
+ () -> getClientUtil().startMigrationFromLocalSource(connectorId, sourceFixture.processGroup().getId()));
+ assertTrue(secondAttemptException.getMessage().toLowerCase().contains("modified"),
+ "Second migration attempt must be rejected because the Connector has been modified; got: " + secondAttemptException.getMessage());
+ }
+
+ @Test
+ public void testMigrateActionStaysDisallowedAfterMigrationAndAcrossRestart() throws Exception {
+ final File outputFile = new File("target/migration/modified-after-restart.txt");
+ deleteFile(outputFile);
+
+ final FlowRegistryClientEntity registryClient = registerClient();
+ final SourceFixture sourceFixture = createSourceFixture(MIGRATABLE_FLOW_NAME, registryClient, true, outputFile, true);
+ prepareSourceForMigration(sourceFixture, outputFile);
+
+ final ConnectorEntity connector = getClientUtil().createConnector("MigrationTargetConnector");
+ final String connectorId = connector.getId();
+
+ final MigrationRequestEntity requestEntity = getClientUtil().startMigrationFromLocalSource(connectorId, sourceFixture.processGroup().getId());
+ getClientUtil().waitForMigrationSuccess(connectorId, requestEntity.getRequest().getRequestId());
+
+ getNiFiInstance().stop();
+ getNiFiInstance().start();
+ setupClient();
+
+ // After restart the framework rebuilds the Connector from its persisted configuration. MigrationTargetConnector
+ // records the migrated flow's configuration as its own configuration properties and rebuilds the managed
+ // Process Group from those properties inside applyUpdate(...); the initial-flow check must therefore still
+ // report the Connector as modified and MIGRATE must remain disallowed.
+ final ConnectorActionDTO migrateAfterRestart = findMigrateAction(getNifiClient().getConnectorClient().getConnector(connectorId));
+ assertNotNull(migrateAfterRestart, "MIGRATE action must remain present after a restart");
+ assertEquals(Boolean.FALSE, migrateAfterRestart.getAllowed(),
+ "MIGRATE must remain disallowed after a restart because the Connector's persisted configuration still records the migration");
+ assertNotNull(migrateAfterRestart.getReasonNotAllowed(), "MIGRATE must report a reasonNotAllowed after a restart");
+ assertTrue(migrateAfterRestart.getReasonNotAllowed().toLowerCase().contains("modified"),
+ "reasonNotAllowed must continue to explain that the Connector has been modified after a restart; got: "
+ + migrateAfterRestart.getReasonNotAllowed());
+ }
+
+ /**
+ * Verifies that the StateManager state {@code MigrationTargetConnector.migrateState} records via
+ * {@code ConnectorMigrationContext.setComponentState} survives a NiFi restart. The source flow contains a
+ * {@code StatefulCountProcessor} whose state is captured into the source flow. During migration the framework
+ * writes that state directly into the live {@code StateManager} of the managed processor. The state then survives
+ * a restart through the same mechanism as any normal component state: the {@code StateManager} persists each
+ * component's state to its configured state provider (the local provider on disk, the cluster provider in
+ * ZooKeeper), and the same component identifier is rehydrated on restart, so the state is read back from the
+ * same provider. Migration does not re-apply the state on restart; it only seeds it once during the initial
+ * migration.
+ */
+ @Test
+ public void testComponentStateAppliedByMigrateStateSurvivesRestart() throws Exception {
+ final File outputFile = new File("target/migration/component-state-after-restart.txt");
+ deleteFile(outputFile);
+
+ final FlowRegistryClientEntity registryClient = registerClient();
+ final SourceFixture sourceFixture = createSourceFixture(MIGRATABLE_FLOW_NAME, registryClient, true, outputFile, true);
+ prepareSourceForMigration(sourceFixture, outputFile);
+
+ final ConnectorEntity connector = getClientUtil().createConnector("MigrationTargetConnector");
+ final String connectorId = connector.getId();
+
+ final MigrationRequestEntity requestEntity = getClientUtil().startMigrationFromLocalSource(connectorId, sourceFixture.processGroup().getId());
+ getClientUtil().waitForMigrationSuccess(connectorId, requestEntity.getRequest().getRequestId());
+
+ final ConnectorEntity migratedConnector = getNifiClient().getConnectorClient().getConnector(connectorId);
+ final String managedGroupId = migratedConnector.getComponent().getManagedProcessGroupId();
+ final String migratedCountProcessorId = getProcessorId(connectorId, managedGroupId, "StatefulCountProcessor");
+
+ final Map stateBeforeRestart = readLocalState(connectorId, migratedCountProcessorId);
+ assertFalse(stateBeforeRestart.isEmpty(),
+ "Migrated processor must carry the StateManager state that the connector recorded via migrateState(...)");
+
+ getNiFiInstance().stop();
+ getNiFiInstance().start();
+ setupClient();
+
+ final Map stateAfterRestart = readLocalState(connectorId, migratedCountProcessorId);
+ assertEquals(stateBeforeRestart, stateAfterRestart,
+ "StateManager state must be identical before and after a restart; component state is persisted by the StateManager's configured state provider and rehydrated on the next load");
+ }
+
+ /**
+ * Verifies that a failure during the state phase of migration rolls back the staged configuration so that the
+ * persisted active configuration matches the pre-migration state both immediately and after a NiFi restart. The
+ * {@code FailingStateMigrationConnector} used here stages a configuration delta during {@code migrateConfiguration}
+ * and then throws from {@code migrateState}. The framework must skip the per-step commit onto active, so the
+ * marker property must remain unset both before and after the restart.
+ */
+ @Test
+ public void testStateMigrationFailureLeavesConfigurationUnchangedAcrossRestart() throws Exception {
+ final File outputFile = new File("target/migration/state-failure-restart-output.txt");
+ deleteFile(outputFile);
+
+ final FlowRegistryClientEntity registryClient = registerClient();
+ final SourceFixture sourceFixture = createSourceFixture("StateFailureSource", registryClient, true, outputFile, true);
+ prepareSourceForMigration(sourceFixture, outputFile);
+
+ final ConnectorEntity connector = getClientUtil().createConnector("FailingStateMigrationConnector");
+ final String connectorId = connector.getId();
+
+ // Sanity check: before any migration the marker property is unset.
+ assertNull(readMarkerPropertyValue(connectorId), "Marker property must be unset on a fresh connector");
+
+ final MigrationRequestEntity requestEntity = getClientUtil().startMigrationFromLocalSource(connectorId, sourceFixture.processGroup().getId());
+ final MigrationRequestEntity completed = getClientUtil().waitForMigrationFailure(connectorId, requestEntity.getRequest().getRequestId());
+ assertNotNull(completed.getRequest().getFailureReason(), "Migration must report a failure reason when migrateState throws");
+
+ // The connector records a configuration change during migrateConfiguration, but the framework must not commit
+ // it onto active because the state phase failed.
+ assertNull(readMarkerPropertyValue(connectorId),
+ "Marker property must remain unset after the state-migration failure; commit must run only after both phases succeed");
+
+ getNiFiInstance().stop();
+ getNiFiInstance().start();
+ setupClient();
+
+ // After a restart the persisted active configuration is rehydrated. The marker property must still be unset
+ // because the framework rolled the migration back before persisting the staged configuration change.
+ assertNull(readMarkerPropertyValue(connectorId),
+ "Marker property must remain unset after a restart following a failed state migration");
+ }
+
+ @Test
+ public void testMigrationInterruptedByRestartLeavesConnectorFresh() throws Exception {
+ final File outputFile = new File("target/migration/failure-restart-output.txt");
+ deleteFile(outputFile);
+
+ final FlowRegistryClientEntity registryClient = registerClient();
+ final SourceFixture sourceFixture = createSourceFixture("FailureRestartSource", registryClient, false, outputFile, true);
+ prepareSourceForMigration(sourceFixture, outputFile);
+
+ final String sourceGroupId = sourceFixture.processGroup().getId();
+ final String originalName = getNifiClient().getProcessGroupClient().getProcessGroup(sourceGroupId).getComponent().getName();
+
+ final ConnectorEntity connector = getClientUtil().createConnector("FailingConfigurationMigrationConnector");
+ final String connectorId = connector.getId();
+ final MigrationRequestEntity requestEntity = getClientUtil().startMigrationFromLocalSource(connectorId, sourceGroupId);
+
+ Thread.sleep(1000L);
+ getNiFiInstance().stop();
+ getNiFiInstance().start(true);
+ setupClient();
+
+ waitFor(() -> {
+ try {
+ getNifiClient().getConnectorClient().getConnector(connectorId);
+ return true;
+ } catch (final Exception e) {
+ return false;
+ }
+ });
+
+ assertThrows(NiFiClientException.class, () -> getNifiClient().getConnectorClient().getMigrationStatus(connectorId, requestEntity.getRequest().getRequestId()));
+
+ waitFor(() -> isConnectorFresh(connectorId));
+ assertConnectorFresh(connectorId);
+ assertSourceUntouched(sourceFixture, originalName);
+ }
+
+ private boolean isConnectorFresh(final String connectorId) {
+ try {
+ final ConnectorEntity connectorEntity = getNifiClient().getConnectorClient().getConnector(connectorId);
+ final String managedGroupId = connectorEntity.getComponent().getManagedProcessGroupId();
+ final ProcessGroupFlowEntity flowEntity = getNifiClient().getConnectorClient().getFlow(connectorId, managedGroupId);
+ if (flowEntity.getProcessGroupFlow().getFlow().getProcessors() != null && !flowEntity.getProcessGroupFlow().getFlow().getProcessors().isEmpty()) {
+ return false;
+ }
+ if (flowEntity.getProcessGroupFlow().getFlow().getConnections() != null && !flowEntity.getProcessGroupFlow().getFlow().getConnections().isEmpty()) {
+ return false;
+ }
+
+ return getNifiClient().getConnectorClient().getAssets(connectorId).getAssets() == null
+ || getNifiClient().getConnectorClient().getAssets(connectorId).getAssets().isEmpty();
+ } catch (final Exception e) {
+ return false;
+ }
+ }
+
+ private String readMarkerPropertyValue(final String connectorId) throws Exception {
+ final ConnectorEntity connectorEntity = getNifiClient().getConnectorClient().getConnector(connectorId);
+ final ConnectorConfigurationDTO activeConfiguration = connectorEntity.getComponent().getActiveConfiguration();
+ if (activeConfiguration == null || activeConfiguration.getConfigurationStepConfigurations() == null) {
+ return null;
+ }
+ for (final ConfigurationStepConfigurationDTO stepConfig : activeConfiguration.getConfigurationStepConfigurations()) {
+ if (!"State Migration Marker".equals(stepConfig.getConfigurationStepName())) {
+ continue;
+ }
+ if (stepConfig.getPropertyGroupConfigurations() == null) {
+ continue;
+ }
+ for (final PropertyGroupConfigurationDTO group : stepConfig.getPropertyGroupConfigurations()) {
+ if (group.getPropertyValues() == null) {
+ continue;
+ }
+ final ConnectorValueReferenceDTO valueReference = group.getPropertyValues().get("Marker");
+ if (valueReference == null) {
+ continue;
+ }
+ return valueReference.getValue();
+ }
+ }
+ return null;
+ }
+
+ private Map readLocalState(final String connectorId, final String processorId) throws Exception {
+ final ComponentStateEntity stateEntity = getNifiClient().getConnectorClient().getProcessorState(connectorId, processorId);
+ assertNotNull(stateEntity.getComponentState(), "Component state response must include component state");
+ assertNotNull(stateEntity.getComponentState().getLocalState(), "Component state response must include local state");
+ final List entries = stateEntity.getComponentState().getLocalState().getState();
+ if (entries == null || entries.isEmpty()) {
+ return Map.of();
+ }
+ final Map stateMap = new HashMap<>();
+ for (final StateEntryDTO entry : entries) {
+ stateMap.put(entry.getKey(), entry.getValue());
+ }
+ return Map.copyOf(stateMap);
+ }
+
+}
diff --git a/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/ConnectorClient.java b/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/ConnectorClient.java
index 76f49df4b07c..522a850664aa 100644
--- a/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/ConnectorClient.java
+++ b/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/ConnectorClient.java
@@ -24,10 +24,13 @@
import org.apache.nifi.web.api.entity.ConnectorEntity;
import org.apache.nifi.web.api.entity.ConnectorPropertyAllowableValuesEntity;
import org.apache.nifi.web.api.entity.DropRequestEntity;
+import org.apache.nifi.web.api.entity.MigrationPayloadEntity;
+import org.apache.nifi.web.api.entity.MigrationRequestEntity;
import org.apache.nifi.web.api.entity.ProcessGroupFlowEntity;
import org.apache.nifi.web.api.entity.ProcessGroupStatusEntity;
import org.apache.nifi.web.api.entity.StatusHistoryEntity;
import org.apache.nifi.web.api.entity.VerifyConnectorConfigStepRequestEntity;
+import org.apache.nifi.web.api.entity.VersionedFlowMigrationSourcesEntity;
import java.io.File;
import java.io.IOException;
@@ -282,6 +285,16 @@ VerifyConnectorConfigStepRequestEntity getConfigStepVerificationRequest(String c
VerifyConnectorConfigStepRequestEntity deleteConfigStepVerificationRequest(String connectorId, String configurationStepName,
String requestId) throws NiFiClientException, IOException;
+ VersionedFlowMigrationSourcesEntity listMigrationSources(String connectorId) throws NiFiClientException, IOException;
+
+ MigrationPayloadEntity uploadMigrationPayload(String connectorId, File file) throws NiFiClientException, IOException;
+
+ MigrationRequestEntity startMigration(MigrationRequestEntity requestEntity) throws NiFiClientException, IOException;
+
+ MigrationRequestEntity getMigrationStatus(String connectorId, String requestId) throws NiFiClientException, IOException;
+
+ MigrationRequestEntity cancelMigration(String connectorId, String requestId) throws NiFiClientException, IOException;
+
/**
* Applies an update to a connector.
*
diff --git a/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/impl/JerseyConnectorClient.java b/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/impl/JerseyConnectorClient.java
index 915091d08e58..8c8f7c3b9995 100644
--- a/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/impl/JerseyConnectorClient.java
+++ b/nifi-toolkit/nifi-toolkit-client/src/main/java/org/apache/nifi/toolkit/client/impl/JerseyConnectorClient.java
@@ -34,10 +34,13 @@
import org.apache.nifi.web.api.entity.ConnectorPropertyAllowableValuesEntity;
import org.apache.nifi.web.api.entity.ConnectorRunStatusEntity;
import org.apache.nifi.web.api.entity.DropRequestEntity;
+import org.apache.nifi.web.api.entity.MigrationPayloadEntity;
+import org.apache.nifi.web.api.entity.MigrationRequestEntity;
import org.apache.nifi.web.api.entity.ProcessGroupFlowEntity;
import org.apache.nifi.web.api.entity.ProcessGroupStatusEntity;
import org.apache.nifi.web.api.entity.StatusHistoryEntity;
import org.apache.nifi.web.api.entity.VerifyConnectorConfigStepRequestEntity;
+import org.apache.nifi.web.api.entity.VersionedFlowMigrationSourcesEntity;
import java.io.File;
import java.io.FileInputStream;
@@ -461,6 +464,78 @@ public VerifyConnectorConfigStepRequestEntity deleteConfigStepVerificationReques
});
}
+ @Override
+ public VersionedFlowMigrationSourcesEntity listMigrationSources(final String connectorId) throws NiFiClientException, IOException {
+ Objects.requireNonNull(connectorId, "Connector ID required");
+
+ return executeAction("Error retrieving connector migration sources", () -> {
+ final WebTarget target = connectorTarget
+ .path("/migration-sources")
+ .resolveTemplate("id", connectorId);
+ return getRequestBuilder(target).get(VersionedFlowMigrationSourcesEntity.class);
+ });
+ }
+
+ @Override
+ public MigrationPayloadEntity uploadMigrationPayload(final String connectorId, final File file) throws NiFiClientException, IOException {
+ Objects.requireNonNull(connectorId, "Connector ID required");
+ Objects.requireNonNull(file, "Migration payload file required");
+ if (!file.exists()) {
+ throw new FileNotFoundException(file.getAbsolutePath());
+ }
+
+ try (final InputStream payloadInputStream = new FileInputStream(file)) {
+ return executeAction("Error uploading connector migration payload", () -> {
+ final WebTarget target = connectorTarget
+ .path("/migration-payloads")
+ .resolveTemplate("id", connectorId);
+ return getRequestBuilder(target).post(Entity.entity(payloadInputStream, MediaType.APPLICATION_OCTET_STREAM_TYPE), MigrationPayloadEntity.class);
+ });
+ }
+ }
+
+ @Override
+ public MigrationRequestEntity startMigration(final MigrationRequestEntity requestEntity) throws NiFiClientException, IOException {
+ Objects.requireNonNull(requestEntity, "Migration request entity required");
+ Objects.requireNonNull(requestEntity.getRequest(), "Migration request required");
+ Objects.requireNonNull(requestEntity.getRequest().getConnectorId(), "Connector ID required");
+
+ return executeAction("Error creating connector migration request", () -> {
+ final WebTarget target = connectorTarget
+ .path("/migration-requests")
+ .resolveTemplate("id", requestEntity.getRequest().getConnectorId());
+ return getRequestBuilder(target).post(Entity.entity(requestEntity, MediaType.APPLICATION_JSON_TYPE), MigrationRequestEntity.class);
+ });
+ }
+
+ @Override
+ public MigrationRequestEntity getMigrationStatus(final String connectorId, final String requestId) throws NiFiClientException, IOException {
+ Objects.requireNonNull(connectorId, "Connector ID required");
+ Objects.requireNonNull(requestId, "Migration request ID required");
+
+ return executeAction("Error retrieving connector migration request", () -> {
+ final WebTarget target = connectorTarget
+ .path("/migration-requests/{requestId}")
+ .resolveTemplate("id", connectorId)
+ .resolveTemplate("requestId", requestId);
+ return getRequestBuilder(target).get(MigrationRequestEntity.class);
+ });
+ }
+
+ @Override
+ public MigrationRequestEntity cancelMigration(final String connectorId, final String requestId) throws NiFiClientException, IOException {
+ Objects.requireNonNull(connectorId, "Connector ID required");
+ Objects.requireNonNull(requestId, "Migration request ID required");
+
+ return executeAction("Error deleting connector migration request", () -> {
+ final WebTarget target = connectorTarget
+ .path("/migration-requests/{requestId}")
+ .resolveTemplate("id", connectorId)
+ .resolveTemplate("requestId", requestId);
+ return getRequestBuilder(target).delete(MigrationRequestEntity.class);
+ });
+ }
+
@Override
public ConnectorEntity applyUpdate(final ConnectorEntity connectorEntity) throws NiFiClientException, IOException {
if (connectorEntity == null) {