diff --git a/nifi-docs/src/main/asciidoc/administration-guide.adoc b/nifi-docs/src/main/asciidoc/administration-guide.adoc index c3cec4e80241..2e9e77981b9c 100644 --- a/nifi-docs/src/main/asciidoc/administration-guide.adoc +++ b/nifi-docs/src/main/asciidoc/administration-guide.adoc @@ -3852,6 +3852,93 @@ The Secrets Manager delegates to Parameter Providers to retrieve secret values. |`nifi.secrets.manager.cache.duration`|The duration for which resolved secret values are cached before being refreshed from the underlying Parameter Providers. Accepts any NiFi time duration value such as `5 mins`, `30 secs`, etc. A value of `0 sec` disables caching entirely. Defaults to `5 mins`. |==== +== Migrating a Connector from a Versioned Process Group + +NiFi supports a one-time path that migrates a newly created Connector from a source Versioned Process Group. +The source Versioned Process Group is used as a reference: the Connector reads it and updates its own managed flow to mirror the source's configuration, parameters, and component state. +The source flow itself is not installed onto the Connector. +Only Connectors that implement migration support can use this capability. + +=== Prerequisites for a local migration + +When the source is a local Process Group on the same NiFi instance, the Process Group must satisfy all of the following requirements before NiFi will allow the migration: + +- The source Process Group must be under version control. +- The source Process Group must be `UP_TO_DATE` with the latest published version in Flow Registry. +- All processors in the source Process Group must be stopped. +- All controller services in the source Process Group must be disabled. +- All queues in the source Process Group must be empty. +- The source Process Group must not reference controller services outside of the Process Group. +- The target Connector must be not yet configured, stopped, and not previously migrated. +- The target Connector's active flow must be empty when migration begins. Connectors that ship a non-empty initial flow are not compatible with the migration path. + +If the source Process Group is not under version control, export it and use the uploaded payload path instead. + +=== Migration paths + +There are two ways to run the migration: + +- Local Process Group migration: + Select an eligible Process Group that already exists on the current NiFi canvas. + NiFi validates the prerequisites listed above before starting the migration. +- Uploaded payload migration: + Export a Process Group definition, including component state if the source flow has any, and upload the resulting flow snapshot payload to the Connector migration endpoint. + This path is intended for non-version-controlled sources, sources exported from another NiFi instance, or any other source that cannot satisfy the local version-control requirements. + +For uploaded payloads, NiFi does not require the source flow to be under version control. +The Connector's own migration support check determines whether the uploaded flow can be imported. + +=== Running a migration + +. Create a new Connector instance. +. If the source Process Group is local and registry-backed, choose it from the Connector migration source list. +. If the source Process Group is not eligible for the local path, export it with component state included and upload the payload to the Connector migration payload endpoint. +. Start the Connector migration request. +. Poll the request until it completes. +. Open the Connector configuration and supply any missing secrets. +. Verify the configuration and start the Connector. + +=== What NiFi migrates + +During migration, NiFi provides the Connector with the exported flow definition, parameter contexts, referenced assets, and any attached component state for `@Stateful` components. +The Connector performs the flow translation and updates its own managed flow to mirror the source. + +For local migrations, a Connector can also copy referenced assets from the source Process Group into the Connector's own asset namespace. +Uploaded payload migrations do not include live access to source assets, so any required asset content must be re-uploaded separately after the migration. + +Sensitive parameter values are not present in exported flow definitions. +After migration, configure the Connector with any missing secret values before starting it. + +=== Cleanup after a successful migration + +After a successful local migration, NiFi disables the source Process Group and renames it with the `(Migrated) ` prefix. +This makes it clear that the source flow has already been used as the basis for a Connector migration. + +Uploaded payloads and migration request state are kept only in memory for the lifetime of the request. +When the request succeeds, fails, or is cancelled, NiFi removes the uploaded payload associated with that request. + +=== Cluster-topology rule for component state + +When the source flow being migrated includes LOCAL component state for a stateful component (for example a `@Stateful` Processor or Controller Service), the destination cluster must have at least as many connected nodes as the source flow's exported state references. +The rule applied by the migration manager is: + +---- +size(component.localNodeStates) <= number of connected nodes in the destination cluster +---- + +This is the same rule that applies when importing a Versioned Process Group into a destination cluster. +NiFi enforces it for both the local migration path and the uploaded payload path so that LOCAL state can be unambiguously distributed across the destination cluster. + +When the migration request fails with a message such as `Cannot import flow with component state: the flow definition contains local state from N source node(s) but the destination cluster has only M connected node(s)`, take one of the following actions: + +- Reconnect any disconnected cluster nodes so that the destination cluster has at least `N` connected nodes, then re-run the migration. +- Re-export the source flow without component state (uncheck the "include component state" option) and re-upload the payload. + The Connector will start with empty LOCAL state on every destination node, which is appropriate when the LOCAL state is reproducible from the upstream system. +- Migrate into a cluster that has at least `N` connected nodes. + +The migration request is also rejected when any cluster node is in the `CONNECTING`, `DISCONNECTED`, or `DISCONNECTING` state at the time the request is submitted, because component state and asset content cannot be synchronized to a node that is not currently connected. +Reconnect the disconnected nodes and re-submit the request. + [[upgrading_nifi]] == Upgrading NiFi diff --git a/nifi-docs/src/main/asciidoc/developer-guide.adoc b/nifi-docs/src/main/asciidoc/developer-guide.adoc index 1906efacc77b..d22fd5525542 100644 --- a/nifi-docs/src/main/asciidoc/developer-guide.adoc +++ b/nifi-docs/src/main/asciidoc/developer-guide.adoc @@ -2697,6 +2697,139 @@ The following command is used to generate a standard binary distribution of Apac `mvn clean install -Pcontrib-check` +== Supporting migration from a Versioned Process Group + +Connectors can implement a one-time migration flow that uses a source Versioned Process Group as a reference for populating a freshly created Connector. +This is intended for Connectors that can translate an exported flow definition, its parameter contexts, referenced assets, and any attached component state into their own managed flow. +The source Versioned Process Group is not installed onto the Connector; instead, the Connector reads the source as input and updates its own flow to mirror the source's configuration and state. + +Migration support is optional. +Connectors opt in by implementing the optional capability interface `org.apache.nifi.components.connector.migration.MigratableConnector`. +A Connector that does not implement `MigratableConnector` is never offered as a migration target and continues to behave as before. + +=== Migration contract + +`MigratableConnector` is a separate interface that the Connector class must implement (typically alongside extending `AbstractConnector`). It exposes three methods: + +- `isMigrationSupported(ConnectorMigrationContext)` is a cheap, side-effect-free predicate. + It should inspect `context.getSourceFlow()` and return `true` only when the Connector can be migrated from the source structure. + Implementations should limit themselves to structural checks such as processor types, parameter names, and exported metadata. + Component state and asset content are not available at listing time and must not be relied on; per-state precondition checks belong inside `migrateConfiguration` or `migrateState`. + This method must not call `ConnectorMigrationContext.copyAssetFromSource(...)` or any of the write APIs described below; the framework wraps the eligibility-time context so any such attempt is rejected with `IllegalStateException` and the Connector is filtered out of the listing. +- `migrateConfiguration(ConnectorMigrationContext)` records the configuration the Connector wants on the other side of the migration. + The Connector reads `context.getSourceFlow()` and records configuration changes via `context.setProperties(stepName, propertyValues)` (merge) or `context.replaceProperties(stepName, propertyValues)` (full replacement of the step). + These calls are applied to a working copy of the Connector's configuration that the framework seeded with the Connector's current active configuration, so the working copy always reflects the fully-merged result. + After `migrateConfiguration` returns, the framework drives `Connector.applyUpdate(workingContext, activeContext)` from that merged configuration so the managed Process Group is rebuilt by the same code path the framework uses on restart. + The Connector must not call `updateFlow(...)` from this method; the framework hands the Connector a read-only wrapper around the active `FlowContext`, so any direct `updateFlow(...)` attempt is rejected with `IllegalStateException`. + Component-state writes are also rejected in this phase: `setComponentState(...)` throws because the managed components do not exist yet. +- `migrateState(ConnectorMigrationContext)` runs after the framework has rebuilt the managed Process Group from the merged configuration. + At this point every Processor and Controller Service in the source flow has a corresponding managed component in the active `FlowContext`, so the Connector can match each source `VersionedConfigurableExtension` to its managed counterpart by versioned identifier. + The Connector records the desired `StateManager` state for each managed component via `context.setComponentState(versionedComponentId, versionedComponentState)`. + After `migrateState` returns, the framework writes each staged `VersionedComponentState` into the corresponding managed component's live `StateManager`. + Configuration writes (`setProperties`, `replaceProperties`) and direct `updateFlow(...)` calls are rejected in this phase for the same reasons described above. + +The two phases together form a single, all-or-nothing migration. The framework only commits the merged configuration onto the Connector's persisted active configuration after both `migrateState` and the framework's subsequent state-application step have succeeded. Any failure in either phase rolls the migration back: the managed Process Group is restored from `getInitialFlow()`, the working configuration copy is discarded, any assets copied via `copyAssetFromSource(...)` are deleted, and the Connector's persisted configuration remains unchanged. + +=== Accessing the source flow + +`ConnectorMigrationContext` exposes the source `VersionedExternalFlow` using `getSourceFlow()`. +The source includes: + +- The exported `VersionedProcessGroup` +- Parameter contexts +- Referenced assets +- Attached `VersionedComponentState` for `@Stateful` components +- Export metadata such as flow name, registry identifiers, and version + +The same context also indicates whether the request came from a local Process Group on the same NiFi instance or from an uploaded payload using `isLocalMigration()`. + +The active `FlowContext` is available via `context.getActiveFlowContext()` and is read-only during migration: it forwards reads (configuration, managed Process Group) to the live active flow context but rejects `updateFlow(...)` with `IllegalStateException`. The Connector should treat this object as a window onto the current state of the Connector, not as a write target. After `migrateConfiguration` returns, the framework rebuilds the managed Process Group by calling `Connector.applyUpdate(workingContext, activeContext)` with this same active `FlowContext` as the target, so any component references the Connector resolves against `getActiveFlowContext()` remain valid once the migrated flow is applied. + +=== Recording configuration changes + +A typical `migrateConfiguration` implementation translates the source flow into a representation the Connector can rebuild from later (often a serialized version of the source `VersionedExternalFlow` or a derived configuration), then stages it onto the Connector's own configuration: + +[source,java] +---- +@Override +public void migrateConfiguration(final ConnectorMigrationContext context) throws FlowUpdateException { + final VersionedExternalFlow sourceFlow = context.getSourceFlow(); + final String serializedSourceFlow = OBJECT_MAPPER.writeValueAsString(sourceFlow); + + // Record the configuration the Connector wants on the other side of the migration. The framework merges this + // delta onto the active configuration and then drives Connector.applyUpdate(workingContext, activeContext), + // which is the same path used on every restart - so the managed Process Group is rebuilt the same way after + // migration and after every subsequent restart. + context.setProperties(MIGRATION_STATE_STEP_NAME, Map.of(MIGRATED_SOURCE_FLOW_PROPERTY_NAME, serializedSourceFlow)); +} +---- + +`setProperties` is additive: existing properties on the step keep their values unless the staged map overrides them. +`replaceProperties` is destructive: the step is reset to exactly the supplied property map, and any pre-existing properties not present in the map are removed. + +The Connector's `applyUpdate(workingContext, activeContext)` implementation reads the merged configuration from the supplied `workingContext`, rebuilds the desired managed Process Group, and calls `getInitializationContext().updateFlow(activeContext, rebuiltFlow)` to install it. Because the framework drives the same `applyUpdate(...)` call during restart via `inheritConfiguration(...)`, the migrated managed flow is rebuilt automatically on every restart from the persisted configuration; the Connector does not need to do anything extra to make migrations survive restarts. + +=== Recording component state + +After the managed Process Group has been rebuilt, `migrateState` records the `StateManager` state for individual managed components: + +[source,java] +---- +@Override +public void migrateState(final ConnectorMigrationContext context) { + final VersionedProcessGroup sourceGroup = context.getSourceFlow().getFlowContents(); + for (final VersionedProcessor sourceProcessor : sourceGroup.getProcessors()) { + final VersionedComponentState sourceState = sourceProcessor.getComponentState(); + if (sourceState != null) { + context.setComponentState(sourceProcessor.getIdentifier(), sourceState); + } + } +} +---- + +The first argument to `setComponentState` is the source-side versioned identifier; the framework resolves it to the matching managed Processor or Controller Service and writes the supplied `VersionedComponentState` into that component's live `StateManager`. Cluster-scope state is applied unconditionally on every node; per-node local-scope state is selected by node ordinal so a clustered migration installs the right entry on each node. Components whose `@Stateful` annotation does not declare a requested scope are skipped with a warning rather than failing the migration. + +`setComponentState` only stages the desired state; the framework writes it after `migrateState` returns. If a Connector calls `setComponentState` more than once for the same versioned identifier, only the last call is applied. + +=== Rewriting parameters and assets + +The exported source flow contains parameter contexts and any asset references that were attached to exported parameters. +Connectors are responsible for mapping those values into their own target shape inside `migrateConfiguration`. + +A Connector can copy an asset from a local source Process Group into its own asset namespace using `ConnectorMigrationContext.copyAssetFromSource(String)`. +This method is available only for local migrations. +It throws `IllegalArgumentException` when the supplied source asset identifier is null or blank, and `IllegalStateException` when invoked for an uploaded-payload migration. +Uploaded payloads do not have access to a live source asset namespace, so asset references from uploaded payloads must be handled outside of the migration request. + +The following example locates a source parameter's referenced asset, copies it into the Connector's own asset namespace, and records the returned `AssetReference` on the target property: + +[source,java] +---- +final VersionedParameter sourceParameter = findSourceParameter(context.getSourceFlow(), sourceParameterName); +if (sourceParameter != null && sourceParameter.getReferencedAssets() != null && !sourceParameter.getReferencedAssets().isEmpty() && context.isLocalMigration()) { + final VersionedAsset referencedAsset = sourceParameter.getReferencedAssets().getFirst(); + final AssetReference assetReference = context.copyAssetFromSource(referencedAsset.getIdentifier()); + context.setValueReference(targetStepName, targetPropertyName, assetReference); +} +---- + +=== Sensitive values + +Sensitive parameter values and other secret material are not present in the exported source flow. +Connectors must leave those values unset during migration. +After the migration completes, the user supplies the missing values through the normal Configure step before starting the Connector. + +=== Additional expectations + +- `isMigrationSupported(...)` must not mutate flow state, parameter values, or assets. +- `migrateConfiguration` and `migrateState` should either complete successfully or throw an exception. Any thrown exception aborts the migration and rolls back every side effect: copied assets are deleted, the managed Process Group is restored from `getInitialFlow()`, every component's `StateManager` state is cleared, and the persisted active configuration is left unchanged. +- Migration is rejected once the Connector has been modified from the state it was created in. + The framework considers a Connector modified when any configured property on its active or working configuration differs from that property's declared default value, or when any Processor or Controller Service in the managed flow has stored component state. + This check is evaluated at the time `migrateConfiguration` is invoked, and again whenever the `MIGRATE` allowable action is evaluated, so the user is shown why migration is unavailable. + This check is restart-safe because the persisted configuration and any stored component state are recomputed each time from durable sources. +- Migration is one-shot from the framework's point of view. + Because a successful migration writes configuration and, typically, component state onto the Connector, the Connector is considered modified immediately afterward, and a subsequent migration request will be rejected until the Connector is discarded and a fresh one is created. + == How to contribute to Apache NiFi We are always excited to have contributions from the community - especially from new contributors! diff --git a/nifi-framework-api/src/main/java/org/apache/nifi/components/connector/ConnectorConfigurationProvider.java b/nifi-framework-api/src/main/java/org/apache/nifi/components/connector/ConnectorConfigurationProvider.java index 16e5ae6e9b2b..0dea11cc6a11 100644 --- a/nifi-framework-api/src/main/java/org/apache/nifi/components/connector/ConnectorConfigurationProvider.java +++ b/nifi-framework-api/src/main/java/org/apache/nifi/components/connector/ConnectorConfigurationProvider.java @@ -140,6 +140,36 @@ default void verifyEnterTroubleshooting(final String connectorId) { default void verifyEndTroubleshooting(final String connectorId) { } + /** + * Verifies that the provider allows the connector with the given identifier to be migrated. This is called before + * the framework migrates a Connector by inheriting the configuration, parameters, and component state of a source + * flow, giving the provider an opportunity to reject the operation (for example, because the external system has + * the connector in a state that is not compatible with accepting a migration). + * + *

If the provider cannot support the operation, it should throw a runtime exception + * (for example, {@link IllegalStateException} or + * {@link ConnectorConfigurationProviderException}) describing why the migration is not + * allowed. The exception message will be surfaced to the caller.

+ * + *

The default implementation is a no-op, allowing the migration to proceed.

+ * + * @param connectorId the identifier of the connector that is being migrated + */ + default void verifyMigration(final String connectorId) { + } + + /** + * Notifies the provider that a migration into the connector with the given identifier has completed and is durable. + * This is called after the framework has committed the migrated configuration onto the connector's active + * configuration, so the provider can perform any bookkeeping it needs on migration completion. + * + * @param connectorId the identifier of the connector that inherited the migration + * @param sourceProcessGroupId the identifier of the local Process Group that was migrated from, or {@code null} + * when the migration source was an uploaded payload rather than a local Process Group + */ + default void migrationComplete(final String connectorId, final String sourceProcessGroupId) { + } + /** * Determines how the connector repository should handle synchronization for the given * connector during flow inheritance (cluster join). The provider examines the external diff --git a/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/MigrationPayloadDTO.java b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/MigrationPayloadDTO.java new file mode 100644 index 000000000000..08a17d70c3b4 --- /dev/null +++ b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/MigrationPayloadDTO.java @@ -0,0 +1,34 @@ +/* + * 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.web.api.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.xml.bind.annotation.XmlType; + +@XmlType(name = "migrationPayload") +public class MigrationPayloadDTO { + private String payloadId; + + @Schema(description = "The identifier of the uploaded migration payload.") + public String getPayloadId() { + return payloadId; + } + + public void setPayloadId(final String payloadId) { + this.payloadId = payloadId; + } +} diff --git a/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/MigrationRequestDTO.java b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/MigrationRequestDTO.java new file mode 100644 index 000000000000..9dbbce360cf8 --- /dev/null +++ b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/MigrationRequestDTO.java @@ -0,0 +1,54 @@ +/* + * 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.web.api.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.xml.bind.annotation.XmlType; + +@XmlType(name = "migrationRequest") +public class MigrationRequestDTO extends AsynchronousRequestDTO { + private String connectorId; + private MigrationRequestLocalSourceDTO localSource; + private String payloadId; + + @Schema(description = "The identifier of the Connector receiving the migration.") + public String getConnectorId() { + return connectorId; + } + + public void setConnectorId(final String connectorId) { + this.connectorId = connectorId; + } + + @Schema(description = "The local Process Group source for the migration request.") + public MigrationRequestLocalSourceDTO getLocalSource() { + return localSource; + } + + public void setLocalSource(final MigrationRequestLocalSourceDTO localSource) { + this.localSource = localSource; + } + + @Schema(description = "The identifier of a previously uploaded migration payload.") + public String getPayloadId() { + return payloadId; + } + + public void setPayloadId(final String payloadId) { + this.payloadId = payloadId; + } +} diff --git a/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/MigrationRequestLocalSourceDTO.java b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/MigrationRequestLocalSourceDTO.java new file mode 100644 index 000000000000..c0434ea011c9 --- /dev/null +++ b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/MigrationRequestLocalSourceDTO.java @@ -0,0 +1,34 @@ +/* + * 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.web.api.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.xml.bind.annotation.XmlType; + +@XmlType(name = "migrationRequestLocalSource") +public class MigrationRequestLocalSourceDTO { + private String processGroupId; + + @Schema(description = "The identifier of the local source Process Group to migrate.") + public String getProcessGroupId() { + return processGroupId; + } + + public void setProcessGroupId(final String processGroupId) { + this.processGroupId = processGroupId; + } +} diff --git a/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/MigrationUpdateStepDTO.java b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/MigrationUpdateStepDTO.java new file mode 100644 index 000000000000..61d4d2b16ca1 --- /dev/null +++ b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/MigrationUpdateStepDTO.java @@ -0,0 +1,23 @@ +/* + * 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.web.api.dto; + +import jakarta.xml.bind.annotation.XmlType; + +@XmlType(name = "migrationUpdateStep") +public class MigrationUpdateStepDTO extends UpdateStepDTO { +} diff --git a/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/VersionedFlowMigrationSourceDTO.java b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/VersionedFlowMigrationSourceDTO.java new file mode 100644 index 000000000000..5629eed4b4f2 --- /dev/null +++ b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/VersionedFlowMigrationSourceDTO.java @@ -0,0 +1,130 @@ +/* + * 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.web.api.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.xml.bind.annotation.XmlType; + +import java.util.List; + +@XmlType(name = "versionedFlowMigrationSource") +public class VersionedFlowMigrationSourceDTO { + private String processGroupId; + private String processGroupName; + private String parentProcessGroupId; + private String registryClientId; + private String bucketId; + private String flowId; + private String flowName; + private String version; + private boolean readyForMigration; + private List ineligibilityReasons; + + @Schema(description = "The identifier of the source Process Group.") + public String getProcessGroupId() { + return processGroupId; + } + + public void setProcessGroupId(final String processGroupId) { + this.processGroupId = processGroupId; + } + + @Schema(description = "The name of the source Process Group.") + public String getProcessGroupName() { + return processGroupName; + } + + public void setProcessGroupName(final String processGroupName) { + this.processGroupName = processGroupName; + } + + @Schema(description = "The identifier of the parent Process Group of the source Process Group.") + public String getParentProcessGroupId() { + return parentProcessGroupId; + } + + public void setParentProcessGroupId(final String parentProcessGroupId) { + this.parentProcessGroupId = parentProcessGroupId; + } + + @Schema(description = "The identifier of the Flow Registry client backing the source Process Group.") + public String getRegistryClientId() { + return registryClientId; + } + + public void setRegistryClientId(final String registryClientId) { + this.registryClientId = registryClientId; + } + + @Schema(description = "The Flow Registry bucket identifier for the source Process Group.") + public String getBucketId() { + return bucketId; + } + + public void setBucketId(final String bucketId) { + this.bucketId = bucketId; + } + + @Schema(description = "The Flow Registry flow identifier for the source Process Group.") + public String getFlowId() { + return flowId; + } + + public void setFlowId(final String flowId) { + this.flowId = flowId; + } + + @Schema(description = "The name of the versioned flow backing the source Process Group, as recorded in the Flow Registry. " + + "May be null when the registry has not yet supplied a name (for example, while the source's version control state is SYNC_FAILURE).") + public String getFlowName() { + return flowName; + } + + public void setFlowName(final String flowName) { + this.flowName = flowName; + } + + @Schema(description = "The published version of the source Process Group.") + public String getVersion() { + return version; + } + + public void setVersion(final String version) { + this.version = version; + } + + @Schema(description = "Whether the source Process Group is currently in a state that allows it to be migrated into the target Connector. " + + "When false, ineligibilityReasons describes what must change before the migration can proceed.") + public boolean isReadyForMigration() { + return readyForMigration; + } + + public void setReadyForMigration(final boolean readyForMigration) { + this.readyForMigration = readyForMigration; + } + + @Schema(description = "User-facing descriptions of all conditions that currently prevent the source Process Group from being migrated. " + + "Empty when readyForMigration is true. Each entry describes a single remediable condition (running processors, queued FlowFiles, etc.); " + + "every applicable condition is included so the user can address them together.") + public List getIneligibilityReasons() { + return ineligibilityReasons; + } + + public void setIneligibilityReasons(final List ineligibilityReasons) { + this.ineligibilityReasons = ineligibilityReasons; + } +} diff --git a/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/entity/MigrationPayloadEntity.java b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/entity/MigrationPayloadEntity.java new file mode 100644 index 000000000000..2a2d716dd83e --- /dev/null +++ b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/entity/MigrationPayloadEntity.java @@ -0,0 +1,35 @@ +/* + * 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.web.api.entity; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.xml.bind.annotation.XmlRootElement; +import org.apache.nifi.web.api.dto.MigrationPayloadDTO; + +@XmlRootElement(name = "migrationPayloadEntity") +public class MigrationPayloadEntity extends Entity { + private MigrationPayloadDTO payload; + + @Schema(description = "The uploaded migration payload metadata.") + public MigrationPayloadDTO getPayload() { + return payload; + } + + public void setPayload(final MigrationPayloadDTO payload) { + this.payload = payload; + } +} diff --git a/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/entity/MigrationRequestEntity.java b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/entity/MigrationRequestEntity.java new file mode 100644 index 000000000000..7351b4c9b44a --- /dev/null +++ b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/entity/MigrationRequestEntity.java @@ -0,0 +1,35 @@ +/* + * 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.web.api.entity; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.xml.bind.annotation.XmlRootElement; +import org.apache.nifi.web.api.dto.MigrationRequestDTO; + +@XmlRootElement(name = "migrationRequestEntity") +public class MigrationRequestEntity extends Entity { + private MigrationRequestDTO request; + + @Schema(description = "The migration request.") + public MigrationRequestDTO getRequest() { + return request; + } + + public void setRequest(final MigrationRequestDTO request) { + this.request = request; + } +} diff --git a/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/entity/VersionedFlowMigrationSourcesEntity.java b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/entity/VersionedFlowMigrationSourcesEntity.java new file mode 100644 index 000000000000..6f426b2579a6 --- /dev/null +++ b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/entity/VersionedFlowMigrationSourcesEntity.java @@ -0,0 +1,37 @@ +/* + * 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.web.api.entity; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.xml.bind.annotation.XmlRootElement; +import org.apache.nifi.web.api.dto.VersionedFlowMigrationSourceDTO; + +import java.util.List; + +@XmlRootElement(name = "versionedFlowMigrationSourcesEntity") +public class VersionedFlowMigrationSourcesEntity extends Entity { + private List migrationSources; + + @Schema(description = "The Versioned Process Groups that the Connector can be migrated from.") + public List getMigrationSources() { + return migrationSources; + } + + public void setMigrationSources(final List migrationSources) { + this.migrationSources = migrationSources; + } +} diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/StandardHttpResponseMapper.java b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/StandardHttpResponseMapper.java index 6c2b43a3239b..22c19c34a4a5 100644 --- a/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/StandardHttpResponseMapper.java +++ b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/StandardHttpResponseMapper.java @@ -63,6 +63,7 @@ import org.apache.nifi.cluster.coordination.http.endpoints.LabelsEndpointMerger; import org.apache.nifi.cluster.coordination.http.endpoints.LatestProvenanceEventsMerger; import org.apache.nifi.cluster.coordination.http.endpoints.ListFlowFilesEndpointMerger; +import org.apache.nifi.cluster.coordination.http.endpoints.MigrationRequestEndpointMerger; import org.apache.nifi.cluster.coordination.http.endpoints.NarDetailsEndpointMerger; import org.apache.nifi.cluster.coordination.http.endpoints.NarSummariesEndpointMerger; import org.apache.nifi.cluster.coordination.http.endpoints.NarSummaryEndpointMerger; @@ -152,6 +153,7 @@ public StandardHttpResponseMapper(final NiFiProperties nifiProperties) { endpointMergers.add(new ConnectorFlowEndpointMerger()); endpointMergers.add(new ConnectorPropertyGroupEndpointMerger()); endpointMergers.add(new ConnectorPropertyGroupNamesEndpointMerger()); + endpointMergers.add(new MigrationRequestEndpointMerger()); endpointMergers.add(new VerifyConnectorConfigStepEndpointMerger()); endpointMergers.add(new ConnectionEndpointMerger()); endpointMergers.add(new ConnectionsEndpointMerger()); diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/endpoints/MigrationRequestEndpointMerger.java b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/endpoints/MigrationRequestEndpointMerger.java new file mode 100644 index 000000000000..10e3b78798e7 --- /dev/null +++ b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/endpoints/MigrationRequestEndpointMerger.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.cluster.coordination.http.endpoints; + +import org.apache.nifi.cluster.manager.NodeResponse; +import org.apache.nifi.cluster.protocol.NodeIdentifier; +import org.apache.nifi.web.api.dto.MigrationRequestDTO; +import org.apache.nifi.web.api.dto.MigrationUpdateStepDTO; +import org.apache.nifi.web.api.entity.MigrationRequestEntity; + +import java.net.URI; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +public class MigrationRequestEndpointMerger extends AbstractSingleEntityEndpoint { + public static final Pattern MIGRATION_REQUEST_URI_PATTERN = + Pattern.compile("/nifi-api/connectors/[a-f0-9\\-]{36}/migration-requests(/[a-f0-9\\-]{36})?"); + + @Override + protected Class getEntityClass() { + return MigrationRequestEntity.class; + } + + @Override + public boolean canHandle(final URI uri, final String method) { + return MIGRATION_REQUEST_URI_PATTERN.matcher(uri.getPath()).matches(); + } + + @Override + protected void mergeResponses(final MigrationRequestEntity clientEntity, final Map entityMap, + final Set successfulResponses, final Set problematicResponses) { + final MigrationRequestDTO clientRequest = clientEntity.getRequest(); + if (clientRequest == null) { + return; + } + + final List clientUpdateSteps = clientRequest.getUpdateSteps() == null ? List.of() : clientRequest.getUpdateSteps(); + + for (final Map.Entry nodeEntry : entityMap.entrySet()) { + final NodeIdentifier nodeId = nodeEntry.getKey(); + final MigrationRequestDTO nodeRequest = nodeEntry.getValue().getRequest(); + if (nodeRequest == null) { + continue; + } + + clientRequest.setComplete(clientRequest.isComplete() && nodeRequest.isComplete()); + + if (nodeRequest.getFailureReason() != null) { + final String nodeReason = "Node " + nodeId.getApiAddress() + ":" + nodeId.getApiPort() + ": " + nodeRequest.getFailureReason(); + final String existingReason = clientRequest.getFailureReason(); + clientRequest.setFailureReason(existingReason == null ? nodeReason : existingReason + "; " + nodeReason); + } + + // Surface the most recent activity across the cluster. percentCompleted and complete are merged as the + // worst case across nodes because they describe whether every node has finished, but lastUpdated is an + // activity timestamp: a polling client expects it to reflect the latest update from any node rather than + // the oldest, which would otherwise make the displayed timestamp appear to move backward as nodes report. + final Date clientLastUpdated = clientRequest.getLastUpdated(); + final Date nodeLastUpdated = nodeRequest.getLastUpdated(); + if (nodeLastUpdated != null && (clientLastUpdated == null || nodeLastUpdated.after(clientLastUpdated))) { + clientRequest.setLastUpdated(nodeLastUpdated); + } + + clientRequest.setPercentCompleted(Math.min(clientRequest.getPercentCompleted(), nodeRequest.getPercentCompleted())); + + mergeUpdateSteps(clientUpdateSteps, nodeRequest.getUpdateSteps()); + } + + // Recompute the human-readable state to reflect any failure surfaced by a remote node so that polling clients + // see a consistent picture rather than the arbitrary client node's view. + if (clientRequest.getFailureReason() != null) { + clientRequest.setState("Failed: " + clientRequest.getFailureReason()); + } else if (clientRequest.isComplete()) { + clientRequest.setState("Complete"); + } + } + + private void mergeUpdateSteps(final List clientSteps, final List nodeSteps) { + if (clientSteps == null || nodeSteps == null) { + return; + } + + final int sharedStepCount = Math.min(clientSteps.size(), nodeSteps.size()); + for (int stepIndex = 0; stepIndex < sharedStepCount; stepIndex++) { + final MigrationUpdateStepDTO clientStep = clientSteps.get(stepIndex); + final MigrationUpdateStepDTO nodeStep = nodeSteps.get(stepIndex); + + clientStep.setComplete(clientStep.isComplete() && nodeStep.isComplete()); + if (nodeStep.getFailureReason() != null && clientStep.getFailureReason() == null) { + clientStep.setFailureReason(nodeStep.getFailureReason()); + } + } + } +} diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/replication/ClusteredConnectorRequestReplicator.java b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/replication/ClusteredConnectorRequestReplicator.java index c0e51251065c..a09138903eb4 100644 --- a/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/replication/ClusteredConnectorRequestReplicator.java +++ b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/main/java/org/apache/nifi/cluster/coordination/http/replication/ClusteredConnectorRequestReplicator.java @@ -60,17 +60,22 @@ public ConnectorState getState(final String connectorId) throws IOException { try { final NodeResponse mergedNodeResponse = asyncResponse.awaitMergedResponse(); + final Entity updatedEntity = mergedNodeResponse.getUpdatedEntity(); final Response response = mergedNodeResponse.getClientResponse(); - final int statusCode = response.getStatusInfo().getStatusCode(); - logger.debug("getState: Connector [{}] — merged response status [{}] ({})", connectorId, statusCode, response.getStatusInfo().getReasonPhrase()); - - verifyResponse(response.getStatusInfo(), connectorId); + if (response != null) { + final int statusCode = response.getStatusInfo().getStatusCode(); + logger.debug("getState: Connector [{}] — merged response status [{}] ({})", connectorId, statusCode, response.getStatusInfo().getReasonPhrase()); + verifyResponse(response.getStatusInfo(), connectorId); + } else if (mergedNodeResponse.hasThrowable()) { + throw new IOException("Failed to retrieve Connector state for " + connectorId, mergedNodeResponse.getThrowable()); + } else if (!(updatedEntity instanceof ConnectorEntity)) { + throw new IOException("Received neither response nor merged Connector entity while retrieving state for Connector " + connectorId); + } // Use the merged/updated entity if available, otherwise fall back to reading from the raw response. // The updatedEntity contains the properly merged state from all nodes, while readEntity() would // only return the state from whichever single node was selected as the "client response". final ConnectorEntity connectorEntity; - final Entity updatedEntity = mergedNodeResponse.getUpdatedEntity(); if (updatedEntity instanceof final ConnectorEntity mergedConnectorEntity) { logger.debug("getState: Connector [{}] - using merged updatedEntity", connectorId); connectorEntity = mergedConnectorEntity; @@ -115,7 +120,7 @@ private void verifyResponse(final StatusType responseStatusType, final String co final String reason = responseStatusType.getReasonPhrase(); if (responseStatusType.getStatusCode() == Status.NOT_FOUND.getStatusCode()) { - throw new IllegalArgumentException("Connector with ID + " + connectorId + " does not exist"); + throw new IllegalArgumentException("Connector with ID " + connectorId + " does not exist"); } final Family responseFamily = responseStatusType.getFamily(); diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/test/java/org/apache/nifi/cluster/coordination/http/endpoints/MigrationRequestEndpointMergerTest.java b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/test/java/org/apache/nifi/cluster/coordination/http/endpoints/MigrationRequestEndpointMergerTest.java new file mode 100644 index 000000000000..223cfb1cec19 --- /dev/null +++ b/nifi-framework-bundle/nifi-framework/nifi-framework-cluster/src/test/java/org/apache/nifi/cluster/coordination/http/endpoints/MigrationRequestEndpointMergerTest.java @@ -0,0 +1,147 @@ +/* + * 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.cluster.coordination.http.endpoints; + +import org.apache.nifi.cluster.protocol.NodeIdentifier; +import org.apache.nifi.web.api.dto.MigrationRequestDTO; +import org.apache.nifi.web.api.dto.MigrationUpdateStepDTO; +import org.apache.nifi.web.api.entity.MigrationRequestEntity; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Date; +import java.util.LinkedHashMap; +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.assertTrue; + +public class MigrationRequestEndpointMergerTest { + + private MigrationRequestEndpointMerger merger; + + @BeforeEach + public void setUp() { + merger = new MigrationRequestEndpointMerger(); + } + + @Test + public void testMergeReflectsWorstCompletionState() { + final MigrationRequestEntity client = createMigrationRequest(true, null, 100, new Date(2_000L)); + final Map map = new LinkedHashMap<>(); + map.put(nodeId("node-1", 8080), createMigrationRequest(true, null, 100, new Date(2_000L))); + map.put(nodeId("node-2", 8081), createMigrationRequest(false, null, 40, new Date(1_500L))); + + merger.mergeResponses(client, map, null, null); + + // Any node still in progress drives the merged response to incomplete. + assertFalse(client.getRequest().isComplete()); + } + + @Test + public void testMergePropagatesFailureFromAnyNode() { + final MigrationRequestEntity client = createMigrationRequest(true, null, 100, new Date(2_000L)); + final Map map = new LinkedHashMap<>(); + map.put(nodeId("node-1", 8080), createMigrationRequest(true, null, 100, new Date(2_000L))); + map.put(nodeId("node-2", 8081), createMigrationRequest(true, "rollback failed", 100, new Date(2_500L))); + + merger.mergeResponses(client, map, null, null); + + assertNotNull(client.getRequest().getFailureReason()); + assertTrue(client.getRequest().getFailureReason().contains("rollback failed"), client.getRequest().getFailureReason()); + assertTrue(client.getRequest().getFailureReason().contains("node-2"), client.getRequest().getFailureReason()); + assertTrue(client.getRequest().getState().startsWith("Failed: "), client.getRequest().getState()); + } + + @Test + public void testMergePicksMinimumPercentComplete() { + final MigrationRequestEntity client = createMigrationRequest(false, null, 90, new Date(2_000L)); + final Map map = new LinkedHashMap<>(); + map.put(nodeId("node-1", 8080), createMigrationRequest(false, null, 60, new Date(2_500L))); + map.put(nodeId("node-2", 8081), createMigrationRequest(false, null, 25, new Date(2_700L))); + + merger.mergeResponses(client, map, null, null); + + assertEquals(25, client.getRequest().getPercentCompleted()); + } + + @Test + public void testMergeReflectsMostRecentLastUpdated() { + final MigrationRequestEntity client = createMigrationRequest(false, null, 90, new Date(2_000L)); + final Map map = new LinkedHashMap<>(); + map.put(nodeId("node-1", 8080), createMigrationRequest(false, null, 60, new Date(3_500L))); + map.put(nodeId("node-2", 8081), createMigrationRequest(false, null, 25, new Date(1_000L))); + + merger.mergeResponses(client, map, null, null); + + // The merged timestamp must reflect the latest activity across the cluster rather than the oldest, so a + // polling client never sees the displayed timestamp move backward as nodes report. + assertEquals(new Date(3_500L), client.getRequest().getLastUpdated()); + } + + @Test + public void testMergeAdoptsNodeLastUpdatedWhenClientHasNone() { + final MigrationRequestEntity client = createMigrationRequest(false, null, 90, null); + final Map map = new LinkedHashMap<>(); + map.put(nodeId("node-1", 8080), createMigrationRequest(false, null, 60, new Date(1_200L))); + + merger.mergeResponses(client, map, null, null); + + assertEquals(new Date(1_200L), client.getRequest().getLastUpdated()); + } + + @Test + public void testMergePropagatesPerStepFailures() { + final MigrationRequestEntity client = createMigrationRequest(true, null, 100, new Date(2_000L)); + final MigrationRequestEntity nodeFailure = createMigrationRequest(true, null, 100, new Date(2_500L)); + nodeFailure.getRequest().getUpdateSteps().get(0).setFailureReason("step failed on node"); + nodeFailure.getRequest().getUpdateSteps().get(0).setComplete(false); + + final Map map = new LinkedHashMap<>(); + map.put(nodeId("node-1", 8080), nodeFailure); + + merger.mergeResponses(client, map, null, null); + + final MigrationUpdateStepDTO mergedStep = client.getRequest().getUpdateSteps().get(0); + assertFalse(mergedStep.isComplete()); + assertEquals("step failed on node", mergedStep.getFailureReason()); + } + + private MigrationRequestEntity createMigrationRequest(final boolean complete, final String failureReason, final int percentComplete, final Date lastUpdated) { + final MigrationRequestDTO request = new MigrationRequestDTO(); + request.setComplete(complete); + request.setFailureReason(failureReason); + request.setPercentCompleted(percentComplete); + request.setLastUpdated(lastUpdated); + + final MigrationUpdateStepDTO step = new MigrationUpdateStepDTO(); + step.setDescription("Migrate Versioned Flow"); + step.setComplete(complete); + request.setUpdateSteps(List.of(step)); + + final MigrationRequestEntity entity = new MigrationRequestEntity(); + entity.setRequest(request); + return entity; + } + + private NodeIdentifier nodeId(final String hostname, final int port) { + return new NodeIdentifier(hostname + "-" + port, hostname, port, hostname, port + 1, hostname, port + 2, port + 3, false); + } +} diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/flow/synchronization/StandardVersionedComponentSynchronizer.java b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/flow/synchronization/StandardVersionedComponentSynchronizer.java index 61ae3a19a2cb..06442bd47579 100644 --- a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/flow/synchronization/StandardVersionedComponentSynchronizer.java +++ b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/flow/synchronization/StandardVersionedComponentSynchronizer.java @@ -63,7 +63,6 @@ import org.apache.nifi.flow.VersionedAsset; import org.apache.nifi.flow.VersionedComponent; import org.apache.nifi.flow.VersionedComponentState; -import org.apache.nifi.flow.VersionedConfigurableExtension; import org.apache.nifi.flow.VersionedConnection; import org.apache.nifi.flow.VersionedControllerService; import org.apache.nifi.flow.VersionedExternalFlow; @@ -4128,45 +4127,7 @@ private Map getPropertyValues(final ComponentNode componentNode) } private void validateLocalStateTopology(final VersionedProcessGroup proposed) { - final int connectedNodeCount = context.getConnectedNodeCount(); - if (connectedNodeCount <= 0) { - return; - } - - final int maxSourceNodes = findMaxLocalStateNodeCount(proposed); - if (maxSourceNodes > connectedNodeCount) { - throw new IllegalStateException( - "Cannot import flow with component state: the flow definition contains local state from %d source node(s) but the destination cluster has only %d connected node(s). " - .formatted(maxSourceNodes, connectedNodeCount) - + "Import into a cluster with at least %d node(s), or export without component state.".formatted(maxSourceNodes)); - } - } - - private int findMaxLocalStateNodeCount(final VersionedProcessGroup group) { - int max = 0; - for (final VersionedConfigurableExtension ext : getStatefulExtensions(group)) { - final VersionedComponentState state = ext.getComponentState(); - if (state != null && state.getLocalNodeStates() != null) { - max = Math.max(max, state.getLocalNodeStates().size()); - } - } - if (group.getProcessGroups() != null) { - for (final VersionedProcessGroup child : group.getProcessGroups()) { - max = Math.max(max, findMaxLocalStateNodeCount(child)); - } - } - return max; - } - - private List getStatefulExtensions(final VersionedProcessGroup group) { - final List extensions = new ArrayList<>(); - if (group.getProcessors() != null) { - extensions.addAll(group.getProcessors()); - } - if (group.getControllerServices() != null) { - extensions.addAll(group.getControllerServices()); - } - return extensions; + VersionedComponentStateValidator.validateLocalStateTopology(proposed, context.getConnectedNodeCount()); } private void restoreComponentState(final String componentId, final VersionedComponentState componentState, final ComponentNode componentNode) { diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/flow/synchronization/VersionedComponentStateValidator.java b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/flow/synchronization/VersionedComponentStateValidator.java new file mode 100644 index 000000000000..4610a4acb4e2 --- /dev/null +++ b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/flow/synchronization/VersionedComponentStateValidator.java @@ -0,0 +1,102 @@ +/* + * 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.flow.synchronization; + +import org.apache.nifi.flow.VersionedComponentState; +import org.apache.nifi.flow.VersionedConfigurableExtension; +import org.apache.nifi.flow.VersionedProcessGroup; + +import java.util.ArrayList; +import java.util.List; + +/** + * Shared helpers that inspect the LOCAL component state contained in an exported {@link VersionedProcessGroup} + * and validate it against the destination cluster topology before importing or migrating the flow. + * + *

This is the single source of truth for the cluster-topology rule used by both the standard versioned-flow + * synchronization path and the connector migration path. Both paths must reject a flow whose LOCAL state + * contains entries for more nodes than the destination cluster currently has connected, because there is no way + * to attribute the extra source-node states to a destination node.

+ */ +public final class VersionedComponentStateValidator { + + private VersionedComponentStateValidator() { + } + + /** + * Validates that the LOCAL component state inside the given proposed {@link VersionedProcessGroup} can be + * imported into a cluster that currently has the given number of connected nodes. + * + * @param proposed the proposed flow to validate + * @param connectedNodeCount the number of nodes that are currently connected to the destination cluster + * @throws IllegalStateException when any stateful component in the proposed flow has more local-node-state entries + * than the destination cluster has connected nodes + */ + public static void validateLocalStateTopology(final VersionedProcessGroup proposed, final int connectedNodeCount) { + if (proposed == null || connectedNodeCount <= 0) { + return; + } + + final int maxSourceNodes = findMaxLocalStateNodeCount(proposed); + if (maxSourceNodes > connectedNodeCount) { + throw new IllegalStateException( + "Cannot import flow with component state: the flow definition contains local state from %d source node(s) but the destination cluster has only %d connected node(s). " + .formatted(maxSourceNodes, connectedNodeCount) + + "Import into a cluster with at least %d node(s), or export without component state.".formatted(maxSourceNodes)); + } + } + + /** + * Returns the maximum number of local-node-state entries declared by any stateful component anywhere in the + * given {@link VersionedProcessGroup} hierarchy. Returns 0 when no component declares LOCAL state. + * + * @param group the root process group to inspect + * @return the maximum local-node-state count across the group hierarchy + */ + public static int findMaxLocalStateNodeCount(final VersionedProcessGroup group) { + if (group == null) { + return 0; + } + + int max = 0; + for (final VersionedConfigurableExtension extension : getStatefulExtensions(group)) { + final VersionedComponentState state = extension.getComponentState(); + if (state != null && state.getLocalNodeStates() != null) { + max = Math.max(max, state.getLocalNodeStates().size()); + } + } + + if (group.getProcessGroups() != null) { + for (final VersionedProcessGroup child : group.getProcessGroups()) { + max = Math.max(max, findMaxLocalStateNodeCount(child)); + } + } + + return max; + } + + private static List getStatefulExtensions(final VersionedProcessGroup group) { + final List extensions = new ArrayList<>(); + if (group.getProcessors() != null) { + extensions.addAll(group.getProcessors()); + } + if (group.getControllerServices() != null) { + extensions.addAll(group.getControllerServices()); + } + return extensions; + } +} diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/components/connector/ConnectorFlowSnapshotProvider.java b/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/components/connector/ConnectorFlowSnapshotProvider.java new file mode 100644 index 000000000000..773f0ed40f05 --- /dev/null +++ b/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/components/connector/ConnectorFlowSnapshotProvider.java @@ -0,0 +1,40 @@ +/* + * 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.registry.flow.RegisteredFlowSnapshot; + +/** + * Supplies {@link RegisteredFlowSnapshot} instances for Process Groups that are being inspected as + * candidate sources for Connector migration. Implementations delegate to the standard flow snapshot + * facility so that the migration manager and the listing path use a single mechanism for snapshot + * acquisition rather than reimplementing flow mapping in framework-core. + */ +@FunctionalInterface +public interface ConnectorFlowSnapshotProvider { + + /** + * Returns the current flow snapshot for the Process Group with the given identifier, optionally + * including referenced controller services and exported component state. + * + * @param processGroupId the identifier of the Process Group to snapshot + * @param includeReferencedServices whether to include controller services referenced from outside the group + * @param includeComponentState whether to include LOCAL and CLUSTER component state in the snapshot + * @return the flow snapshot + */ + RegisteredFlowSnapshot getCurrentFlowSnapshotByGroupId(String processGroupId, boolean includeReferencedServices, boolean includeComponentState); +} diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/components/connector/ConnectorMigrationManager.java b/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/components/connector/ConnectorMigrationManager.java new file mode 100644 index 000000000000..363c3dc685f2 --- /dev/null +++ b/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/components/connector/ConnectorMigrationManager.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.components.connector; + +import org.apache.nifi.flow.VersionedExternalFlow; + +import java.util.List; +import java.util.function.BooleanSupplier; + +/** + * Framework service that drives the eligibility-listing path used by the migration-sources REST endpoint and the + * actual migration path used by the migration-request REST endpoint. Connector translation logic is owned by the + * extension; framework concerns (eligibility gating, source disable/rename, rollback, cluster-topology validation, + * and atomic flag persistence) live here. + */ +public interface ConnectorMigrationManager { + + /** + * Lists the Versioned Process Groups on the canvas that the given Connector can use as a migration source. + * + * @param connectorId the identifier of the target Connector + * @return the eligible source Process Groups, after framework prerequisites and the Connector's own + * {@code isMigrationSupported(...)} filter have been applied + */ + List listMigrationSources(String connectorId); + + /** + * Verifies that the given Process Group satisfies every framework prerequisite required for a local-source + * migration into the given Connector. Throws an {@link IllegalStateException} with a state-specific + * diagnostic when any prerequisite is unmet. + * + * @param connectorId the identifier of the target Connector + * @param processGroupId the identifier of the source Process Group + */ + void verifyEligibility(String connectorId, String processGroupId); + + /** + * Verifies that the target Connector is itself ready to receive a migration, independent of any particular source. + * This asserts that the Connector is stopped and that its active flow has not been modified from its initial flow. + * It is invoked synchronously when a migration request is submitted so the caller sees an unready Connector + * reported immediately rather than only after the asynchronous migration task runs. Throws an + * {@link IllegalStateException} when the Connector is not in a state that can receive a migration. + * + * @param connectorId the identifier of the target Connector + */ + void verifyConnectorReadyForMigration(String connectorId); + + /** + * Migrates the target Connector by updating the Connector's own flow to mirror the configuration, parameters, and + * component state captured in the given source flow. When {@code processGroupId} is non-null the migration is + * treated as a local-source migration (and the source Process Group is disabled and renamed on success); when + * {@code processGroupId} is null the migration is treated as an uploaded-payload migration. On any failure during + * the Connector's {@code migrate(...)} call or the framework's post-migrate work, the Connector is rolled back to + * its initial-flow state and the source Process Group is left untouched. + * + * @param connectorId the identifier of the target Connector + * @param processGroupId the identifier of the source Process Group for local-source migration, or {@code null} + * for an uploaded-payload migration + * @param sourceFlow the source flow whose configuration, parameters, and component state the Connector should + * mirror into its own managed flow + * @throws FlowUpdateException when the migration cannot be completed + */ + default void migrateFromVersionedFlow(final String connectorId, final String processGroupId, final VersionedExternalFlow sourceFlow) throws FlowUpdateException { + migrateFromVersionedFlow(connectorId, processGroupId, sourceFlow, () -> false); + } + + /** + * Equivalent to {@link #migrateFromVersionedFlow(String, String, VersionedExternalFlow)} but additionally polls the + * given supplier at framework-controlled checkpoints so a caller-driven cancellation is observed promptly. The + * Connector's own {@code migrate(...)} call is not interrupted; cancellation only takes effect at the next + * framework checkpoint, after which the migration is rolled back as if it had failed. + * + * @param connectorId the identifier of the target Connector + * @param processGroupId the identifier of the source Process Group for local-source migration, or {@code null} + * for an uploaded-payload migration + * @param sourceFlow the source flow whose configuration, parameters, and component state the Connector should + * mirror into its own managed flow + * @param cancellationCheck a supplier that returns {@code true} when the caller has requested cancellation; the + * framework polls it at the natural boundaries between the Connector's {@code migrate(...)} + * call and the subsequent framework post-migrate work + * @throws FlowUpdateException when the migration cannot be completed + */ + void migrateFromVersionedFlow(String connectorId, String processGroupId, VersionedExternalFlow sourceFlow, + BooleanSupplier cancellationCheck) throws FlowUpdateException; +} diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/components/connector/ConnectorMigrationSource.java b/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/components/connector/ConnectorMigrationSource.java new file mode 100644 index 000000000000..567b1467b7c5 --- /dev/null +++ b/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/components/connector/ConnectorMigrationSource.java @@ -0,0 +1,130 @@ +/* + * 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 java.util.List; + +/** + * Describes a Versioned Process Group on the canvas that is eligible to be used as the source for a Connector + * migration. Returned by {@link ConnectorMigrationManager#listMigrationSources(String)} and surfaced to clients + * through the {@code GET /connectors/{id}/migration-sources} endpoint. + */ +public class ConnectorMigrationSource { + private String processGroupId; + private String processGroupName; + private String parentProcessGroupId; + private String registryClientId; + private String bucketId; + private String flowId; + private String flowName; + private String version; + private boolean readyForMigration; + private List ineligibilityReasons; + + public String getProcessGroupId() { + return processGroupId; + } + + public void setProcessGroupId(final String processGroupId) { + this.processGroupId = processGroupId; + } + + public String getProcessGroupName() { + return processGroupName; + } + + public void setProcessGroupName(final String processGroupName) { + this.processGroupName = processGroupName; + } + + public String getParentProcessGroupId() { + return parentProcessGroupId; + } + + public void setParentProcessGroupId(final String parentProcessGroupId) { + this.parentProcessGroupId = parentProcessGroupId; + } + + public String getRegistryClientId() { + return registryClientId; + } + + public void setRegistryClientId(final String registryClientId) { + this.registryClientId = registryClientId; + } + + public String getBucketId() { + return bucketId; + } + + public void setBucketId(final String bucketId) { + this.bucketId = bucketId; + } + + public String getFlowId() { + return flowId; + } + + public void setFlowId(final String flowId) { + this.flowId = flowId; + } + + public String getFlowName() { + return flowName; + } + + public void setFlowName(final String flowName) { + this.flowName = flowName; + } + + public String getVersion() { + return version; + } + + public void setVersion(final String version) { + this.version = version; + } + + /** + * Indicates whether the source Process Group is currently in a state from which migration can proceed. When + * {@code false}, {@link #getIneligibilityReasons()} lists every condition that must be remediated before the + * source can be migrated. + * + * @return whether the source is ready for migration + */ + public boolean isReadyForMigration() { + return readyForMigration; + } + + public void setReadyForMigration(final boolean readyForMigration) { + this.readyForMigration = readyForMigration; + } + + /** + * @return user-facing descriptions of every condition that currently prevents the source Process Group from being + * migrated. Empty when {@link #isReadyForMigration()} is {@code true}. The list contains all applicable + * conditions so the user can address them together. + */ + public List getIneligibilityReasons() { + return ineligibilityReasons; + } + + public void setIneligibilityReasons(final List ineligibilityReasons) { + this.ineligibilityReasons = ineligibilityReasons; + } +} diff --git a/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/components/connector/ConnectorNode.java b/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/components/connector/ConnectorNode.java index fd994aa8e79c..46c10627b7e7 100644 --- a/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/components/connector/ConnectorNode.java +++ b/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/components/connector/ConnectorNode.java @@ -23,6 +23,7 @@ import org.apache.nifi.components.DescribedValue; import org.apache.nifi.components.ValidationResult; import org.apache.nifi.components.VersionedComponent; +import org.apache.nifi.components.connector.migration.ConnectorMigrationContext; import org.apache.nifi.components.validation.ValidationState; import org.apache.nifi.components.validation.ValidationStatus; import org.apache.nifi.components.validation.ValidationTrigger; @@ -106,6 +107,32 @@ default void setCustomLoggingAttributes(final Map 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: + *
    + *
  1. resolves the managed component by its versioned identifier;
  2. + *
  3. 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(...)};
  4. + *
  5. writes the cluster-scope state, when the component declares {@link Scope#CLUSTER} on its + * {@code @Stateful} annotation;
  6. + *
  7. 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.
  8. + *
+ * 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

+ * + *
    + *
  1. {@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.
  2. + *
  3. {@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.
  4. + *
+ */ +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) {