This is an automated email from the ASF dual-hosted git repository.
exceptionfactory pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git
The following commit(s) were added to refs/heads/main by this push:
new 966830073d8 NIFI-15148 Add service support for Branch creation in
Registry Clients (#11072)
966830073d8 is described below
commit 966830073d8df9339c5c57de27d2f9c89038da6e
Author: Pierre Villard <[email protected]>
AuthorDate: Mon Jun 1 11:57:54 2026 -0700
NIFI-15148 Add service support for Branch creation in Registry Clients
(#11072)
Signed-off-by: David Handermann <[email protected]>
---
.../bitbucket/BitbucketRepositoryClient.java | 80 +++++++
.../bitbucket/BitbucketRepositoryClientTest.java | 57 ++++-
.../azure/devops/AzureDevOpsRepositoryClient.java | 72 ++++++
.../devops/AzureDevOpsRepositoryClientTest.java | 47 ++++
.../flow/git/AbstractGitFlowRegistryClient.java | 36 +++
.../flow/git/client/GitRepositoryClient.java | 15 ++
.../git/AbstractGitFlowRegistryClientTest.java | 108 ++++++++-
.../apache/nifi/github/GitHubRepositoryClient.java | 53 +++++
.../apache/nifi/gitlab/GitLabRepositoryClient.java | 35 +++
.../api/entity/CreateFlowBranchRequestEntity.java | 77 ++++++
.../flow/FlowAnalyzingRegistryClientNode.java | 6 +
.../flow/StandardFlowRegistryClientNode.java | 9 +
.../nifi/registry/flow/FlowRegistryClientNode.java | 1 +
.../org/apache/nifi/web/NiFiServiceFacade.java | 12 +
.../apache/nifi/web/StandardNiFiServiceFacade.java | 134 +++++++++++
.../org/apache/nifi/web/api/VersionsResource.java | 78 ++++++
.../org/apache/nifi/web/dao/FlowRegistryDAO.java | 3 +
.../nifi/web/dao/impl/StandardFlowRegistryDAO.java | 17 ++
.../nifi/web/StandardNiFiServiceFacadeTest.java | 228 ++++++++++++++++++
.../apache/nifi/web/api/TestVersionsResource.java | 265 ++++++++++++++++++++-
.../web/dao/impl/StandardFlowRegistryDAOTest.java | 127 ++++++++++
21 files changed, 1455 insertions(+), 5 deletions(-)
diff --git
a/nifi-extension-bundles/nifi-atlassian-bundle/nifi-atlassian-extensions/src/main/java/org/apache/nifi/atlassian/bitbucket/BitbucketRepositoryClient.java
b/nifi-extension-bundles/nifi-atlassian-bundle/nifi-atlassian-extensions/src/main/java/org/apache/nifi/atlassian/bitbucket/BitbucketRepositoryClient.java
index ca5c0066ab9..2972f0b40a2 100644
---
a/nifi-extension-bundles/nifi-atlassian-bundle/nifi-atlassian-extensions/src/main/java/org/apache/nifi/atlassian/bitbucket/BitbucketRepositoryClient.java
+++
b/nifi-extension-bundles/nifi-atlassian-bundle/nifi-atlassian-extensions/src/main/java/org/apache/nifi/atlassian/bitbucket/BitbucketRepositoryClient.java
@@ -50,6 +50,7 @@ import java.util.Collections;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
+import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.OptionalLong;
@@ -545,6 +546,85 @@ public class BitbucketRepositoryClient implements
GitRepositoryClient {
return getRequiredLatestCommit(branch, resolvedPath);
}
+ @Override
+ public void createBranch(final String newBranchName, final String
sourceBranch, final Optional<String> sourceCommitSha)
+ throws IOException, FlowRegistryException {
+ if (newBranchName == null || newBranchName.isBlank()) {
+ throw new IllegalArgumentException("Branch name must be
specified");
+ }
+ if (sourceBranch == null || sourceBranch.isBlank()) {
+ throw new IllegalArgumentException("Source branch must be
specified");
+ }
+
+ final String trimmedNewBranch = newBranchName.trim();
+ final String trimmedSourceBranch = sourceBranch.trim();
+
+ if (getBranches().contains(trimmedNewBranch)) {
+ throw new FlowRegistryException("Branch [%s] already
exists".formatted(trimmedNewBranch));
+ }
+
+ logger.info("Creating branch [{}] from [{}] in repository [{}]",
trimmedNewBranch, trimmedSourceBranch, repoName);
+
+ if (formFactor == BitbucketFormFactor.DATA_CENTER) {
+ createBranchDataCenter(trimmedNewBranch, trimmedSourceBranch,
sourceCommitSha);
+ } else {
+ createBranchCloud(trimmedNewBranch, trimmedSourceBranch,
sourceCommitSha);
+ }
+ }
+
+ private void createBranchCloud(final String newBranchName, final String
sourceBranch, final Optional<String> sourceCommitSha) throws
FlowRegistryException {
+ final String targetHash;
+ if (sourceCommitSha.isPresent() && !sourceCommitSha.get().isBlank()) {
+ targetHash = sourceCommitSha.get();
+ } else {
+ targetHash = getBranchHeadCloud(sourceBranch);
+ }
+
+ final URI uri =
getRepositoryUriBuilder().addPathSegment("refs").addPathSegment("branches").build();
+ final String json;
+ try {
+ json = objectMapper.writeValueAsString(Map.of(FIELD_NAME,
newBranchName, FIELD_TARGET, Map.of(FIELD_HASH, targetHash)));
+ } catch (final Exception e) {
+ throw new FlowRegistryException("Failed to serialize branch
creation request", e);
+ }
+
+ try (final HttpResponseEntity response =
this.webClient.getWebClientService()
+ .post()
+ .uri(uri)
+ .header(AUTHORIZATION_HEADER, authToken.getAuthzHeaderValue())
+ .header(CONTENT_TYPE_HEADER, "application/json")
+ .body(json)
+ .retrieve()) {
+ verifyStatusCode(response, "Error creating branch [%s] in
repository [%s]".formatted(newBranchName, repoName),
HttpURLConnection.HTTP_CREATED);
+ } catch (final IOException e) {
+ throw new FlowRegistryException("Failed closing Bitbucket create
branch response", e);
+ }
+ }
+
+ private void createBranchDataCenter(final String newBranchName, final
String sourceBranch, final Optional<String> sourceCommitSha) throws
FlowRegistryException {
+ final String startPoint = sourceCommitSha.filter(sha ->
!sha.isBlank()).orElse("refs/heads/" + sourceBranch);
+ final URI uri =
getRepositoryUriBuilder().addPathSegment("branches").build();
+
+ final String json;
+ try {
+ json = objectMapper.writeValueAsString(Map.of(FIELD_NAME,
newBranchName, "startPoint", startPoint));
+ } catch (final Exception e) {
+ throw new FlowRegistryException("Failed to serialize branch
creation request", e);
+ }
+
+ try (final HttpResponseEntity response =
this.webClient.getWebClientService()
+ .post()
+ .uri(uri)
+ .header(AUTHORIZATION_HEADER, authToken.getAuthzHeaderValue())
+ .header(CONTENT_TYPE_HEADER, "application/json")
+ .body(json)
+ .retrieve()) {
+ verifyStatusCode(response, "Error creating branch [%s] in
repository [%s]".formatted(newBranchName, repoName), HttpURLConnection.HTTP_OK);
+ } catch (final IOException e) {
+ throw new FlowRegistryException("Failed closing Bitbucket create
branch response", e);
+ }
+ }
+
@Override
public InputStream deleteContent(final String filePath, final String
commitMessage, final String branch) throws FlowRegistryException {
final String resolvedPath = getResolvedPath(filePath);
diff --git
a/nifi-extension-bundles/nifi-atlassian-bundle/nifi-atlassian-extensions/src/test/java/org/apache/nifi/atlassian/bitbucket/BitbucketRepositoryClientTest.java
b/nifi-extension-bundles/nifi-atlassian-bundle/nifi-atlassian-extensions/src/test/java/org/apache/nifi/atlassian/bitbucket/BitbucketRepositoryClientTest.java
index 168f516e4d2..962c48cd008 100644
---
a/nifi-extension-bundles/nifi-atlassian-bundle/nifi-atlassian-extensions/src/test/java/org/apache/nifi/atlassian/bitbucket/BitbucketRepositoryClientTest.java
+++
b/nifi-extension-bundles/nifi-atlassian-bundle/nifi-atlassian-extensions/src/test/java/org/apache/nifi/atlassian/bitbucket/BitbucketRepositoryClientTest.java
@@ -35,10 +35,12 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.stubbing.OngoingStubbing;
import java.io.ByteArrayInputStream;
+import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URI;
import java.nio.charset.StandardCharsets;
+import java.util.Optional;
import java.util.OptionalLong;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -126,14 +128,21 @@ class BitbucketRepositoryClientTest {
postAfterHeaders = mock(HttpRequestBodySpec.class);
lenient().when(webClientService.post()).thenReturn(postSpec);
lenient().when(postSpec.uri(any(URI.class))).thenReturn(postBodySpec);
+ lenient().when(postBodySpec.header(anyString(),
anyString())).thenReturn(postBodySpec);
lenient().when(postBodySpec.body(any(InputStream.class),
any(OptionalLong.class))).thenReturn(afterBody);
+ lenient().when(postBodySpec.body(anyString())).thenReturn(afterBody);
lenient().when(afterBody.header(anyString(),
anyString())).thenReturn(postAfterHeaders);
lenient().when(postAfterHeaders.header(anyString(),
anyString())).thenReturn(postAfterHeaders);
- OngoingStubbing<HttpResponseEntity> stubbing =
when(postAfterHeaders.retrieve());
+ OngoingStubbing<HttpResponseEntity> stubbing =
lenient().when(postAfterHeaders.retrieve());
for (final HttpResponseEntity response : responses) {
stubbing = stubbing.thenReturn(response);
}
+
+ OngoingStubbing<HttpResponseEntity> directStubbing =
lenient().when(afterBody.retrieve());
+ for (final HttpResponseEntity response : responses) {
+ directStubbing = directStubbing.thenReturn(response);
+ }
}
private HttpResponseEntity mockResponse(final int statusCode, final String
body) {
@@ -315,6 +324,52 @@ class BitbucketRepositoryClientTest {
assertEquals(RESULT_COMMIT_SHA, commitSha);
}
+ @Test
+ void testCreateBranchCloudSuccess() throws FlowRegistryException,
IOException {
+ stubGetChain(
+ branchListResponse(),
+ branchListResponse(),
+ branchHeadResponse(BRANCH_HEAD_SHA)
+ );
+ stubPostChain(createdResponse());
+
+ final BitbucketRepositoryClient client = buildCloudClient();
+ client.createBranch("feature", "main", Optional.empty());
+ }
+
+ @Test
+ void testCreateBranchCloudWithCommitSha() throws FlowRegistryException,
IOException {
+ stubGetChain(branchListResponse(), branchListResponse());
+ stubPostChain(createdResponse());
+
+ final BitbucketRepositoryClient client = buildCloudClient();
+ client.createBranch("feature", "main", Optional.of("abc123"));
+ }
+
+ @Test
+ void testCreateBranchCloudAlreadyExists() throws FlowRegistryException {
+ stubGetChain(branchListResponse(), branchListResponse());
+
+ final BitbucketRepositoryClient client = buildCloudClient();
+ final FlowRegistryException exception =
assertThrows(FlowRegistryException.class,
+ () -> client.createBranch("main", "main", Optional.empty()));
+ assertTrue(exception.getMessage().contains("already exists"));
+ }
+
+ @Test
+ void testCreateBranchBlankNameRejected() throws FlowRegistryException {
+ stubGetChain(branchListResponse());
+ final BitbucketRepositoryClient client = buildCloudClient();
+ assertThrows(IllegalArgumentException.class, () ->
client.createBranch(" ", "main", Optional.empty()));
+ }
+
+ @Test
+ void testCreateBranchBlankSourceRejected() throws FlowRegistryException {
+ stubGetChain(branchListResponse());
+ final BitbucketRepositoryClient client = buildCloudClient();
+ assertThrows(IllegalArgumentException.class, () ->
client.createBranch("feature", " ", Optional.empty()));
+ }
+
private BitbucketRepositoryClient buildDataCenterClient() throws
FlowRegistryException {
return BitbucketRepositoryClient.builder()
.clientId("test-client")
diff --git
a/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-registry-clients/src/main/java/org/apache/nifi/azure/devops/AzureDevOpsRepositoryClient.java
b/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-registry-clients/src/main/java/org/apache/nifi/azure/devops/AzureDevOpsRepositoryClient.java
index cf4d2b2ea69..c6c3bfb4410 100644
---
a/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-registry-clients/src/main/java/org/apache/nifi/azure/devops/AzureDevOpsRepositoryClient.java
+++
b/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-registry-clients/src/main/java/org/apache/nifi/azure/devops/AzureDevOpsRepositoryClient.java
@@ -121,6 +121,7 @@ public class AzureDevOpsRepositoryClient implements
GitRepositoryClient {
private static final String CHANGE_TYPE_DELETE = "delete";
private static final String CONTENT_TYPE_BASE64 = "base64encoded";
private static final int MAX_PUSH_ATTEMPTS = 3;
+ private static final String ZERO_OBJECT_ID =
"0000000000000000000000000000000000000000";
// Common query parameter names and values
private static final String VERSION_DESCRIPTOR_VERSION =
"versionDescriptor.version";
@@ -447,6 +448,75 @@ public class AzureDevOpsRepositoryClient implements
GitRepositoryClient {
.retrieve();
}
+ @Override
+ public void createBranch(final String newBranchName, final String
sourceBranch, final Optional<String> sourceCommitSha)
+ throws IOException, FlowRegistryException {
+ if (newBranchName == null || newBranchName.isBlank()) {
+ throw new IllegalArgumentException("Branch name must be
specified");
+ }
+ if (sourceBranch == null || sourceBranch.isBlank()) {
+ throw new IllegalArgumentException("Source branch must be
specified");
+ }
+
+ final String trimmedNewBranch = newBranchName.trim();
+ final String trimmedSourceBranch = sourceBranch.trim();
+
+ if (branchExists(trimmedNewBranch)) {
+ throw new FlowRegistryException("Branch [%s] already
exists".formatted(trimmedNewBranch));
+ }
+
+ final String baseCommitSha;
+ if (sourceCommitSha.isPresent() && !sourceCommitSha.get().isBlank()) {
+ baseCommitSha = sourceCommitSha.get();
+ } else {
+ baseCommitSha = fetchBranchHead(trimmedSourceBranch);
+ }
+
+ logger.info("Creating branch [{}] from [{}] at commit [{}] in repo
[{}]", trimmedNewBranch, trimmedSourceBranch, baseCommitSha, repoName);
+
+ final URI refsUri = getUriBuilder().addPathSegment(SEGMENT_REFS)
+ .addQueryParameter(API, API_VERSION)
+ .build();
+
+ final String json;
+ try {
+ json = MAPPER.writeValueAsString(List.of(new
CreateRefRequest(REFS_HEADS_PREFIX + trimmedNewBranch, ZERO_OBJECT_ID,
baseCommitSha)));
+ } catch (final Exception e) {
+ throw new FlowRegistryException("Failed to serialize branch
creation request", e);
+ }
+
+ final HttpResponseEntity response =
this.webClient.getWebClientService()
+ .post()
+ .uri(refsUri)
+ .header(AUTHORIZATION_HEADER, bearerToken())
+ .header(CONTENT_TYPE_HEADER,
MediaType.APPLICATION_JSON.getMediaType())
+ .body(json)
+ .retrieve();
+
+ if (response.statusCode() != HttpURLConnection.HTTP_OK) {
+ throw new FlowRegistryException("Failed to create branch [%s] in
repo [%s] - %s".formatted(trimmedNewBranch, repoName,
getErrorMessage(response)));
+ }
+ }
+
+ private boolean branchExists(final String branchName) throws
FlowRegistryException {
+ final URI refUri = getUriBuilder().addPathSegment(SEGMENT_REFS)
+ .addQueryParameter(PARAM_FILTER, FILTER_HEADS_PREFIX +
branchName)
+ .addQueryParameter(API, API_VERSION)
+ .build();
+ final JsonNode refResponse = executeGet(refUri);
+ final JsonNode values = refResponse.get(JSON_FIELD_VALUE);
+ if (values == null || !values.isArray()) {
+ return false;
+ }
+ for (final JsonNode ref : values) {
+ final String refName = ref.get(JSON_FIELD_NAME).asText();
+ if (refName.equals(REFS_HEADS_PREFIX + branchName)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
@Override
public InputStream deleteContent(final String filePath, final String
commitMessage, final String branch) throws FlowRegistryException, IOException {
final String path = getResolvedPath(filePath);
@@ -521,6 +591,8 @@ public class AzureDevOpsRepositoryClient implements
GitRepositoryClient {
private record NewContent(String content, String contentType) { }
+ private record CreateRefRequest(String name, String oldObjectId, String
newObjectId) { }
+
/**
* Create URI builder for accessing the repository.
*
diff --git
a/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-registry-clients/src/test/java/org/apache/nifi/azure/devops/AzureDevOpsRepositoryClientTest.java
b/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-registry-clients/src/test/java/org/apache/nifi/azure/devops/AzureDevOpsRepositoryClientTest.java
index ba6c1831de8..7168a879aa7 100644
---
a/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-registry-clients/src/test/java/org/apache/nifi/azure/devops/AzureDevOpsRepositoryClientTest.java
+++
b/nifi-extension-bundles/nifi-azure-bundle/nifi-azure-registry-clients/src/test/java/org/apache/nifi/azure/devops/AzureDevOpsRepositoryClientTest.java
@@ -42,6 +42,7 @@ import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URI;
import java.nio.charset.StandardCharsets;
+import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -303,4 +304,50 @@ class AzureDevOpsRepositoryClientTest {
assertTrue(body.contains("delete-commit"));
}
}
+
+ @Test
+ void testCreateBranchSuccess() throws FlowRegistryException, IOException {
+ final HttpResponseEntity emptyRefsResponse =
mockResponse(HttpURLConnection.HTTP_OK, "{\"value\":[]}");
+ stubGetChain(repoInfoResponse(), permissionsResponse(),
emptyRefsResponse, branchHeadResponse(BRANCH_HEAD_SHA));
+ stubPostChain(mockResponse(HttpURLConnection.HTTP_OK,
"{\"value\":[{\"name\":\"refs/heads/feature\"}]}"));
+
+ final AzureDevOpsRepositoryClient client = buildClient();
+ client.createBranch("feature", "main", Optional.empty());
+ }
+
+ @Test
+ void testCreateBranchWithSourceCommitSha() throws FlowRegistryException,
IOException {
+ final HttpResponseEntity emptyRefsResponse =
mockResponse(HttpURLConnection.HTTP_OK, "{\"value\":[]}");
+ stubGetChain(repoInfoResponse(), permissionsResponse(),
emptyRefsResponse);
+ stubPostChain(mockResponse(HttpURLConnection.HTTP_OK,
"{\"value\":[{\"name\":\"refs/heads/feature\"}]}"));
+
+ final AzureDevOpsRepositoryClient client = buildClient();
+ client.createBranch("feature", "main", Optional.of("abc123"));
+ }
+
+ @Test
+ void testCreateBranchAlreadyExists() throws FlowRegistryException {
+ final HttpResponseEntity existingBranchResponse =
mockResponse(HttpURLConnection.HTTP_OK,
+
"{\"value\":[{\"name\":\"refs/heads/feature\",\"objectId\":\"abc123\"}]}");
+ stubGetChain(repoInfoResponse(), permissionsResponse(),
existingBranchResponse);
+
+ final AzureDevOpsRepositoryClient client = buildClient();
+ final FlowRegistryException exception =
assertThrows(FlowRegistryException.class,
+ () -> client.createBranch("feature", "main",
Optional.empty()));
+ assertTrue(exception.getMessage().contains("already exists"));
+ }
+
+ @Test
+ void testCreateBranchBlankNameRejected() throws FlowRegistryException {
+ stubGetChain(repoInfoResponse(), permissionsResponse());
+ final AzureDevOpsRepositoryClient client = buildClient();
+ assertThrows(IllegalArgumentException.class, () ->
client.createBranch(" ", "main", Optional.empty()));
+ }
+
+ @Test
+ void testCreateBranchBlankSourceRejected() throws FlowRegistryException {
+ stubGetChain(repoInfoResponse(), permissionsResponse());
+ final AzureDevOpsRepositoryClient client = buildClient();
+ assertThrows(IllegalArgumentException.class, () ->
client.createBranch("feature", " ", Optional.empty()));
+ }
}
diff --git
a/nifi-extension-bundles/nifi-extension-utils/nifi-git-flow-registry/src/main/java/org/apache/nifi/registry/flow/git/AbstractGitFlowRegistryClient.java
b/nifi-extension-bundles/nifi-extension-utils/nifi-git-flow-registry/src/main/java/org/apache/nifi/registry/flow/git/AbstractGitFlowRegistryClient.java
index 740763b7296..2f4f7f1fe02 100644
---
a/nifi-extension-bundles/nifi-extension-utils/nifi-git-flow-registry/src/main/java/org/apache/nifi/registry/flow/git/AbstractGitFlowRegistryClient.java
+++
b/nifi-extension-bundles/nifi-extension-utils/nifi-git-flow-registry/src/main/java/org/apache/nifi/registry/flow/git/AbstractGitFlowRegistryClient.java
@@ -221,6 +221,42 @@ public abstract class AbstractGitFlowRegistryClient
extends AbstractFlowRegistry
return defaultBranch;
}
+ @Override
+ public void createBranch(final FlowRegistryClientConfigurationContext
context, final FlowVersionLocation sourceLocation, final String newBranchName)
+ throws FlowRegistryException, IOException {
+ if (StringUtils.isBlank(newBranchName)) {
+ throw new IllegalArgumentException("Branch name must be specified
when creating a new branch");
+ }
+
+ final GitRepositoryClient repositoryClient =
getRepositoryClient(context);
+ verifyWritePermissions(repositoryClient);
+
+ final String sourceBranch = resolveSourceBranch(context,
sourceLocation);
+ if (StringUtils.isBlank(sourceBranch)) {
+ throw new FlowRegistryException("Unable to determine source branch
for new branch creation");
+ }
+
+ final Optional<String> sourceCommitSha = sourceLocation == null ?
Optional.empty() : Optional.ofNullable(sourceLocation.getVersion());
+ final String trimmedBranchName = newBranchName.trim();
+ final String trimmedSourceBranch = sourceBranch.trim();
+
+ getLogger().info("Creating branch [{}] from branch [{}]",
trimmedBranchName, trimmedSourceBranch);
+
+ try {
+ repositoryClient.createBranch(trimmedBranchName,
trimmedSourceBranch, sourceCommitSha);
+ } catch (final UnsupportedOperationException e) {
+ throw new FlowRegistryException("Configured repository client does
not support branch creation", e);
+ }
+ }
+
+ private String resolveSourceBranch(final
FlowRegistryClientConfigurationContext context, final FlowVersionLocation
sourceLocation) {
+ if (sourceLocation != null &&
StringUtils.isNotBlank(sourceLocation.getBranch())) {
+ return sourceLocation.getBranch();
+ }
+ final String defaultBranch =
context.getProperty(REPOSITORY_BRANCH).getValue();
+ return StringUtils.isBlank(defaultBranch) ? null : defaultBranch;
+ }
+
@Override
public Set<FlowRegistryBucket> getBuckets(final
FlowRegistryClientConfigurationContext context, final String branch) throws
IOException, FlowRegistryException {
final GitRepositoryClient repositoryClient =
getRepositoryClient(context);
diff --git
a/nifi-extension-bundles/nifi-extension-utils/nifi-git-flow-registry/src/main/java/org/apache/nifi/registry/flow/git/client/GitRepositoryClient.java
b/nifi-extension-bundles/nifi-extension-utils/nifi-git-flow-registry/src/main/java/org/apache/nifi/registry/flow/git/client/GitRepositoryClient.java
index 6c2853c71b5..65abaf9055f 100644
---
a/nifi-extension-bundles/nifi-extension-utils/nifi-git-flow-registry/src/main/java/org/apache/nifi/registry/flow/git/client/GitRepositoryClient.java
+++
b/nifi-extension-bundles/nifi-extension-utils/nifi-git-flow-registry/src/main/java/org/apache/nifi/registry/flow/git/client/GitRepositoryClient.java
@@ -174,6 +174,21 @@ public interface GitRepositoryClient {
return deleteContent(filePath, commitMessage, branch);
}
+ /**
+ * Creates a new branch in the repository.
+ *
+ * @param newBranchName the name of the branch to create
+ * @param sourceBranch the name of the source branch
+ * @param sourceCommitSha optional commit SHA to use as the starting point
for the new branch. If empty, the head commit of the source branch should be
used.
+ * @throws IOException if an I/O error occurs
+ * @throws FlowRegistryException if a non-I/O error occurs
+ * @throws UnsupportedOperationException if the repository implementation
does not support branch creation
+ */
+ default void createBranch(final String newBranchName, final String
sourceBranch, final Optional<String> sourceCommitSha)
+ throws IOException, FlowRegistryException {
+ throw new UnsupportedOperationException("Branch creation is not
supported");
+ }
+
/**
* Closes any resources held by the client.
*
diff --git
a/nifi-extension-bundles/nifi-extension-utils/nifi-git-flow-registry/src/test/java/org/apache/nifi/registry/flow/git/AbstractGitFlowRegistryClientTest.java
b/nifi-extension-bundles/nifi-extension-utils/nifi-git-flow-registry/src/test/java/org/apache/nifi/registry/flow/git/AbstractGitFlowRegistryClientTest.java
index 21e85bc4ea2..ec98d490bee 100644
---
a/nifi-extension-bundles/nifi-extension-utils/nifi-git-flow-registry/src/test/java/org/apache/nifi/registry/flow/git/AbstractGitFlowRegistryClientTest.java
+++
b/nifi-extension-bundles/nifi-extension-utils/nifi-git-flow-registry/src/test/java/org/apache/nifi/registry/flow/git/AbstractGitFlowRegistryClientTest.java
@@ -22,7 +22,9 @@ import org.apache.nifi.components.PropertyDescriptor;
import org.apache.nifi.components.PropertyValue;
import org.apache.nifi.logging.ComponentLog;
import org.apache.nifi.registry.flow.FlowRegistryClientConfigurationContext;
+import org.apache.nifi.registry.flow.FlowRegistryClientInitializationContext;
import org.apache.nifi.registry.flow.FlowRegistryException;
+import org.apache.nifi.registry.flow.FlowVersionLocation;
import org.apache.nifi.registry.flow.git.client.GitCommit;
import org.apache.nifi.registry.flow.git.client.GitCreateContentRequest;
import org.apache.nifi.registry.flow.git.client.GitRepositoryClient;
@@ -38,8 +40,10 @@ import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
+import javax.net.ssl.SSLContext;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class AbstractGitFlowRegistryClientTest {
@@ -49,6 +53,7 @@ class AbstractGitFlowRegistryClientTest {
final TestGitRepositoryClient repositoryClient = new
TestGitRepositoryClient(true, true, Set.of("bucket-a", ".git"));
final AtomicReference<TestGitRepositoryClient> suppliedClient = new
AtomicReference<>(repositoryClient);
final TestGitFlowRegistryClient flowRegistryClient = new
TestGitFlowRegistryClient(() -> suppliedClient.getAndSet(null),
"[email protected]");
+ flowRegistryClient.initialize(createInitializationContext());
final FlowRegistryClientConfigurationContext context =
createContext("main", "[.].*");
final ComponentLog verificationLogger = new
MockComponentLog("test-component", this);
@@ -71,6 +76,7 @@ class AbstractGitFlowRegistryClientTest {
final TestGitFlowRegistryClient flowRegistryClient = new
TestGitFlowRegistryClient(() -> {
throw new FlowRegistryException("Authentication failed");
}, "[email protected]");
+ flowRegistryClient.initialize(createInitializationContext());
final FlowRegistryClientConfigurationContext context =
createContext("main", "[.].*");
final ComponentLog verificationLogger = new
MockComponentLog("test-component", this);
@@ -85,6 +91,7 @@ class AbstractGitFlowRegistryClientTest {
void verifyReadFailureSkipsBucketListing() throws Exception {
final TestGitRepositoryClient repositoryClient = new
TestGitRepositoryClient(false, false, Set.of());
final TestGitFlowRegistryClient flowRegistryClient = new
TestGitFlowRegistryClient(() -> repositoryClient, "[email protected]");
+ flowRegistryClient.initialize(createInitializationContext());
final FlowRegistryClientConfigurationContext context =
createContext("main", "[.].*");
final ComponentLog verificationLogger = new
MockComponentLog("test-component", this);
@@ -100,10 +107,11 @@ class AbstractGitFlowRegistryClientTest {
@Test
void verifyBucketListingFailureReported() throws Exception {
- final TestGitRepositoryClient repositoryClient = new
TestGitRepositoryClient(true, true, Set.of());
+ final TestGitRepositoryClient repositoryClient = new
TestGitRepositoryClient(true, true, Set.of("bucket-a"));
repositoryClient.setGetTopLevelDirectoryNamesException(new
FlowRegistryException("listing error"));
final TestGitFlowRegistryClient flowRegistryClient = new
TestGitFlowRegistryClient(() -> repositoryClient, "[email protected]");
+ flowRegistryClient.initialize(createInitializationContext());
final FlowRegistryClientConfigurationContext context =
createContext("main", "[.].*");
final ComponentLog verificationLogger = new
MockComponentLog("test-component", this);
@@ -118,6 +126,41 @@ class AbstractGitFlowRegistryClientTest {
assertTrue(repositoryClient.isClosed());
}
+ @Test
+ void createBranchDelegatesToRepositoryClient() throws Exception {
+ final TestGitRepositoryClient repositoryClient = new
TestGitRepositoryClient(true, true, Set.of("bucket-a"));
+ final TestGitFlowRegistryClient flowRegistryClient = new
TestGitFlowRegistryClient(() -> repositoryClient, "[email protected]");
+ flowRegistryClient.initialize(createInitializationContext());
+ final FlowRegistryClientConfigurationContext context =
createContext("main", "[.].*");
+
+ final FlowVersionLocation sourceLocation = new
FlowVersionLocation("source-branch", "bucket-a", "flow-x", "commit-1");
+
+ flowRegistryClient.createBranch(context, sourceLocation, " new-branch
");
+
+ assertEquals("new-branch", repositoryClient.getCreatedBranch());
+ assertEquals("source-branch",
repositoryClient.getCreatedBranchSource());
+ assertEquals(Optional.of("commit-1"),
repositoryClient.getCreatedBranchCommit());
+ }
+
+ @Test
+ void createBranchUnsupportedThrowsFlowRegistryException() throws Exception
{
+ final TestGitRepositoryClient repositoryClient = new
TestGitRepositoryClient(true, true, Set.of("bucket-a"));
+ repositoryClient.setBranchCreationUnsupported(true);
+
+ final TestGitFlowRegistryClient flowRegistryClient = new
TestGitFlowRegistryClient(() -> repositoryClient, "[email protected]");
+ flowRegistryClient.initialize(createInitializationContext());
+ final FlowRegistryClientConfigurationContext context =
createContext("main", "[.].*");
+
+ final FlowVersionLocation sourceLocation = new FlowVersionLocation();
+ sourceLocation.setBranch("source");
+
+ final FlowRegistryException exception =
assertThrows(FlowRegistryException.class,
+ () -> flowRegistryClient.createBranch(context, sourceLocation,
"new-branch"));
+
+ assertEquals("Configured repository client does not support branch
creation", exception.getMessage());
+ assertTrue(repositoryClient.getCreatedBranchCommit().isEmpty());
+ }
+
private FlowRegistryClientConfigurationContext createContext(final String
branch, final String exclusionPattern) {
final Map<PropertyDescriptor, PropertyValue> properties = Map.of(
AbstractGitFlowRegistryClient.REPOSITORY_BRANCH, new
MockPropertyValue(branch),
@@ -145,6 +188,25 @@ class AbstractGitFlowRegistryClientTest {
};
}
+ private FlowRegistryClientInitializationContext
createInitializationContext() {
+ return new FlowRegistryClientInitializationContext() {
+ @Override
+ public String getIdentifier() {
+ return "test-git-client";
+ }
+
+ @Override
+ public ComponentLog getLogger() {
+ return new MockComponentLog("test-git-client",
AbstractGitFlowRegistryClientTest.this);
+ }
+
+ @Override
+ public Optional<SSLContext> getSystemSslContext() {
+ return Optional.empty();
+ }
+ };
+ }
+
private static class TestGitFlowRegistryClient extends
AbstractGitFlowRegistryClient {
private final RepositoryClientSupplier repositoryClientSupplier;
private final String storageLocation;
@@ -186,6 +248,11 @@ class AbstractGitFlowRegistryClientTest {
private FlowRegistryException topLevelDirectoryNamesException;
private IOException topLevelDirectoryNamesIOException;
private boolean closed;
+ private boolean branchCreationUnsupported;
+ private FlowRegistryException createBranchException;
+ private String createdBranch;
+ private String createdBranchSource;
+ private Optional<String> createdBranchCommit = Optional.empty();
TestGitRepositoryClient(final boolean canRead, final boolean canWrite,
final Set<String> bucketNames) {
this.canRead = canRead;
@@ -198,11 +265,31 @@ class AbstractGitFlowRegistryClientTest {
this.topLevelDirectoryNamesIOException = null;
}
- void setGetTopLevelDirectoryNamesException(final IOException
exception) {
+ void setGetTopLevelDirectoryNamesIOException(final IOException
exception) {
this.topLevelDirectoryNamesIOException = exception;
this.topLevelDirectoryNamesException = null;
}
+ void setBranchCreationUnsupported(final boolean unsupported) {
+ this.branchCreationUnsupported = unsupported;
+ }
+
+ void setCreateBranchException(final FlowRegistryException exception) {
+ this.createBranchException = exception;
+ }
+
+ String getCreatedBranch() {
+ return createdBranch;
+ }
+
+ String getCreatedBranchSource() {
+ return createdBranchSource;
+ }
+
+ Optional<String> getCreatedBranchCommit() {
+ return createdBranchCommit;
+ }
+
boolean isClosed() {
return closed;
}
@@ -228,6 +315,21 @@ class AbstractGitFlowRegistryClientTest {
return bucketNames;
}
+ @Override
+ public void createBranch(final String newBranchName, final String
sourceBranch, final Optional<String> sourceCommitSha)
+ throws IOException, FlowRegistryException {
+ if (branchCreationUnsupported) {
+ throw new UnsupportedOperationException("Branch creation not
supported");
+ }
+ if (createBranchException != null) {
+ throw createBranchException;
+ }
+
+ createdBranch = newBranchName;
+ createdBranchSource = sourceBranch;
+ createdBranchCommit = sourceCommitSha;
+ }
+
@Override
public void close() {
closed = true;
@@ -270,7 +372,7 @@ class AbstractGitFlowRegistryClientTest {
@Override
public String createContent(final GitCreateContentRequest request) {
- throw new UnsupportedOperationException("Not required for test");
+ return "test-commit";
}
@Override
diff --git
a/nifi-extension-bundles/nifi-github-bundle/nifi-github-extensions/src/main/java/org/apache/nifi/github/GitHubRepositoryClient.java
b/nifi-extension-bundles/nifi-github-bundle/nifi-github-extensions/src/main/java/org/apache/nifi/github/GitHubRepositoryClient.java
index 0a023b8cda6..14c9616c35d 100644
---
a/nifi-extension-bundles/nifi-github-bundle/nifi-github-extensions/src/main/java/org/apache/nifi/github/GitHubRepositoryClient.java
+++
b/nifi-extension-bundles/nifi-github-bundle/nifi-github-extensions/src/main/java/org/apache/nifi/github/GitHubRepositoryClient.java
@@ -21,6 +21,7 @@ package org.apache.nifi.github;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
+import org.apache.commons.lang3.StringUtils;
import org.apache.nifi.logging.ComponentLog;
import org.apache.nifi.registry.flow.FlowRegistryException;
import org.apache.nifi.registry.flow.git.client.GitCommit;
@@ -472,6 +473,58 @@ public class GitHubRepositoryClient implements
GitRepositoryClient {
});
}
+ @Override
+ public void createBranch(final String newBranchName, final String
sourceBranch, final Optional<String> sourceCommitSha)
+ throws IOException, FlowRegistryException {
+ if (StringUtils.isBlank(newBranchName)) {
+ throw new IllegalArgumentException("Branch name must be
specified");
+ }
+ if (StringUtils.isBlank(sourceBranch)) {
+ throw new IllegalArgumentException("Source branch must be
specified");
+ }
+
+ final String trimmedNewBranch = newBranchName.trim();
+ final String trimmedSourceBranch = sourceBranch.trim();
+ final String newBranchRefPath = "heads/" + trimmedNewBranch;
+ final String sourceBranchRefPath = "heads/" + trimmedSourceBranch;
+
+ // FileNotFoundException indicates the branch does not exist, which is
the expected case.
+ // Other exceptions (FlowRegistryException, IOException) propagate as
communication errors.
+ try {
+ execute(() -> repository.getRef(newBranchRefPath));
+ throw new FlowRegistryException("Branch [" + trimmedNewBranch + "]
already exists");
+ } catch (final FileNotFoundException notFound) {
+ logger.debug("Branch [{}] does not exist and will be created",
trimmedNewBranch, notFound);
+ }
+
+ final GHRef sourceBranchRef;
+ try {
+ sourceBranchRef = execute(() ->
repository.getRef(sourceBranchRefPath));
+ } catch (final FileNotFoundException notFound) {
+ throw new FlowRegistryException("Source branch [" +
trimmedSourceBranch + "] does not exist", notFound);
+ }
+
+ final String baseCommitSha;
+ if (sourceCommitSha.isPresent()) {
+ final String requestedCommitSha = sourceCommitSha.get();
+ try {
+ baseCommitSha = execute(() ->
repository.getCommit(requestedCommitSha).getSHA1());
+ } catch (final FileNotFoundException notFound) {
+ throw new FlowRegistryException("Commit [" +
requestedCommitSha + "] was not found in the repository", notFound);
+ }
+ } else {
+ baseCommitSha = sourceBranchRef.getObject().getSha();
+ }
+
+ logger.info("Creating branch [{}] from [{}] at commit [{}] for
repository [{}]",
+ trimmedNewBranch, trimmedSourceBranch, baseCommitSha,
repository.getFullName());
+
+ execute(() -> {
+
repository.createRef(BRANCH_REF_PATTERN.formatted(trimmedNewBranch),
baseCommitSha);
+ return null;
+ });
+ }
+
private String getResolvedPath(final String path) {
return repoPath == null ? path : repoPath + "/" + path;
}
diff --git
a/nifi-extension-bundles/nifi-gitlab-bundle/nifi-gitlab-extensions/src/main/java/org/apache/nifi/gitlab/GitLabRepositoryClient.java
b/nifi-extension-bundles/nifi-gitlab-bundle/nifi-gitlab-extensions/src/main/java/org/apache/nifi/gitlab/GitLabRepositoryClient.java
index 14eeb0ede65..0d1d84952b4 100644
---
a/nifi-extension-bundles/nifi-gitlab-bundle/nifi-gitlab-extensions/src/main/java/org/apache/nifi/gitlab/GitLabRepositoryClient.java
+++
b/nifi-extension-bundles/nifi-gitlab-bundle/nifi-gitlab-extensions/src/main/java/org/apache/nifi/gitlab/GitLabRepositoryClient.java
@@ -21,6 +21,7 @@ import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import org.apache.commons.io.IOUtils;
+import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.nifi.logging.ComponentLog;
import org.apache.nifi.registry.flow.FlowRegistryException;
@@ -304,6 +305,40 @@ public class GitLabRepositoryClient implements
GitRepositoryClient {
});
}
+ @Override
+ public void createBranch(final String newBranchName, final String
sourceBranch, final Optional<String> sourceCommitSha)
+ throws IOException, FlowRegistryException {
+ if (StringUtils.isBlank(newBranchName)) {
+ throw new IllegalArgumentException("Branch name must be
specified");
+ }
+ if (StringUtils.isBlank(sourceBranch)) {
+ throw new IllegalArgumentException("Source branch must be
specified");
+ }
+
+ final String trimmedNewBranch = newBranchName.trim();
+ final String trimmedSourceBranch = sourceBranch.trim();
+ final RepositoryApi repositoryApi = gitLab.getRepositoryApi();
+
+ try {
+ repositoryApi.getBranch(projectPath, trimmedNewBranch);
+ throw new FlowRegistryException("Branch [%s] already
exists".formatted(trimmedNewBranch));
+ } catch (final GitLabApiException e) {
+ if (e.getHttpStatus() != HttpURLConnection.HTTP_NOT_FOUND) {
+ throw new FlowRegistryException("Failed to check existence of
branch [%s]".formatted(trimmedNewBranch), e);
+ }
+ logger.debug("Branch [{}] does not exist and will be created",
trimmedNewBranch);
+ }
+
+ final String ref = sourceCommitSha.filter(sha ->
!sha.isBlank()).orElse(trimmedSourceBranch);
+
+ logger.info("Creating branch [{}] from ref [{}] in repository [{}]",
trimmedNewBranch, ref, projectPath);
+
+ execute(() -> {
+ repositoryApi.createBranch(projectPath, trimmedNewBranch, ref);
+ return null;
+ });
+ }
+
@Override
public void close() {
gitLab.close();
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/entity/CreateFlowBranchRequestEntity.java
b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/entity/CreateFlowBranchRequestEntity.java
new file mode 100644
index 00000000000..8e57e8387ee
--- /dev/null
+++
b/nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/entity/CreateFlowBranchRequestEntity.java
@@ -0,0 +1,77 @@
+/*
+ * 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.RevisionDTO;
+
+@XmlRootElement(name = "createFlowBranchRequestEntity")
+public class CreateFlowBranchRequestEntity extends Entity {
+
+ private RevisionDTO processGroupRevision;
+ private String branch;
+ private String sourceBranch;
+ private String sourceVersion;
+ private Boolean disconnectedNodeAcknowledged;
+
+ @Schema(description = "The Revision of the Process Group under Version
Control")
+ public RevisionDTO getProcessGroupRevision() {
+ return processGroupRevision;
+ }
+
+ public void setProcessGroupRevision(final RevisionDTO
processGroupRevision) {
+ this.processGroupRevision = processGroupRevision;
+ }
+
+ @Schema(description = "The name of the new branch to create")
+ public String getBranch() {
+ return branch;
+ }
+
+ public void setBranch(final String branch) {
+ this.branch = branch;
+ }
+
+ @Schema(description = "The name of the source branch to create the new
branch from. Defaults to the branch currently tracking in NiFi.")
+ public String getSourceBranch() {
+ return sourceBranch;
+ }
+
+ public void setSourceBranch(final String sourceBranch) {
+ this.sourceBranch = sourceBranch;
+ }
+
+ @Schema(description = "The version on the source branch to use when
creating the new branch. Defaults to the version currently tracked by NiFi.")
+ public String getSourceVersion() {
+ return sourceVersion;
+ }
+
+ public void setSourceVersion(final String sourceVersion) {
+ this.sourceVersion = sourceVersion;
+ }
+
+ @Schema(description = "Acknowledges that this node is disconnected to
allow for mutable requests to proceed.")
+ public Boolean isDisconnectedNodeAcknowledged() {
+ return disconnectedNodeAcknowledged;
+ }
+
+ public void setDisconnectedNodeAcknowledged(final Boolean
disconnectedNodeAcknowledged) {
+ this.disconnectedNodeAcknowledged = disconnectedNodeAcknowledged;
+ }
+}
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/registry/flow/FlowAnalyzingRegistryClientNode.java
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/registry/flow/FlowAnalyzingRegistryClientNode.java
index 260830005b8..9e14ee7db71 100644
---
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/registry/flow/FlowAnalyzingRegistryClientNode.java
+++
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/registry/flow/FlowAnalyzingRegistryClientNode.java
@@ -452,6 +452,12 @@ public final class FlowAnalyzingRegistryClientNode
implements FlowRegistryClient
return node.getFlows(context, bucketLocation);
}
+ @Override
+ public void createBranch(final FlowRegistryClientUserContext context,
final FlowVersionLocation sourceLocation, final String newBranchName)
+ throws FlowRegistryException, IOException {
+ node.createBranch(context, sourceLocation, newBranchName);
+ }
+
@Override
public FlowSnapshotContainer getFlowContents(final
FlowRegistryClientUserContext context, final FlowVersionLocation
flowVersionLocation, final boolean fetchRemoteFlows)
throws FlowRegistryException, IOException {
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/registry/flow/StandardFlowRegistryClientNode.java
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/registry/flow/StandardFlowRegistryClientNode.java
index ccb1f08f9b9..a8ca4d9b753 100644
---
a/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/registry/flow/StandardFlowRegistryClientNode.java
+++
b/nifi-framework-bundle/nifi-framework/nifi-framework-components/src/main/java/org/apache/nifi/registry/flow/StandardFlowRegistryClientNode.java
@@ -241,6 +241,15 @@ public final class StandardFlowRegistryClientNode extends
AbstractComponentNode
return execute(() ->
client.get().getComponent().getFlows(getConfigurationContext(context),
bucketLocation));
}
+ @Override
+ public void createBranch(final FlowRegistryClientUserContext context,
final FlowVersionLocation sourceLocation, final String newBranchName)
+ throws FlowRegistryException, IOException {
+ execute(() -> {
+
client.get().getComponent().createBranch(getConfigurationContext(context),
sourceLocation, newBranchName);
+ return null;
+ });
+ }
+
@Override
public FlowSnapshotContainer getFlowContents(final
FlowRegistryClientUserContext context, final FlowVersionLocation
flowVersionLocation, final boolean fetchRemoteFlows)
throws FlowRegistryException, IOException {
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/registry/flow/FlowRegistryClientNode.java
b/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/registry/flow/FlowRegistryClientNode.java
index 968f3113853..e6af6beedab 100644
---
a/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/registry/flow/FlowRegistryClientNode.java
+++
b/nifi-framework-bundle/nifi-framework/nifi-framework-core-api/src/main/java/org/apache/nifi/registry/flow/FlowRegistryClientNode.java
@@ -53,6 +53,7 @@ public interface FlowRegistryClientNode extends ComponentNode
{
RegisteredFlow getFlow(FlowRegistryClientUserContext context, FlowLocation
flowLocation) throws FlowRegistryException, IOException;
Set<RegisteredFlow> getFlows(FlowRegistryClientUserContext context,
BucketLocation bucketLocation) throws FlowRegistryException, IOException;
+ void createBranch(FlowRegistryClientUserContext context,
FlowVersionLocation sourceLocation, String newBranchName) throws
FlowRegistryException, IOException;
FlowSnapshotContainer getFlowContents(FlowRegistryClientUserContext
context, FlowVersionLocation flowVersionLocation, boolean fetchRemoteFlows)
throws FlowRegistryException, IOException;
RegisteredFlowSnapshot registerFlowSnapshot(
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 11f33b91bd2..e156d0327a1 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
@@ -1797,6 +1797,18 @@ public interface NiFiServiceFacade {
VersionControlInformationEntity setVersionControlInformation(Revision
processGroupRevision, String processGroupId, VersionControlInformationDTO
versionControlInfo,
Map<String, String> versionedComponentMapping);
+ /**
+ * Creates a new branch in the associated Flow Registry for the specified
Process Group and updates the local Version Control information to track the
new branch.
+ *
+ * @param revision the revision for the Process Group
+ * @param processGroupId the Process Group identifier
+ * @param newBranchName the name of the new branch to create
+ * @param sourceBranch the branch to branch from
+ * @param sourceVersion the commit/version on the source branch to branch
from
+ * @return the updated Version Control information
+ */
+ VersionControlInformationEntity createFlowBranch(Revision revision, String
processGroupId, String newBranchName, String sourceBranch, String
sourceVersion);
+
/**
* Disconnects the specified Process Group from version control.
*
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 1a33481c2c7..59457f6da08 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
@@ -201,9 +201,11 @@ import org.apache.nifi.registry.flow.RegisterAction;
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.StandardVersionControlInformation;
import org.apache.nifi.registry.flow.VerifiableFlowRegistryClient;
import org.apache.nifi.registry.flow.VersionControlInformation;
import org.apache.nifi.registry.flow.VersionedFlowState;
+import org.apache.nifi.registry.flow.VersionedFlowStatus;
import org.apache.nifi.registry.flow.diff.ComparableDataFlow;
import org.apache.nifi.registry.flow.diff.ConciseEvolvingDifferenceDescriptor;
import org.apache.nifi.registry.flow.diff.DifferenceType;
@@ -6458,6 +6460,138 @@ public class StandardNiFiServiceFacade implements
NiFiServiceFacade {
return
entityFactory.createVersionControlInformationEntity(snapshot.getComponent(),
dtoFactory.createRevisionDTO(snapshot.getLastModification()));
}
+ @Override
+ public VersionControlInformationEntity createFlowBranch(final Revision
revision, final String processGroupId, final String newBranchName,
+ final String
sourceBranch, final String sourceVersion) {
+ final ProcessGroup group =
processGroupDAO.getProcessGroup(processGroupId);
+ final VersionControlInformation versionControlInformation =
group.getVersionControlInformation();
+
+ if (versionControlInformation == null) {
+ throw new IllegalStateException("Process Group with ID " +
processGroupId + " is not currently under Version Control");
+ }
+
+ if (StringUtils.isBlank(newBranchName)) {
+ throw new IllegalArgumentException("Branch name must be
specified");
+ }
+ final String trimmedBranchName = newBranchName.trim();
+ if (trimmedBranchName.equals(versionControlInformation.getBranch())) {
+ throw new IllegalArgumentException("Process Group is already
tracking branch " + trimmedBranchName);
+ }
+
+ final String resolvedSourceBranch =
StringUtils.isNotBlank(sourceBranch) ? sourceBranch :
versionControlInformation.getBranch();
+ if (StringUtils.isBlank(resolvedSourceBranch)) {
+ throw new IllegalArgumentException("Source branch must be
specified");
+ }
+
+ final String resolvedSourceVersion =
StringUtils.isNotBlank(sourceVersion) ? sourceVersion :
versionControlInformation.getVersion();
+
+ final FlowVersionLocation sourceLocation = new
FlowVersionLocation(resolvedSourceBranch,
+ versionControlInformation.getBucketIdentifier(),
+ versionControlInformation.getFlowIdentifier(),
+ resolvedSourceVersion);
+
+ final FlowRegistryClientUserContext clientUserContext =
FlowRegistryClientContextFactory.getContextForUser(NiFiUserUtils.getNiFiUser());
+
+ try {
+ flowRegistryDAO.createBranchForUser(clientUserContext,
versionControlInformation.getRegistryIdentifier(), sourceLocation,
trimmedBranchName);
+ } catch (final UnsupportedOperationException e) {
+ throw new IllegalArgumentException("Configured Flow Registry does
not support branch creation", e);
+ }
+
+ final VersionControlInformationDTO updatedVersionControlInformation =
new VersionControlInformationDTO();
+ updatedVersionControlInformation.setGroupId(processGroupId);
+
updatedVersionControlInformation.setRegistryId(versionControlInformation.getRegistryIdentifier());
+
updatedVersionControlInformation.setRegistryName(versionControlInformation.getRegistryName());
+
updatedVersionControlInformation.setBucketId(versionControlInformation.getBucketIdentifier());
+
updatedVersionControlInformation.setBucketName(versionControlInformation.getBucketName());
+
updatedVersionControlInformation.setFlowId(versionControlInformation.getFlowIdentifier());
+
updatedVersionControlInformation.setFlowName(versionControlInformation.getFlowName());
+
updatedVersionControlInformation.setFlowDescription(versionControlInformation.getFlowDescription());
+
updatedVersionControlInformation.setStorageLocation(versionControlInformation.getStorageLocation());
+ updatedVersionControlInformation.setBranch(trimmedBranchName);
+ updatedVersionControlInformation.setVersion(resolvedSourceVersion);
+
+ final VersionedFlowStatus status =
versionControlInformation.getStatus();
+ VersionedFlowState updatedState = null;
+ String stateExplanation = status == null ? null :
status.getStateExplanation();
+ if (status != null) {
+ final VersionedFlowState state = status.getState();
+ if (state != null) {
+ updatedState = switch (state) {
+ case LOCALLY_MODIFIED_AND_STALE -> {
+ stateExplanation = "Process Group has local
modifications";
+ yield VersionedFlowState.LOCALLY_MODIFIED;
+ }
+ case STALE -> VersionedFlowState.UP_TO_DATE;
+ default -> state;
+ };
+ }
+ }
+
+ if (updatedState != null) {
+ updatedVersionControlInformation.setState(updatedState.name());
+
updatedVersionControlInformation.setStateExplanation(stateExplanation);
+ }
+
+ final FlowManager flowManager = controllerFacade.getFlowManager();
+
+ VersionedProcessGroup registrySnapshot = null;
+ if (flowManager == null) {
+ logger.warn("Failed to synchronize Process Group {} with Flow
Registry after creating branch {} because Flow Manager is unavailable",
group.getIdentifier(), trimmedBranchName);
+ } else {
+ try {
+ final FlowRegistryClientNode registryClient =
flowManager.getFlowRegistryClient(versionControlInformation.getRegistryIdentifier());
+ if (registryClient == null) {
+ logger.warn("Unable to retrieve Flow Registry client with
identifier {} for Process Group {}",
versionControlInformation.getRegistryIdentifier(), group.getIdentifier());
+ } else {
+ final FlowVersionLocation branchLocation = new
FlowVersionLocation(trimmedBranchName,
+ versionControlInformation.getBucketIdentifier(),
+ versionControlInformation.getFlowIdentifier(),
+ resolvedSourceVersion);
+
+ final FlowSnapshotContainer snapshotContainer =
registryClient.getFlowContents(clientUserContext, branchLocation, false);
+ final RegisteredFlowSnapshot flowSnapshot =
snapshotContainer == null ? null : snapshotContainer.getFlowSnapshot();
+ if (flowSnapshot != null) {
+ registrySnapshot = flowSnapshot.getFlowContents();
+ }
+ }
+ } catch (final IOException | FlowRegistryException e) {
+ logger.warn("Failed to retrieve Flow Registry snapshot for
Process Group {}", group.getIdentifier(), e);
+ }
+ }
+
+ final RevisionUpdate<VersionControlInformationDTO> snapshot =
updateComponent(revision,
+ group,
+ () ->
processGroupDAO.updateVersionControlInformation(updatedVersionControlInformation,
Collections.emptyMap()),
+ processGroup ->
dtoFactory.createVersionControlInformationDto(processGroup));
+
+ final VersionControlInformation updatedVci =
group.getVersionControlInformation();
+ if (registrySnapshot != null) {
+ final StandardVersionControlInformation restoredInfo =
StandardVersionControlInformation.Builder.fromDto(updatedVersionControlInformation)
+ .flowSnapshot(registrySnapshot)
+ .build();
+
restoredInfo.setBucketName(updatedVersionControlInformation.getBucketName());
+
restoredInfo.setFlowName(updatedVersionControlInformation.getFlowName());
+
restoredInfo.setFlowDescription(updatedVersionControlInformation.getFlowDescription());
+
restoredInfo.setStorageLocation(updatedVersionControlInformation.getStorageLocation());
+
+ group.setVersionControlInformation(restoredInfo,
Collections.emptyMap());
+ } else if (updatedVci instanceof StandardVersionControlInformation
standardVci) {
+ standardVci.setFlowSnapshot(null);
+ }
+
+ if (flowManager != null) {
+ try {
+ group.synchronizeWithFlowRegistry(flowManager);
+ } catch (final Exception e) {
+ logger.warn("Failed to synchronize Process Group {} with Flow
Registry after creating branch {}", group.getIdentifier(), trimmedBranchName,
e);
+ }
+ }
+
+ final VersionControlInformationDTO refreshedDto =
dtoFactory.createVersionControlInformationDto(group);
+ return
entityFactory.createVersionControlInformationEntity(refreshedDto,
dtoFactory.createRevisionDTO(snapshot.getLastModification()));
+ }
+
@Override
public VersionControlInformationEntity deleteVersionControl(final Revision
revision, final String processGroupId) {
final ProcessGroup group =
processGroupDAO.getProcessGroup(processGroupId);
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/VersionsResource.java
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/VersionsResource.java
index 8a6de0bf959..2e32fcac7b3 100644
---
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/VersionsResource.java
+++
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/VersionsResource.java
@@ -64,6 +64,7 @@ import org.apache.nifi.web.api.dto.VersionedFlowDTO;
import org.apache.nifi.web.api.dto.VersionedFlowUpdateRequestDTO;
import org.apache.nifi.web.api.entity.AffectedComponentEntity;
import org.apache.nifi.web.api.entity.CreateActiveRequestEntity;
+import org.apache.nifi.web.api.entity.CreateFlowBranchRequestEntity;
import org.apache.nifi.web.api.entity.Entity;
import org.apache.nifi.web.api.entity.ProcessGroupEntity;
import org.apache.nifi.web.api.entity.StartVersionControlRequestEntity;
@@ -623,6 +624,83 @@ public class VersionsResource extends
FlowUpdateResource<VersionControlInformati
}
}
+ @POST
+ @Consumes(MediaType.APPLICATION_JSON)
+ @Produces(MediaType.APPLICATION_JSON)
+ @Path("process-groups/{id}/branches")
+ @Operation(
+ summary = "Creates a new branch for a version controlled Process
Group",
+ description = NON_GUARANTEED_ENDPOINT,
+ responses = {
+ @ApiResponse(responseCode = "200", content =
@Content(schema = @Schema(implementation =
VersionControlInformationEntity.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 -
/process-groups/{uuid}"),
+ @SecurityRequirement(name = "Write -
/process-groups/{uuid}")
+ }
+ )
+ public Response createFlowBranch(
+ @Parameter(description = "The process group id.") @PathParam("id")
final String groupId,
+ @Parameter(description = "The branch creation request.", required
= true) final CreateFlowBranchRequestEntity requestEntity) {
+
+ if (requestEntity == null) {
+ throw new IllegalArgumentException("Branch creation request must
be specified.");
+ }
+
+ final RevisionDTO revisionDto =
requestEntity.getProcessGroupRevision();
+ if (revisionDto == null) {
+ throw new IllegalArgumentException("Process Group Revision must be
specified");
+ }
+ if (StringUtils.isBlank(requestEntity.getBranch())) {
+ throw new IllegalArgumentException("Branch name must be
specified");
+ }
+
+ if (isReplicateRequest()) {
+ return replicate(HttpMethod.POST, requestEntity);
+ } else if (isDisconnectedFromCluster()) {
+
verifyDisconnectedNodeModification(requestEntity.isDisconnectedNodeAcknowledged());
+ }
+
+ final Revision requestRevision = getRevision(revisionDto, groupId);
+
+ return withWriteLock(
+ serviceFacade,
+ requestEntity,
+ requestRevision,
+ lookup -> {
+ final Authorizable processGroup =
lookup.getProcessGroup(groupId).getAuthorizable();
+ final NiFiUser user = NiFiUserUtils.getNiFiUser();
+ processGroup.authorize(authorizer, RequestAction.READ,
user);
+ processGroup.authorize(authorizer, RequestAction.WRITE,
user);
+ },
+ () -> {
+ final VersionControlInformationEntity
currentVersionControlInfo = serviceFacade.getVersionControlInformation(groupId);
+ if (currentVersionControlInfo == null ||
currentVersionControlInfo.getVersionControlInformation() == null) {
+ throw new IllegalStateException("Process Group with ID
" + groupId + " is not currently under Version Control");
+ }
+
+ final VersionControlInformationDTO currentInfo =
currentVersionControlInfo.getVersionControlInformation();
+ if
(VersionControlInformationDTO.SYNC_FAILURE.equals(currentInfo.getState())) {
+ throw new IllegalStateException("Process Group with ID
" + groupId + " cannot create a new branch while reporting Sync Failure");
+ }
+ },
+ (revision, entity) -> {
+ final VersionControlInformationEntity responseEntity =
serviceFacade.createFlowBranch(
+ revision,
+ groupId,
+ entity.getBranch(),
+ entity.getSourceBranch(),
+ entity.getSourceVersion());
+
+ return generateOkResponse(responseEntity).build();
+ });
+ }
+
private String lockVersionControl(final URI originalUri, final String
groupId) throws URISyntaxException {
final URI createRequestUri = new URI(originalUri.getScheme(),
originalUri.getUserInfo(), originalUri.getHost(),
originalUri.getPort(), "/nifi-api/versions/active-requests",
null, originalUri.getFragment());
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/dao/FlowRegistryDAO.java
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/dao/FlowRegistryDAO.java
index 3c572cf4bb6..a773a6838b2 100644
---
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/dao/FlowRegistryDAO.java
+++
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/dao/FlowRegistryDAO.java
@@ -21,6 +21,7 @@ import org.apache.nifi.registry.flow.FlowRegistryBranch;
import org.apache.nifi.registry.flow.FlowRegistryBucket;
import org.apache.nifi.registry.flow.FlowRegistryClientNode;
import org.apache.nifi.registry.flow.FlowRegistryClientUserContext;
+import org.apache.nifi.registry.flow.FlowVersionLocation;
import org.apache.nifi.registry.flow.RegisteredFlow;
import org.apache.nifi.registry.flow.RegisteredFlowSnapshotMetadata;
import org.apache.nifi.web.api.dto.ConfigVerificationResultDTO;
@@ -54,6 +55,8 @@ public interface FlowRegistryDAO {
Set<RegisteredFlowSnapshotMetadata>
getFlowVersionsForUser(FlowRegistryClientUserContext context, String branch,
String registryId, String bucketId, String flowId);
+ void createBranchForUser(FlowRegistryClientUserContext context, String
registryId, FlowVersionLocation sourceLocation, String newBranchName);
+
FlowRegistryClientNode removeFlowRegistry(String registryId);
void verifyConfigVerification(String registryId);
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/dao/impl/StandardFlowRegistryDAO.java
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/dao/impl/StandardFlowRegistryDAO.java
index 973b2c6839c..f405b9684a6 100644
---
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/dao/impl/StandardFlowRegistryDAO.java
+++
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/dao/impl/StandardFlowRegistryDAO.java
@@ -33,6 +33,7 @@ import org.apache.nifi.registry.flow.FlowRegistryBucket;
import org.apache.nifi.registry.flow.FlowRegistryClientNode;
import org.apache.nifi.registry.flow.FlowRegistryClientUserContext;
import org.apache.nifi.registry.flow.FlowRegistryException;
+import org.apache.nifi.registry.flow.FlowVersionLocation;
import org.apache.nifi.registry.flow.RegisteredFlow;
import org.apache.nifi.registry.flow.RegisteredFlowSnapshotMetadata;
import org.apache.nifi.util.BundleUtils;
@@ -208,6 +209,22 @@ public class StandardFlowRegistryDAO extends ComponentDAO
implements FlowRegistr
}
}
+ @Override
+ public void createBranchForUser(final FlowRegistryClientUserContext
context, final String registryId, final FlowVersionLocation sourceLocation,
final String newBranchName) {
+ final FlowRegistryClientNode flowRegistry =
flowController.getFlowManager().getFlowRegistryClient(registryId);
+ if (flowRegistry == null) {
+ throw new IllegalArgumentException("Registry ID [%s] not
found".formatted(registryId));
+ }
+
+ try {
+ flowRegistry.createBranch(context, sourceLocation, newBranchName);
+ } catch (final UnsupportedOperationException e) {
+ throw e;
+ } catch (final IOException | FlowRegistryException ioe) {
+ throw new NiFiCoreException("Unable to create branch [%s] in
registry with ID %s".formatted(newBranchName, registryId), ioe);
+ }
+ }
+
@Override
public Set<RegisteredFlowSnapshotMetadata> getFlowVersionsForUser(final
FlowRegistryClientUserContext context, final String registryId, final String
branch,
final
String bucketId, final String flowId) {
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 f034c3e0d5e..60600c72a84 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
@@ -34,6 +34,7 @@ import org.apache.nifi.authorization.resource.ResourceFactory;
import org.apache.nifi.authorization.resource.ResourceType;
import org.apache.nifi.authorization.user.NiFiUser;
import org.apache.nifi.authorization.user.NiFiUserDetails;
+import org.apache.nifi.authorization.user.StandardNiFiUser;
import org.apache.nifi.authorization.user.StandardNiFiUser.Builder;
import org.apache.nifi.components.PropertyDescriptor;
import org.apache.nifi.components.connector.ConnectorNode;
@@ -74,9 +75,17 @@ import org.apache.nifi.nar.ExtensionManager;
import org.apache.nifi.parameter.ParameterContext;
import org.apache.nifi.parameter.ParameterContextLookup;
import org.apache.nifi.processor.Processor;
+import org.apache.nifi.registry.flow.FlowRegistryClientNode;
+import org.apache.nifi.registry.flow.FlowRegistryClientUserContext;
+import org.apache.nifi.registry.flow.FlowRegistryException;
import org.apache.nifi.registry.flow.FlowRegistryUtil;
+import org.apache.nifi.registry.flow.FlowSnapshotContainer;
+import org.apache.nifi.registry.flow.FlowVersionLocation;
import org.apache.nifi.registry.flow.RegisteredFlowSnapshot;
+import org.apache.nifi.registry.flow.StandardVersionControlInformation;
import org.apache.nifi.registry.flow.VersionControlInformation;
+import org.apache.nifi.registry.flow.VersionedFlowState;
+import org.apache.nifi.registry.flow.VersionedFlowStatus;
import org.apache.nifi.registry.flow.diff.ComparableDataFlow;
import org.apache.nifi.registry.flow.diff.DifferenceType;
import org.apache.nifi.registry.flow.diff.FlowComparator;
@@ -110,6 +119,7 @@ import org.apache.nifi.web.api.dto.ParameterContextDTO;
import org.apache.nifi.web.api.dto.ProcessGroupDTO;
import org.apache.nifi.web.api.dto.RemoteProcessGroupDTO;
import org.apache.nifi.web.api.dto.RevisionDTO;
+import org.apache.nifi.web.api.dto.VersionControlInformationDTO;
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.search.SearchResultsDTO;
@@ -127,26 +137,32 @@ import org.apache.nifi.web.api.entity.SecretsEntity;
import org.apache.nifi.web.api.entity.StatusHistoryEntity;
import org.apache.nifi.web.api.entity.TenantEntity;
import org.apache.nifi.web.api.entity.TenantsEntity;
+import org.apache.nifi.web.api.entity.VersionControlInformationEntity;
import org.apache.nifi.web.controller.ControllerFacade;
import org.apache.nifi.web.dao.ComponentStateDAO;
import org.apache.nifi.web.dao.ConnectorDAO;
+import org.apache.nifi.web.dao.FlowRegistryDAO;
import org.apache.nifi.web.dao.ProcessGroupDAO;
import org.apache.nifi.web.dao.RemoteProcessGroupDAO;
import org.apache.nifi.web.dao.UserDAO;
import org.apache.nifi.web.dao.UserGroupDAO;
import org.apache.nifi.web.revision.NaiveRevisionManager;
+import org.apache.nifi.web.revision.RevisionClaim;
import org.apache.nifi.web.revision.RevisionManager;
import org.apache.nifi.web.revision.RevisionUpdate;
import org.apache.nifi.web.revision.StandardRevisionUpdate;
+import org.apache.nifi.web.revision.UpdateRevisionTask;
import org.apache.nifi.web.security.token.NiFiAuthenticationToken;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Answers;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
+import org.springframework.security.authentication.TestingAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
+import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.ArrayList;
@@ -185,7 +201,10 @@ import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@@ -2343,4 +2362,213 @@ public class StandardNiFiServiceFacadeTest {
assertThrows(ResourceNotFoundException.class, () ->
serviceFacade.getConnectorParameterContext(connectorId, processGroupId));
}
+
+ private StandardNiFiServiceFacade createBranchTestFacade(final
ProcessGroupDAO branchProcessGroupDAO, final FlowRegistryDAO flowRegistryDAO,
+ final DtoFactory
branchDtoFactory, final EntityFactory branchEntityFactory,
+ final FlowManager
branchFlowManager) {
+ final ControllerFacade branchControllerFacade =
mock(ControllerFacade.class);
+
lenient().when(branchControllerFacade.getFlowManager()).thenReturn(branchFlowManager);
+
+ final RevisionManager branchRevisionManager =
mock(RevisionManager.class);
+
lenient().when(branchRevisionManager.updateRevision(any(RevisionClaim.class),
any(NiFiUser.class), any(UpdateRevisionTask.class)))
+ .thenAnswer(invocation -> {
+ final UpdateRevisionTask<?> task =
invocation.getArgument(2);
+ return task.update();
+ });
+
lenient().when(branchRevisionManager.getRevision(anyString())).thenAnswer(invocation
-> {
+ final String componentId = invocation.getArgument(0, String.class);
+ return new Revision(1L, "client-1", componentId);
+ });
+
+ final StandardNiFiServiceFacade facade = new
StandardNiFiServiceFacade();
+ facade.setProcessGroupDAO(branchProcessGroupDAO);
+ facade.setFlowRegistryDAO(flowRegistryDAO);
+ facade.setDtoFactory(branchDtoFactory);
+ facade.setEntityFactory(branchEntityFactory);
+ facade.setControllerFacade(branchControllerFacade);
+ facade.setRevisionManager(branchRevisionManager);
+
+ final NiFiUser user = new
StandardNiFiUser.Builder().identity("unit-test").build();
+ final TestingAuthenticationToken authenticationToken = new
TestingAuthenticationToken(new NiFiUserDetails(user), null);
+
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
+
+ return facade;
+ }
+
+ @Test
+ public void testCreateFlowBranchSuccess() throws IOException,
FlowRegistryException {
+ final ProcessGroupDAO branchProcessGroupDAO =
mock(ProcessGroupDAO.class);
+ final FlowRegistryDAO flowRegistryDAO = mock(FlowRegistryDAO.class);
+ final DtoFactory branchDtoFactory = mock(DtoFactory.class);
+ final EntityFactory branchEntityFactory = mock(EntityFactory.class);
+ final FlowManager branchFlowManager = mock(FlowManager.class);
+
lenient().when(branchFlowManager.getFlowRegistryClient(anyString())).thenReturn(null);
+
+ final StandardNiFiServiceFacade facade =
createBranchTestFacade(branchProcessGroupDAO, flowRegistryDAO,
branchDtoFactory, branchEntityFactory, branchFlowManager);
+ final Revision revision = new Revision(1L, "client-1", "pg-1");
+
+ final ProcessGroup processGroup = mock(ProcessGroup.class);
+
when(branchProcessGroupDAO.getProcessGroup("pg-1")).thenReturn(processGroup);
+
+ final VersionControlInformation versionControlInformation =
mock(VersionControlInformation.class);
+
when(processGroup.getVersionControlInformation()).thenReturn(versionControlInformation);
+
when(versionControlInformation.getRegistryIdentifier()).thenReturn("registry-1");
+ when(versionControlInformation.getBranch()).thenReturn("main");
+
when(versionControlInformation.getBucketIdentifier()).thenReturn("bucket-1");
+
when(versionControlInformation.getFlowIdentifier()).thenReturn("flow-1");
+
when(versionControlInformation.getFlowDescription()).thenReturn("desc");
+ when(versionControlInformation.getFlowName()).thenReturn("name");
+ when(versionControlInformation.getStorageLocation()).thenReturn("loc");
+ when(versionControlInformation.getVersion()).thenReturn("1");
+
+ final VersionedFlowStatus flowStatus = mock(VersionedFlowStatus.class);
+ when(versionControlInformation.getStatus()).thenReturn(flowStatus);
+
when(flowStatus.getState()).thenReturn(VersionedFlowState.LOCALLY_MODIFIED_AND_STALE);
+ when(flowStatus.getStateExplanation()).thenReturn("Up to date");
+
+
when(branchProcessGroupDAO.updateVersionControlInformation(any(VersionControlInformationDTO.class),
eq(Collections.emptyMap())))
+ .thenReturn(processGroup);
+
+ final VersionControlInformationDTO updatedDto = new
VersionControlInformationDTO();
+ updatedDto.setBranch("feature");
+ updatedDto.setRegistryId("registry-1");
+
+ final VersionControlInformationDTO refreshedDto = new
VersionControlInformationDTO();
+ refreshedDto.setBranch("feature");
+ refreshedDto.setRegistryId("registry-1");
+ refreshedDto.setState(VersionControlInformationDTO.LOCALLY_MODIFIED);
+ refreshedDto.setStateExplanation("Process Group has local
modifications");
+
+
when(branchDtoFactory.createVersionControlInformationDto(processGroup)).thenReturn(updatedDto,
refreshedDto);
+
+ final VersionControlInformationEntity resultEntity = new
VersionControlInformationEntity();
+ resultEntity.setVersionControlInformation(refreshedDto);
+
when(branchEntityFactory.createVersionControlInformationEntity(eq(refreshedDto),
any(RevisionDTO.class))).thenReturn(resultEntity);
+
when(branchDtoFactory.createRevisionDTO(any(FlowModification.class))).thenReturn(new
RevisionDTO());
+
+ final FlowRegistryClientNode registryClient =
mock(FlowRegistryClientNode.class);
+
when(branchFlowManager.getFlowRegistryClient("registry-1")).thenReturn(registryClient);
+ final VersionedProcessGroup registrySnapshot = new
VersionedProcessGroup();
+ final RegisteredFlowSnapshot registeredFlowSnapshot = new
RegisteredFlowSnapshot();
+ registeredFlowSnapshot.setFlowContents(registrySnapshot);
+ final FlowSnapshotContainer snapshotContainer = new
FlowSnapshotContainer(registeredFlowSnapshot);
+ when(registryClient.getFlowContents(any(),
any(FlowVersionLocation.class), eq(false))).thenReturn(snapshotContainer);
+
+ final VersionControlInformationEntity response =
facade.createFlowBranch(revision, "pg-1", " feature ", null, null);
+ assertEquals(resultEntity, response);
+
+ final ArgumentCaptor<FlowVersionLocation> locationCaptor =
ArgumentCaptor.forClass(FlowVersionLocation.class);
+
verify(flowRegistryDAO).createBranchForUser(any(FlowRegistryClientUserContext.class),
eq("registry-1"), locationCaptor.capture(), eq("feature"));
+
+ final FlowVersionLocation capturedLocation = locationCaptor.getValue();
+ assertEquals("main", capturedLocation.getBranch());
+ assertEquals("bucket-1", capturedLocation.getBucketId());
+ assertEquals("flow-1", capturedLocation.getFlowId());
+ assertEquals("1", capturedLocation.getVersion());
+
+ verify(registryClient).getFlowContents(any(),
any(FlowVersionLocation.class), eq(false));
+ verify(processGroup).setVersionControlInformation(argThat(vci -> vci
instanceof StandardVersionControlInformation
+ && ((StandardVersionControlInformation) vci).getFlowSnapshot()
== registrySnapshot), eq(Collections.emptyMap()));
+ verify(processGroup).synchronizeWithFlowRegistry(branchFlowManager);
+ }
+
+ @Test
+ public void testCreateFlowBranchSameBranchRejected() {
+ final ProcessGroupDAO branchProcessGroupDAO =
mock(ProcessGroupDAO.class);
+ final FlowRegistryDAO flowRegistryDAO = mock(FlowRegistryDAO.class);
+ final FlowManager branchFlowManager = mock(FlowManager.class);
+ final StandardNiFiServiceFacade facade =
createBranchTestFacade(branchProcessGroupDAO, flowRegistryDAO,
mock(DtoFactory.class), mock(EntityFactory.class), branchFlowManager);
+ final Revision revision = new Revision(1L, "client-1", "pg-1");
+
+ final ProcessGroup processGroup = mock(ProcessGroup.class);
+
when(branchProcessGroupDAO.getProcessGroup("pg-1")).thenReturn(processGroup);
+
+ final VersionControlInformation versionControlInformation =
mock(VersionControlInformation.class);
+
when(processGroup.getVersionControlInformation()).thenReturn(versionControlInformation);
+ when(versionControlInformation.getBranch()).thenReturn("main");
+
+ assertThrows(IllegalArgumentException.class, () ->
facade.createFlowBranch(revision, "pg-1", "main", null, null));
+
+ verify(flowRegistryDAO, never()).createBranchForUser(any(), any(),
any(), any());
+ verify(processGroup,
never()).synchronizeWithFlowRegistry(any(FlowManager.class));
+ }
+
+ @Test
+ public void testCreateFlowBranchUnsupportedRegistry() throws IOException,
FlowRegistryException {
+ final ProcessGroupDAO branchProcessGroupDAO =
mock(ProcessGroupDAO.class);
+ final FlowRegistryDAO flowRegistryDAO = mock(FlowRegistryDAO.class);
+ final FlowManager branchFlowManager = mock(FlowManager.class);
+ final StandardNiFiServiceFacade facade =
createBranchTestFacade(branchProcessGroupDAO, flowRegistryDAO,
mock(DtoFactory.class), mock(EntityFactory.class), branchFlowManager);
+ final Revision revision = new Revision(1L, "client-1", "pg-1");
+
+ final ProcessGroup processGroup = mock(ProcessGroup.class);
+
when(branchProcessGroupDAO.getProcessGroup("pg-1")).thenReturn(processGroup);
+
+ final VersionControlInformation versionControlInformation =
mock(VersionControlInformation.class);
+
when(processGroup.getVersionControlInformation()).thenReturn(versionControlInformation);
+
when(versionControlInformation.getRegistryIdentifier()).thenReturn("registry-1");
+ when(versionControlInformation.getBranch()).thenReturn("main");
+
when(versionControlInformation.getBucketIdentifier()).thenReturn("bucket-1");
+
when(versionControlInformation.getFlowIdentifier()).thenReturn("flow-1");
+ when(versionControlInformation.getVersion()).thenReturn("1");
+
+ doThrow(new UnsupportedOperationException("not supported"))
+ .when(flowRegistryDAO)
+ .createBranchForUser(any(FlowRegistryClientUserContext.class),
eq("registry-1"), any(FlowVersionLocation.class), eq("feature"));
+
+ assertThrows(IllegalArgumentException.class, () ->
facade.createFlowBranch(revision, "pg-1", "feature", null, null));
+
+ verify(processGroup,
never()).synchronizeWithFlowRegistry(any(FlowManager.class));
+ }
+
+ @Test
+ public void testCreateFlowBranchNotVersionControlled() {
+ final ProcessGroupDAO branchProcessGroupDAO =
mock(ProcessGroupDAO.class);
+ final FlowRegistryDAO flowRegistryDAO = mock(FlowRegistryDAO.class);
+ final FlowManager branchFlowManager = mock(FlowManager.class);
+ final StandardNiFiServiceFacade facade =
createBranchTestFacade(branchProcessGroupDAO, flowRegistryDAO,
mock(DtoFactory.class), mock(EntityFactory.class), branchFlowManager);
+ final Revision revision = new Revision(1L, "client-1", "pg-1");
+
+ final ProcessGroup processGroup = mock(ProcessGroup.class);
+
when(branchProcessGroupDAO.getProcessGroup("pg-1")).thenReturn(processGroup);
+ when(processGroup.getVersionControlInformation()).thenReturn(null);
+
+ assertThrows(IllegalStateException.class, () ->
facade.createFlowBranch(revision, "pg-1", "feature", null, null));
+
+ verify(flowRegistryDAO, never()).createBranchForUser(any(), any(),
any(), any());
+ verify(processGroup,
never()).synchronizeWithFlowRegistry(any(FlowManager.class));
+ }
+
+ @Test
+ public void testCreateFlowBranchPropagatesRegistryErrors() throws
IOException, FlowRegistryException {
+ final ProcessGroupDAO branchProcessGroupDAO =
mock(ProcessGroupDAO.class);
+ final FlowRegistryDAO flowRegistryDAO = mock(FlowRegistryDAO.class);
+ final FlowManager branchFlowManager = mock(FlowManager.class);
+ final StandardNiFiServiceFacade facade =
createBranchTestFacade(branchProcessGroupDAO, flowRegistryDAO,
mock(DtoFactory.class), mock(EntityFactory.class), branchFlowManager);
+ final Revision revision = new Revision(1L, "client-1", "pg-1");
+
+ final ProcessGroup processGroup = mock(ProcessGroup.class);
+
when(branchProcessGroupDAO.getProcessGroup("pg-1")).thenReturn(processGroup);
+
+ final VersionControlInformation versionControlInformation =
mock(VersionControlInformation.class);
+
when(processGroup.getVersionControlInformation()).thenReturn(versionControlInformation);
+
when(versionControlInformation.getRegistryIdentifier()).thenReturn("registry-1");
+ when(versionControlInformation.getBranch()).thenReturn("main");
+
when(versionControlInformation.getBucketIdentifier()).thenReturn("bucket-1");
+
when(versionControlInformation.getFlowIdentifier()).thenReturn("flow-1");
+ when(versionControlInformation.getVersion()).thenReturn("1");
+
+ final FlowRegistryException cause = new FlowRegistryException("Branch
[feature] already exists");
+ doThrow(new NiFiCoreException("Unable to create branch [feature] in
registry with ID registry-1", cause))
+ .when(flowRegistryDAO)
+ .createBranchForUser(any(FlowRegistryClientUserContext.class),
eq("registry-1"), any(FlowVersionLocation.class), eq("feature"));
+
+ final NiFiCoreException exception =
assertThrows(NiFiCoreException.class, () -> facade.createFlowBranch(revision,
"pg-1", "feature", null, null));
+
+ assertTrue(exception.getMessage().contains("registry-1"));
+ assertTrue(exception.getMessage().contains("[feature]"));
+ assertEquals(cause, exception.getCause());
+
+ verify(processGroup,
never()).synchronizeWithFlowRegistry(any(FlowManager.class));
+ }
}
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/TestVersionsResource.java
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/TestVersionsResource.java
index 2526d640717..f5a1fd88f63 100644
---
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/TestVersionsResource.java
+++
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/TestVersionsResource.java
@@ -21,18 +21,36 @@ import org.apache.nifi.flow.VersionedProcessGroup;
import org.apache.nifi.registry.flow.FlowSnapshotContainer;
import org.apache.nifi.registry.flow.RegisteredFlowSnapshot;
import org.apache.nifi.registry.flow.RegisteredFlowSnapshotMetadata;
+import org.apache.nifi.util.NiFiProperties;
+import org.apache.nifi.web.NiFiCoreException;
import org.apache.nifi.web.NiFiServiceFacade;
+import org.apache.nifi.web.Revision;
+import org.apache.nifi.web.api.dto.RevisionDTO;
+import org.apache.nifi.web.api.dto.VersionControlInformationDTO;
+import org.apache.nifi.web.api.entity.CreateFlowBranchRequestEntity;
+import org.apache.nifi.web.api.entity.VersionControlInformationEntity;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.mock.web.MockHttpServletRequest;
+import java.net.HttpURLConnection;
import java.util.Collections;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -45,6 +63,17 @@ public class TestVersionsResource {
@Mock
private NiFiServiceFacade serviceFacade;
+ @Mock
+ private NiFiProperties properties;
+
+ @BeforeEach
+ public void setUp() {
+ versionsResource.setProperties(properties);
+ lenient().when(properties.isNode()).thenReturn(false);
+ lenient().when(properties.isClustered()).thenReturn(false);
+ versionsResource.httpServletRequest = new MockHttpServletRequest();
+ }
+
@Test
public void testExportFlowVersion() {
final String groupId = UUID.randomUUID().toString();
@@ -70,7 +99,7 @@ public class TestVersionsResource {
final RegisteredFlowSnapshot resultEntity = (RegisteredFlowSnapshot)
response.getEntity();
- assertEquals(200, response.getStatus());
+ assertEquals(HttpURLConnection.HTTP_OK, response.getStatus());
assertEquals(versionedFlowSnapshot, resultEntity);
verify(versionedFlowSnapshot).setFlow(null);
@@ -81,4 +110,238 @@ public class TestVersionsResource {
verify(innerInnerVersionedProcessGroup).setVersionedFlowCoordinates(null);
}
+ @Test
+ public void testCreateFlowBranchRequiresBranchName() {
+ final String groupId = UUID.randomUUID().toString();
+ final CreateFlowBranchRequestEntity requestEntity = new
CreateFlowBranchRequestEntity();
+ final RevisionDTO revisionDTO = new RevisionDTO();
+ revisionDTO.setClientId("client-id");
+ revisionDTO.setVersion(0L);
+ requestEntity.setProcessGroupRevision(revisionDTO);
+
+ assertThrows(IllegalArgumentException.class, () ->
versionsResource.createFlowBranch(groupId, requestEntity));
+ }
+
+ @Test
+ public void testCreateFlowBranchInvokesService() {
+ final String groupId = UUID.randomUUID().toString();
+ final CreateFlowBranchRequestEntity requestEntity = new
CreateFlowBranchRequestEntity();
+ final RevisionDTO revisionDTO = new RevisionDTO();
+ revisionDTO.setClientId("client-id");
+ revisionDTO.setVersion(1L);
+ requestEntity.setProcessGroupRevision(revisionDTO);
+ requestEntity.setBranch("feature");
+
+ versionsResource.httpServletRequest = new MockHttpServletRequest();
+
+ final VersionControlInformationDTO currentDto = new
VersionControlInformationDTO();
+ currentDto.setGroupId(groupId);
+ currentDto.setBranch("main");
+ currentDto.setVersion("3");
+ currentDto.setState(VersionControlInformationDTO.UP_TO_DATE);
+ final VersionControlInformationEntity currentEntity = new
VersionControlInformationEntity();
+ currentEntity.setVersionControlInformation(currentDto);
+
when(serviceFacade.getVersionControlInformation(groupId)).thenReturn(currentEntity);
+
+ final VersionControlInformationEntity expectedEntity = new
VersionControlInformationEntity();
+ when(serviceFacade.createFlowBranch(any(Revision.class), eq(groupId),
eq("feature"), any(), any()))
+ .thenReturn(expectedEntity);
+
+ final Response response = versionsResource.createFlowBranch(groupId,
requestEntity);
+ assertEquals(HttpURLConnection.HTTP_OK, response.getStatus());
+ assertEquals(expectedEntity, response.getEntity());
+
+ ArgumentCaptor<Revision> revisionCaptor =
ArgumentCaptor.forClass(Revision.class);
+ verify(serviceFacade).createFlowBranch(revisionCaptor.capture(),
eq(groupId), eq("feature"), isNull(), isNull());
+
+ final Revision capturedRevision = revisionCaptor.getValue();
+ assertEquals(1L, capturedRevision.getVersion());
+ assertEquals("client-id", capturedRevision.getClientId());
+ assertEquals(groupId, capturedRevision.getComponentId());
+ }
+
+ @Test
+ public void testCreateFlowBranchFailsWhenBranchExists() {
+ final String groupId = UUID.randomUUID().toString();
+ final CreateFlowBranchRequestEntity requestEntity = new
CreateFlowBranchRequestEntity();
+ final RevisionDTO revisionDTO = new RevisionDTO();
+ revisionDTO.setClientId("client-id");
+ revisionDTO.setVersion(1L);
+ requestEntity.setProcessGroupRevision(revisionDTO);
+ requestEntity.setBranch("main");
+
+ versionsResource.httpServletRequest = new MockHttpServletRequest();
+
+ final VersionControlInformationDTO currentDto = new
VersionControlInformationDTO();
+ currentDto.setGroupId(groupId);
+ currentDto.setBranch("main");
+ currentDto.setState(VersionControlInformationDTO.UP_TO_DATE);
+ final VersionControlInformationEntity currentEntity = new
VersionControlInformationEntity();
+ currentEntity.setVersionControlInformation(currentDto);
+
when(serviceFacade.getVersionControlInformation(groupId)).thenReturn(currentEntity);
+
+ when(serviceFacade.createFlowBranch(any(Revision.class), eq(groupId),
eq("main"), any(), any()))
+ .thenThrow(new IllegalArgumentException("Process Group is
already tracking branch main"));
+
+ assertThrows(IllegalArgumentException.class, () ->
versionsResource.createFlowBranch(groupId, requestEntity));
+ }
+
+ @Test
+ public void testCreateFlowBranchUnsupported() {
+ final String groupId = UUID.randomUUID().toString();
+ final CreateFlowBranchRequestEntity requestEntity = new
CreateFlowBranchRequestEntity();
+ final RevisionDTO revisionDTO = new RevisionDTO();
+ revisionDTO.setClientId("client-id");
+ revisionDTO.setVersion(1L);
+ requestEntity.setProcessGroupRevision(revisionDTO);
+ requestEntity.setBranch("feature");
+
+ versionsResource.httpServletRequest = new MockHttpServletRequest();
+
+ final VersionControlInformationDTO currentDto = new
VersionControlInformationDTO();
+ currentDto.setGroupId(groupId);
+ currentDto.setBranch("main");
+ currentDto.setVersion("2");
+ currentDto.setState(VersionControlInformationDTO.UP_TO_DATE);
+ final VersionControlInformationEntity currentEntity = new
VersionControlInformationEntity();
+ currentEntity.setVersionControlInformation(currentDto);
+
when(serviceFacade.getVersionControlInformation(groupId)).thenReturn(currentEntity);
+
+ when(serviceFacade.createFlowBranch(any(Revision.class), eq(groupId),
eq("feature"), any(), any()))
+ .thenThrow(new IllegalArgumentException("Registry does not
support branching"));
+
+ assertThrows(IllegalArgumentException.class, () ->
versionsResource.createFlowBranch(groupId, requestEntity));
+ }
+
+ @Test
+ public void testCreateFlowBranchAllowedWhenLocallyModified() {
+ final String groupId = UUID.randomUUID().toString();
+ final CreateFlowBranchRequestEntity requestEntity = new
CreateFlowBranchRequestEntity();
+ final RevisionDTO revisionDTO = new RevisionDTO();
+ revisionDTO.setClientId("client-id");
+ revisionDTO.setVersion(1L);
+ requestEntity.setProcessGroupRevision(revisionDTO);
+ requestEntity.setBranch("feature");
+
+ versionsResource.httpServletRequest = new MockHttpServletRequest();
+
+ final VersionControlInformationDTO currentDto = new
VersionControlInformationDTO();
+ currentDto.setGroupId(groupId);
+ currentDto.setBranch("main");
+ currentDto.setVersion("1");
+ currentDto.setState(VersionControlInformationDTO.LOCALLY_MODIFIED);
+ final VersionControlInformationEntity currentEntity = new
VersionControlInformationEntity();
+ currentEntity.setVersionControlInformation(currentDto);
+
when(serviceFacade.getVersionControlInformation(groupId)).thenReturn(currentEntity);
+
+ final VersionControlInformationEntity expectedEntity = new
VersionControlInformationEntity();
+ when(serviceFacade.createFlowBranch(any(Revision.class), eq(groupId),
eq("feature"), any(), any()))
+ .thenReturn(expectedEntity);
+
+ final Response response = versionsResource.createFlowBranch(groupId,
requestEntity);
+ assertEquals(HttpURLConnection.HTTP_OK, response.getStatus());
+ assertEquals(expectedEntity, response.getEntity());
+ }
+
+ @Test
+ public void testCreateFlowBranchBlockedWhenSyncFailure() {
+ final String groupId = UUID.randomUUID().toString();
+ final CreateFlowBranchRequestEntity requestEntity = new
CreateFlowBranchRequestEntity();
+ final RevisionDTO revisionDTO = new RevisionDTO();
+ revisionDTO.setClientId("client-id");
+ revisionDTO.setVersion(1L);
+ requestEntity.setProcessGroupRevision(revisionDTO);
+ requestEntity.setBranch("feature");
+
+ versionsResource.httpServletRequest = new MockHttpServletRequest();
+
+ final VersionControlInformationDTO currentDto = new
VersionControlInformationDTO();
+ currentDto.setGroupId(groupId);
+ currentDto.setBranch("main");
+ currentDto.setVersion("1");
+ currentDto.setState(VersionControlInformationDTO.SYNC_FAILURE);
+ final VersionControlInformationEntity currentEntity = new
VersionControlInformationEntity();
+ currentEntity.setVersionControlInformation(currentDto);
+
when(serviceFacade.getVersionControlInformation(groupId)).thenReturn(currentEntity);
+
+ assertThrows(IllegalStateException.class, () ->
versionsResource.createFlowBranch(groupId, requestEntity));
+
+ verify(serviceFacade, never()).createFlowBranch(any(), anyString(),
anyString(), any(), any());
+ }
+
+ @Test
+ public void testCreateFlowBranchAllowedWhenLocallyModifiedAndStale() {
+ final String groupId = UUID.randomUUID().toString();
+ final CreateFlowBranchRequestEntity requestEntity = new
CreateFlowBranchRequestEntity();
+ final RevisionDTO revisionDTO = new RevisionDTO();
+ revisionDTO.setClientId("client-id");
+ revisionDTO.setVersion(1L);
+ requestEntity.setProcessGroupRevision(revisionDTO);
+ requestEntity.setBranch("feature");
+
+ versionsResource.httpServletRequest = new MockHttpServletRequest();
+
+ final VersionControlInformationDTO currentDto = new
VersionControlInformationDTO();
+ currentDto.setGroupId(groupId);
+ currentDto.setBranch("main");
+ currentDto.setVersion("1");
+
currentDto.setState(VersionControlInformationDTO.LOCALLY_MODIFIED_AND_STALE);
+ final VersionControlInformationEntity currentEntity = new
VersionControlInformationEntity();
+ currentEntity.setVersionControlInformation(currentDto);
+
when(serviceFacade.getVersionControlInformation(groupId)).thenReturn(currentEntity);
+
+ final VersionControlInformationEntity expectedEntity = new
VersionControlInformationEntity();
+ when(serviceFacade.createFlowBranch(any(Revision.class), eq(groupId),
eq("feature"), any(), any()))
+ .thenReturn(expectedEntity);
+
+ final Response response = versionsResource.createFlowBranch(groupId,
requestEntity);
+ assertEquals(HttpURLConnection.HTTP_OK, response.getStatus());
+ assertEquals(expectedEntity, response.getEntity());
+ }
+
+ @Test
+ public void testCreateFlowBranchBranchAlreadyExists() {
+ final String groupId = UUID.randomUUID().toString();
+ final CreateFlowBranchRequestEntity requestEntity = new
CreateFlowBranchRequestEntity();
+ final RevisionDTO revisionDTO = new RevisionDTO();
+ revisionDTO.setClientId("client-id");
+ revisionDTO.setVersion(1L);
+ requestEntity.setProcessGroupRevision(revisionDTO);
+ requestEntity.setBranch("feature");
+
+ versionsResource.httpServletRequest = new MockHttpServletRequest();
+
+ final VersionControlInformationDTO currentDto = new
VersionControlInformationDTO();
+ currentDto.setGroupId(groupId);
+ currentDto.setBranch("main");
+ currentDto.setVersion("3");
+ currentDto.setState(VersionControlInformationDTO.UP_TO_DATE);
+ final VersionControlInformationEntity currentEntity = new
VersionControlInformationEntity();
+ currentEntity.setVersionControlInformation(currentDto);
+
when(serviceFacade.getVersionControlInformation(groupId)).thenReturn(currentEntity);
+
+ when(serviceFacade.createFlowBranch(any(Revision.class), eq(groupId),
eq("feature"), any(), any()))
+ .thenThrow(new NiFiCoreException("Unable to create branch
[feature] in registry with ID registry-1: Branch [feature] already exists"));
+
+ assertThrows(NiFiCoreException.class, () ->
versionsResource.createFlowBranch(groupId, requestEntity));
+ }
+
+ @Test
+ public void testCreateFlowBranchNotVersionControlled() {
+ final String groupId = UUID.randomUUID().toString();
+ final CreateFlowBranchRequestEntity requestEntity = new
CreateFlowBranchRequestEntity();
+ final RevisionDTO revisionDTO = new RevisionDTO();
+ revisionDTO.setClientId("client-id");
+ revisionDTO.setVersion(1L);
+ requestEntity.setProcessGroupRevision(revisionDTO);
+ requestEntity.setBranch("feature");
+
+ versionsResource.httpServletRequest = new MockHttpServletRequest();
+
+ final VersionControlInformationEntity currentEntity = new
VersionControlInformationEntity();
+ currentEntity.setVersionControlInformation(null);
+
when(serviceFacade.getVersionControlInformation(groupId)).thenReturn(currentEntity);
+
+ assertThrows(IllegalStateException.class, () ->
versionsResource.createFlowBranch(groupId, requestEntity));
+ }
}
diff --git
a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/dao/impl/StandardFlowRegistryDAOTest.java
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/dao/impl/StandardFlowRegistryDAOTest.java
new file mode 100644
index 00000000000..43f4d69377b
--- /dev/null
+++
b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/dao/impl/StandardFlowRegistryDAOTest.java
@@ -0,0 +1,127 @@
+/*
+ * 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.dao.impl;
+
+import org.apache.nifi.controller.FlowController;
+import org.apache.nifi.controller.flow.FlowManager;
+import org.apache.nifi.registry.flow.FlowRegistryClientNode;
+import org.apache.nifi.registry.flow.FlowRegistryClientUserContext;
+import org.apache.nifi.registry.flow.FlowRegistryException;
+import org.apache.nifi.registry.flow.FlowVersionLocation;
+import org.apache.nifi.web.NiFiCoreException;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.io.IOException;
+
+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.eq;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class StandardFlowRegistryDAOTest {
+
+ @Mock
+ private FlowController flowController;
+
+ @Mock
+ private FlowManager flowManager;
+
+ @Mock
+ private FlowRegistryClientUserContext userContext;
+
+ private StandardFlowRegistryDAO dao;
+
+ @BeforeEach
+ void setUp() {
+ dao = new StandardFlowRegistryDAO();
+ dao.setFlowController(flowController);
+
+ when(flowController.getFlowManager()).thenReturn(flowManager);
+ }
+
+ @Test
+ void testCreateBranchDelegatesToRegistryClient() throws IOException,
FlowRegistryException {
+ final FlowRegistryClientNode clientNode =
mock(FlowRegistryClientNode.class);
+
when(flowManager.getFlowRegistryClient("registry-1")).thenReturn(clientNode);
+
+ final FlowVersionLocation sourceLocation = new
FlowVersionLocation("main", "bucket", "flow", "1");
+
+ dao.createBranchForUser(userContext, "registry-1", sourceLocation,
"feature");
+
+ ArgumentCaptor<FlowVersionLocation> locationCaptor =
ArgumentCaptor.forClass(FlowVersionLocation.class);
+ verify(clientNode).createBranch(eq(userContext),
locationCaptor.capture(), eq("feature"));
+
+ final FlowVersionLocation capturedLocation = locationCaptor.getValue();
+ assertEquals("main", capturedLocation.getBranch());
+ assertEquals("bucket", capturedLocation.getBucketId());
+ assertEquals("flow", capturedLocation.getFlowId());
+ assertEquals("1", capturedLocation.getVersion());
+ }
+
+ @Test
+ void testCreateBranchUnsupported() throws IOException,
FlowRegistryException {
+ final FlowRegistryClientNode clientNode =
mock(FlowRegistryClientNode.class);
+
when(flowManager.getFlowRegistryClient("registry-1")).thenReturn(clientNode);
+ final FlowVersionLocation sourceLocation = new
FlowVersionLocation("main", "bucket", "flow", "1");
+
+ doThrow(new UnsupportedOperationException("not supported"))
+ .when(clientNode)
+ .createBranch(userContext, sourceLocation, "feature");
+
+ assertThrows(UnsupportedOperationException.class,
+ () -> dao.createBranchForUser(userContext, "registry-1",
sourceLocation, "feature"));
+ }
+
+ @Test
+ void testCreateBranchUnknownRegistry() {
+ when(flowManager.getFlowRegistryClient("missing")).thenReturn(null);
+
+ final FlowVersionLocation sourceLocation = new
FlowVersionLocation("main", "bucket", "flow", "1");
+ assertThrows(IllegalArgumentException.class,
+ () -> dao.createBranchForUser(userContext, "missing",
sourceLocation, "feature"));
+ }
+
+ @Test
+ void testCreateBranchFlowRegistryExceptionWrapped() throws IOException,
FlowRegistryException {
+ final FlowRegistryClientNode clientNode =
mock(FlowRegistryClientNode.class);
+
when(flowManager.getFlowRegistryClient("registry-1")).thenReturn(clientNode);
+
+ final FlowVersionLocation sourceLocation = new
FlowVersionLocation("main", "bucket", "flow", "1");
+ final FlowRegistryException cause = new FlowRegistryException("Branch
[feature] already exists");
+ doThrow(cause)
+ .when(clientNode)
+ .createBranch(userContext, sourceLocation, "feature");
+
+ final NiFiCoreException exception =
assertThrows(NiFiCoreException.class,
+ () -> dao.createBranchForUser(userContext, "registry-1",
sourceLocation, "feature"));
+
+ assertTrue(exception.getMessage().contains("registry-1"));
+ assertTrue(exception.getMessage().contains("[feature]"));
+ assertEquals(cause, exception.getCause());
+ }
+}