diff --git a/cdap-app-fabric/src/main/java/io/cdap/cdap/app/guice/AppFabricServiceRuntimeModule.java b/cdap-app-fabric/src/main/java/io/cdap/cdap/app/guice/AppFabricServiceRuntimeModule.java index 4dfdaf0f9873..43918e4b0cf3 100644 --- a/cdap-app-fabric/src/main/java/io/cdap/cdap/app/guice/AppFabricServiceRuntimeModule.java +++ b/cdap-app-fabric/src/main/java/io/cdap/cdap/app/guice/AppFabricServiceRuntimeModule.java @@ -116,6 +116,8 @@ import io.cdap.cdap.internal.app.services.RunRecordCorrectorService; import io.cdap.cdap.internal.app.services.RunRecordMonitorService; import io.cdap.cdap.internal.app.services.ScheduledRunRecordCorrectorService; +import io.cdap.cdap.internal.app.sourcecontrol.PullAppsOperationFactory; +import io.cdap.cdap.internal.app.sourcecontrol.PushAppsOperationFactory; import io.cdap.cdap.internal.app.store.DefaultStore; import io.cdap.cdap.internal.bootstrap.guice.BootstrapModules; import io.cdap.cdap.internal.capability.CapabilityModule; @@ -157,6 +159,7 @@ import io.cdap.cdap.security.impersonation.UGIProvider; import io.cdap.cdap.security.impersonation.UnsupportedUGIProvider; import io.cdap.cdap.security.store.SecureStoreHandler; +import io.cdap.cdap.sourcecontrol.ApplicationManager; import io.cdap.cdap.sourcecontrol.guice.SourceControlModule; import io.cdap.cdap.spi.events.StartProgramEvent; import io.cdap.http.HttpHandler; @@ -366,6 +369,12 @@ protected void configure() { .build(Key.get(ConfiguratorFactory.class, Names.named(AppFabric.FACTORY_IMPLEMENTATION_REMOTE))) ); + + bind(ApplicationManager.class).to( + io.cdap.cdap.internal.app.sourcecontrol.LocalApplicationManager.class); + install(new FactoryModuleBuilder().build(PullAppsOperationFactory.class)); + install(new FactoryModuleBuilder().build(PushAppsOperationFactory.class)); + // Used in InMemoryProgramRunDispatcher, TetheringClientHandler install(RemoteAuthenticatorModules.getDefaultModule( TetheringAgentService.REMOTE_TETHERING_AUTHENTICATOR, diff --git a/cdap-app-fabric/src/main/java/io/cdap/cdap/gateway/handlers/SourceControlManagementHttpHandler.java b/cdap-app-fabric/src/main/java/io/cdap/cdap/gateway/handlers/SourceControlManagementHttpHandler.java index 82622b1e7c3e..745b6aa0f4d9 100644 --- a/cdap-app-fabric/src/main/java/io/cdap/cdap/gateway/handlers/SourceControlManagementHttpHandler.java +++ b/cdap-app-fabric/src/main/java/io/cdap/cdap/gateway/handlers/SourceControlManagementHttpHandler.java @@ -34,7 +34,10 @@ import io.cdap.cdap.proto.ApplicationRecord; import io.cdap.cdap.proto.id.ApplicationReference; import io.cdap.cdap.proto.id.NamespaceId; +import io.cdap.cdap.proto.operation.OperationMeta; +import io.cdap.cdap.proto.sourcecontrol.PullMultipleAppsRequest; import io.cdap.cdap.proto.sourcecontrol.PushAppRequest; +import io.cdap.cdap.proto.sourcecontrol.PushMultipleAppsRequest; import io.cdap.cdap.proto.sourcecontrol.RemoteRepositoryValidationException; import io.cdap.cdap.proto.sourcecontrol.RepositoryConfigRequest; import io.cdap.cdap.proto.sourcecontrol.RepositoryConfigValidationException; @@ -183,6 +186,49 @@ public void pushApp(FullHttpRequest request, HttpResponder responder, } } + /** + * Pushes application configs of requested applications to linked repository in Json format. + * It expects a post body that has a list of application ids and an optional commit message + * E.g. + * + *
+   * {@code
+   * {
+   *   "appIds": ["app_id_1", "app_id_2"],
+   *   "commitMessage": "pushed application XYZ"
+   * }
+   * }
+   *
+   * 
+ * The response will be a {@link OperationMeta} object, which encapsulates the application name, + * version and fileHash. + */ + @POST + @Path("/apps/push") + public void pushApps(FullHttpRequest request, HttpResponder responder, + @PathParam("namespace-id") String namespaceId) throws Exception { + checkSourceControlMultiFeatureFlag(); + NamespaceId namespace = validateNamespaceId(namespaceId); + + PushMultipleAppsRequest appsRequest; + try { + appsRequest = parseBody(request, PushMultipleAppsRequest.class); + } catch (JsonSyntaxException e) { + throw new BadRequestException("Invalid request body.", e); + } + + if (appsRequest == null) { + throw new BadRequestException("Invalid request body."); + } + + if (Strings.isNullOrEmpty(appsRequest.getCommitMessage())) { + throw new BadRequestException("Please specify commit message in the request body."); + } + + OperationMeta operationMeta = sourceControlService.pushApps(namespace, appsRequest); + responder.sendJson(HttpResponseStatus.OK, GSON.toJson(operationMeta)); + } + /** * Pull the requested application from linked repository and deploy in current namespace. */ @@ -202,6 +248,31 @@ public void pullApp(FullHttpRequest request, HttpResponder responder, } } + /** + * Pull the requested applications from linked repository and deploy in current namespace. + */ + @POST + @Path("/apps/pull") + public void pullApps(FullHttpRequest request, HttpResponder responder, + @PathParam("namespace-id") String namespaceId) throws Exception { + checkSourceControlMultiFeatureFlag(); + NamespaceId namespace = validateNamespaceId(namespaceId); + + PullMultipleAppsRequest appsRequest; + try { + appsRequest = parseBody(request, PullMultipleAppsRequest.class); + } catch (JsonSyntaxException e) { + throw new BadRequestException("Invalid request body.", e); + } + + if (appsRequest == null) { + throw new BadRequestException("Invalid request body."); + } + + OperationMeta operationMeta = sourceControlService.pullApps(namespace, appsRequest); + responder.sendJson(HttpResponseStatus.OK, GSON.toJson(operationMeta)); + } + private PushAppRequest validateAndGetAppsRequest(FullHttpRequest request) throws BadRequestException { PushAppRequest appRequest; try { @@ -227,6 +298,13 @@ private void checkSourceControlFeatureFlag() throws ForbiddenException { } } + private void checkSourceControlMultiFeatureFlag() throws ForbiddenException { + checkSourceControlFeatureFlag(); + if (!Feature.SOURCE_CONTROL_MANAGEMENT_MULTIPLE_APPS.isEnabled(featureFlagsProvider)) { + throw new ForbiddenException("Source Control Management for multiple apps feature is not enabled."); + } + } + private NamespaceId validateNamespaceId(String namespaceId) throws BadRequestException { try { return new NamespaceId(namespaceId); diff --git a/cdap-app-fabric/src/main/java/io/cdap/cdap/internal/app/services/SourceControlManagementService.java b/cdap-app-fabric/src/main/java/io/cdap/cdap/internal/app/services/SourceControlManagementService.java index 9e24ecac06b2..e26bd047a723 100644 --- a/cdap-app-fabric/src/main/java/io/cdap/cdap/internal/app/services/SourceControlManagementService.java +++ b/cdap-app-fabric/src/main/java/io/cdap/cdap/internal/app/services/SourceControlManagementService.java @@ -16,6 +16,7 @@ package io.cdap.cdap.internal.app.services; +import com.google.common.util.concurrent.ListenableFuture; import com.google.inject.Inject; import io.cdap.cdap.api.artifact.ArtifactSummary; import io.cdap.cdap.api.security.store.SecureStore; @@ -26,6 +27,14 @@ import io.cdap.cdap.common.app.RunIds; import io.cdap.cdap.common.conf.CConfiguration; import io.cdap.cdap.internal.app.deploy.pipeline.ApplicationWithPrograms; +import io.cdap.cdap.internal.app.sourcecontrol.PullAppsOperation; +import io.cdap.cdap.internal.app.sourcecontrol.PullAppsOperationFactory; +import io.cdap.cdap.internal.app.sourcecontrol.PullAppsRequest; +import io.cdap.cdap.internal.app.sourcecontrol.PushAppsOperation; +import io.cdap.cdap.internal.app.sourcecontrol.PushAppsOperationFactory; +import io.cdap.cdap.internal.app.sourcecontrol.PushAppsRequest; +import io.cdap.cdap.internal.operation.OperationException; +import io.cdap.cdap.internal.operation.SynchronousLongRunningOperationContext; import io.cdap.cdap.proto.ApplicationDetail; import io.cdap.cdap.proto.ApplicationRecord; import io.cdap.cdap.proto.artifact.AppRequest; @@ -33,8 +42,13 @@ import io.cdap.cdap.proto.id.ApplicationReference; import io.cdap.cdap.proto.id.KerberosPrincipalId; import io.cdap.cdap.proto.id.NamespaceId; +import io.cdap.cdap.proto.operation.OperationMeta; +import io.cdap.cdap.proto.operation.OperationResource; +import io.cdap.cdap.proto.operation.OperationType; import io.cdap.cdap.proto.security.NamespacePermission; import io.cdap.cdap.proto.security.StandardPermission; +import io.cdap.cdap.proto.sourcecontrol.PullMultipleAppsRequest; +import io.cdap.cdap.proto.sourcecontrol.PushMultipleAppsRequest; import io.cdap.cdap.proto.sourcecontrol.RemoteRepositoryValidationException; import io.cdap.cdap.proto.sourcecontrol.RepositoryConfig; import io.cdap.cdap.proto.sourcecontrol.RepositoryMeta; @@ -62,7 +76,10 @@ import io.cdap.cdap.store.NamespaceTable; import io.cdap.cdap.store.RepositoryTable; import java.io.IOException; +import java.util.HashSet; import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ExecutionException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -79,6 +96,8 @@ public class SourceControlManagementService { private final SourceControlOperationRunner sourceControlOperationRunner; private final ApplicationLifecycleService appLifecycleService; private final Store store; + private final PushAppsOperationFactory pushAppsOperationFactory; + private final PullAppsOperationFactory pullAppsOperationFactory; private static final Logger LOG = LoggerFactory.getLogger(SourceControlManagementService.class); @@ -93,7 +112,8 @@ public SourceControlManagementService(CConfiguration cConf, AuthenticationContext authenticationContext, SourceControlOperationRunner sourceControlOperationRunner, ApplicationLifecycleService applicationLifecycleService, - Store store) { + Store store, PushAppsOperationFactory pushAppsOperationFactory, + PullAppsOperationFactory pullAppsOperationFactory) { this.cConf = cConf; this.secureStore = secureStore; this.transactionRunner = transactionRunner; @@ -102,6 +122,8 @@ public SourceControlManagementService(CConfiguration cConf, this.sourceControlOperationRunner = sourceControlOperationRunner; this.appLifecycleService = applicationLifecycleService; this.store = store; + this.pushAppsOperationFactory = pushAppsOperationFactory; + this.pullAppsOperationFactory = pullAppsOperationFactory; } private RepositoryTable getRepositoryTable(StructuredTableContext context) throws TableNotFoundException { @@ -203,7 +225,7 @@ public PushAppResponse pushApp(ApplicationReference appRef, String commitMessage // TODO: CDAP-20396 RepositoryConfig is currently only accessible from the service layer // Need to fix it and avoid passing it in RepositoryManagerFactory RepositoryConfig repoConfig = getRepositoryMeta(appRef.getParent()).getConfig(); - + // AppLifecycleService already enforces ApplicationDetail Access ApplicationDetail appDetail = appLifecycleService.getLatestAppDetail(appRef, false); @@ -224,7 +246,7 @@ public PushAppResponse pushApp(ApplicationReference appRef, String commitMessage appRef.getApplication(), appRef.getParent(), appLifecycleService.decodeUserId(authenticationContext)); - + SourceControlMeta sourceControlMeta = new SourceControlMeta(pushResponse.getFileHash()); ApplicationId appId = appRef.app(appDetail.getAppVersion()); store.setAppSourceControlMeta(appId, sourceControlMeta); @@ -251,7 +273,7 @@ public ApplicationRecord pullAndDeploy(ApplicationReference appRef) throws Excep accessEnforcer.enforce(appId, authenticationContext.getPrincipal(), StandardPermission.CREATE); accessEnforcer.enforce(appRef.getParent(), authenticationContext.getPrincipal(), NamespacePermission.READ_REPOSITORY); - + PullAppResponse pullResponse = pullAndValidateApplication(appRef); AppRequest appRequest = pullResponse.getAppRequest(); @@ -319,4 +341,73 @@ public RepositoryAppsResponse listApps(NamespaceId namespace) throws NotFoundExc RepositoryConfig repoConfig = getRepositoryMeta(namespace).getConfig(); return sourceControlOperationRunner.list(new NamespaceRepository(namespace, repoConfig)); } + + /** + * The method to push multiple applications in the same namespace to the linked repository. + * + * @param namespace {@link NamespaceId} from where the apps are to be pushed + * @param request {@link PushMultipleAppsRequest} containing the appIds and the commit message + * + * @return {@link OperationMeta} of the operation to push the apps + * @throws NoChangesToPushException when none of the apps have changed since last commit + * @throws NotFoundException when the repository or any of the apps are not found + * @throws InterruptedException when the push operation is inturrupted + * @throws ExecutionException when the push operation execution fails + * @throws OperationException for any exception occuring in the operation logic + */ + public OperationMeta pushApps(NamespaceId namespace, PushMultipleAppsRequest request) + throws NoChangesToPushException, NotFoundException, InterruptedException, + ExecutionException, OperationException { + accessEnforcer.enforce(namespace, authenticationContext.getPrincipal(), + NamespacePermission.WRITE_REPOSITORY); + RepositoryConfig repoConfig = getRepositoryMeta(namespace).getConfig(); + String committer = authenticationContext.getPrincipal().getName(); + PushAppsOperation pushOp = pushAppsOperationFactory.create(new PushAppsRequest( + new HashSet<>(request.getApps()), + repoConfig, + new CommitMeta(committer, committer, System.currentTimeMillis(), request.getCommitMessage()) + )); + + SynchronousLongRunningOperationContext operationContext = new SynchronousLongRunningOperationContext( + namespace.getNamespace(), + OperationType.PUSH_APPS + ); + ListenableFuture> result = pushOp.run(operationContext); + result.get(); + + return operationContext.getOperationMeta(); + } + + /** + * The method to pull multiple applications from the linked repository and deploy them in current namespace. + * + * @param namespace {@link NamespaceId} from where the apps are to be pushed + * @param request {@link PullMultipleAppsRequest} containing the appIds + * + * @return {@link OperationMeta} of the operation to push the apps + * @throws NotFoundException when the repository or any of the apps are not found + * @throws InterruptedException when the push operation is inturrupted + * @throws ExecutionException when the push operation execution fails + * @throws OperationException for any exception occuring in the operation logic + */ + public OperationMeta pullApps(NamespaceId namespace, PullMultipleAppsRequest request) + throws NotFoundException, InterruptedException, + ExecutionException, OperationException { + accessEnforcer.enforce(namespace, authenticationContext.getPrincipal(), + NamespacePermission.READ_REPOSITORY); + RepositoryConfig repoConfig = getRepositoryMeta(namespace).getConfig(); + PullAppsOperation pullOp = pullAppsOperationFactory.create(new PullAppsRequest( + new HashSet<>(request.getApps()), + repoConfig + )); + + SynchronousLongRunningOperationContext operationContext = new SynchronousLongRunningOperationContext( + namespace.getNamespace(), + OperationType.PULL_APPS + ); + ListenableFuture> result = pullOp.run(operationContext); + result.get(); + + return operationContext.getOperationMeta(); + } } diff --git a/cdap-app-fabric/src/main/java/io/cdap/cdap/internal/app/sourcecontrol/PushAppsOperationFactory.java b/cdap-app-fabric/src/main/java/io/cdap/cdap/internal/app/sourcecontrol/PushAppsOperationFactory.java index 6a3ce0f3e53d..0f3add565a09 100644 --- a/cdap-app-fabric/src/main/java/io/cdap/cdap/internal/app/sourcecontrol/PushAppsOperationFactory.java +++ b/cdap-app-fabric/src/main/java/io/cdap/cdap/internal/app/sourcecontrol/PushAppsOperationFactory.java @@ -16,7 +16,6 @@ package io.cdap.cdap.internal.app.sourcecontrol; - /** * Factory interface for creating {@link PushAppsOperation}. * This interface is for Guice assisted binding, hence there will be no concrete implementation of it. @@ -27,7 +26,7 @@ public interface PushAppsOperationFactory { * Returns an implementation of {@link PushAppsOperation} that operates on the given {@link * PushAppsRequest}. * - * @param request contains list of apps to pull + * @param request contains list of apps to push * @return a new instance of {@link PushAppsOperation}. */ PushAppsOperation create(PushAppsRequest request); diff --git a/cdap-app-fabric/src/main/java/io/cdap/cdap/internal/operation/AbstractLongRunningOperationContext.java b/cdap-app-fabric/src/main/java/io/cdap/cdap/internal/operation/AbstractLongRunningOperationContext.java index de054860b75a..0e55b81e664d 100644 --- a/cdap-app-fabric/src/main/java/io/cdap/cdap/internal/operation/AbstractLongRunningOperationContext.java +++ b/cdap-app-fabric/src/main/java/io/cdap/cdap/internal/operation/AbstractLongRunningOperationContext.java @@ -34,4 +34,14 @@ protected AbstractLongRunningOperationContext(OperationRunId runid, OperationTyp this.runId = runid; this.type = operationType; } + + @Override + public OperationRunId getRunId() { + return runId; + } + + @Override + public OperationType getType() { + return type; + } } diff --git a/cdap-app-fabric/src/main/java/io/cdap/cdap/internal/operation/SynchronousLongRunningOperationContext.java b/cdap-app-fabric/src/main/java/io/cdap/cdap/internal/operation/SynchronousLongRunningOperationContext.java new file mode 100644 index 000000000000..3846158efd6d --- /dev/null +++ b/cdap-app-fabric/src/main/java/io/cdap/cdap/internal/operation/SynchronousLongRunningOperationContext.java @@ -0,0 +1,44 @@ +/* + * Copyright © 2023 Cask Data, Inc. + * + * Licensed 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 io.cdap.cdap.internal.operation; + +import io.cdap.cdap.proto.id.OperationRunId; +import io.cdap.cdap.proto.operation.OperationMeta; +import io.cdap.cdap.proto.operation.OperationResource; +import io.cdap.cdap.proto.operation.OperationType; +import java.time.Instant; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + +public class SynchronousLongRunningOperationContext extends AbstractLongRunningOperationContext { + private OperationMeta operationMeta; + + public SynchronousLongRunningOperationContext(String namespace, OperationType operationType) { + super(new OperationRunId(namespace, UUID.randomUUID().toString()), operationType); + this.operationMeta = new OperationMeta(new HashSet<>(), Instant.now(), null); + } + + @Override + public void updateOperationResources(Set resources) { + operationMeta = new OperationMeta(resources, operationMeta.getCreateTime(), Instant.now()); + } + + public OperationMeta getOperationMeta() { + return operationMeta; + } +} diff --git a/cdap-app-fabric/src/test/java/io/cdap/cdap/internal/app/services/http/AppFabricTestBase.java b/cdap-app-fabric/src/test/java/io/cdap/cdap/internal/app/services/http/AppFabricTestBase.java index 930339247f94..32aeb5795c9a 100644 --- a/cdap-app-fabric/src/test/java/io/cdap/cdap/internal/app/services/http/AppFabricTestBase.java +++ b/cdap-app-fabric/src/test/java/io/cdap/cdap/internal/app/services/http/AppFabricTestBase.java @@ -109,7 +109,9 @@ import io.cdap.cdap.proto.id.ProgramId; import io.cdap.cdap.proto.id.ProgramReference; import io.cdap.cdap.proto.profile.Profile; +import io.cdap.cdap.proto.sourcecontrol.PullMultipleAppsRequest; import io.cdap.cdap.proto.sourcecontrol.PushAppRequest; +import io.cdap.cdap.proto.sourcecontrol.PushMultipleAppsRequest; import io.cdap.cdap.runtime.spi.profile.ProfileStatus; import io.cdap.cdap.scheduler.CoreSchedulerService; import io.cdap.cdap.scheduler.Scheduler; @@ -1493,12 +1495,26 @@ protected HttpResponse pushApplication(ApplicationReference appRef, String commi appRef.getNamespace(), appRef.getApplication()), GSON.toJson(request)); } + protected HttpResponse pushApplications(String namespace, List apps, String commitMessage) + throws Exception { + PushMultipleAppsRequest request = new PushMultipleAppsRequest(apps, commitMessage); + return doPost(String.format("%s/namespaces/%s/repository/apps/push", + Constants.Gateway.API_VERSION_3, namespace), GSON.toJson(request)); + } + protected HttpResponse pullApplication(ApplicationReference appRef) throws Exception { return doPost(String.format("%s/namespaces/%s/repository/apps/%s/pull", Constants.Gateway.API_VERSION_3, appRef.getNamespace(), appRef.getApplication())); } + protected HttpResponse pullApplications(String namespace, List apps) + throws Exception { + PullMultipleAppsRequest request = new PullMultipleAppsRequest(apps); + return doPost(String.format("%s/namespaces/%s/repository/apps/pull", + Constants.Gateway.API_VERSION_3, namespace), GSON.toJson(request)); + } + protected HttpResponse listApplicationsFromRepository(String namespace) throws Exception { return doGet(String.format("%s/namespaces/%s/repository/apps", Constants.Gateway.API_VERSION_3, namespace)); } diff --git a/cdap-app-fabric/src/test/java/io/cdap/cdap/internal/app/services/http/handlers/SourceControlManagementHttpHandlerTests.java b/cdap-app-fabric/src/test/java/io/cdap/cdap/internal/app/services/http/handlers/SourceControlManagementHttpHandlerTests.java index 5dceb237b273..4812dc1151e9 100644 --- a/cdap-app-fabric/src/test/java/io/cdap/cdap/internal/app/services/http/handlers/SourceControlManagementHttpHandlerTests.java +++ b/cdap-app-fabric/src/test/java/io/cdap/cdap/internal/app/services/http/handlers/SourceControlManagementHttpHandlerTests.java @@ -28,14 +28,19 @@ import io.cdap.cdap.common.NotFoundException; import io.cdap.cdap.common.conf.CConfiguration; import io.cdap.cdap.common.id.Id; +import io.cdap.cdap.common.id.Id.Namespace; import io.cdap.cdap.features.Feature; import io.cdap.cdap.gateway.handlers.SourceControlManagementHttpHandler; import io.cdap.cdap.internal.app.services.ApplicationLifecycleService; import io.cdap.cdap.internal.app.services.SourceControlManagementService; import io.cdap.cdap.internal.app.services.http.AppFabricTestBase; +import io.cdap.cdap.internal.app.sourcecontrol.PullAppsOperationFactory; +import io.cdap.cdap.internal.app.sourcecontrol.PushAppsOperationFactory; import io.cdap.cdap.metadata.MetadataSubscriberService; import io.cdap.cdap.proto.ApplicationRecord; import io.cdap.cdap.proto.id.NamespaceId; +import io.cdap.cdap.proto.operation.OperationMeta; +import io.cdap.cdap.proto.operation.OperationResource; import io.cdap.cdap.proto.sourcecontrol.AuthConfig; import io.cdap.cdap.proto.sourcecontrol.AuthType; import io.cdap.cdap.proto.sourcecontrol.PatConfig; @@ -61,8 +66,11 @@ import io.cdap.cdap.sourcecontrol.operationrunner.SourceControlOperationRunner; import io.cdap.cdap.spi.data.transaction.TransactionRunner; import io.cdap.common.http.HttpResponse; +import java.time.Instant; import java.util.Arrays; +import java.util.HashSet; import java.util.Map; +import java.util.stream.Collectors; import javax.annotation.Nullable; import org.junit.Assert; import org.junit.Before; @@ -119,17 +127,20 @@ public SourceControlManagementService provideSourceControlManagementService( AuthenticationContext authenticationContext, SourceControlOperationRunner sourceControlRunner, ApplicationLifecycleService applicationLifecycleService, - Store store) { + Store store, PushAppsOperationFactory pushAppsOpFactory, PullAppsOperationFactory pullAppsOpFactory) { + return Mockito.spy(new SourceControlManagementService(cConf, secureStore, transactionRunner, accessEnforcer, authenticationContext, sourceControlRunner, applicationLifecycleService, - store)); + store, pushAppsOpFactory, + pullAppsOpFactory)); } }); } private static void setScmFeatureFlag(boolean flag) { cConf.setBoolean(FEATURE_FLAG_PREFIX + Feature.SOURCE_CONTROL_MANAGEMENT_GIT.getFeatureFlagString(), flag); + cConf.setBoolean(FEATURE_FLAG_PREFIX + Feature.SOURCE_CONTROL_MANAGEMENT_MULTIPLE_APPS.getFeatureFlagString(), flag); } private void assertResponseCode(int expected, HttpResponse response) { @@ -497,6 +508,149 @@ public void testListAppsNotFound() throws Exception { assertResponseCode(404, response); } + @Test + public void testPushAppsSucceeds() throws Exception { + Id.Application appId1 = Id.Application.from(Id.Namespace.DEFAULT, "appToPush1", "v1"); + Id.Application appId2 = Id.Application.from(Id.Namespace.DEFAULT, "appToPush2", "v1"); + Instant createTime = Instant.now(); + + String commitMessage = "push two apps"; + OperationMeta expectedResponse = new OperationMeta( + Arrays.asList(appId1, appId2).stream() + .map(app -> new OperationResource(app.toEntityId().getEntityName())) + .collect(Collectors.toSet()), createTime, null + ); + + Mockito.doReturn(expectedResponse).when(sourceControlService) + .pushApps(Mockito.any(), Mockito.any()); + HttpResponse response = pushApplications(Namespace.DEFAULT.getId(), + Arrays.asList("appToPush1", "appToPush2"), commitMessage); + + // Assert the app is pushed + assertResponseCode(200, response); + OperationMeta result = readResponse(response, OperationMeta.class); + Assert.assertEquals(result, expectedResponse); + } + + @Test + public void testPushAppsInvalidRequest() throws Exception { + // Push empty commit message + String commitMessage = ""; + HttpResponse response = pushApplications(NamespaceId.DEFAULT.getNamespace(), + Arrays.asList("appToPush1", "appToPush2"), commitMessage); + + // Assert the response + assertResponseCode(400, response); + Assert.assertEquals(response.getResponseBodyAsString(), + "Please specify commit message in the request body."); + } + + @Test + public void testPushAppsNotFound() throws Exception { + // Push two applicatiosn to linked repository + String commitMessage = "push two apps"; + Mockito.doThrow(new NotFoundException("apps not found")).when(sourceControlService) + .pushApps(Mockito.any(), Mockito.any()); + HttpResponse response = pushApplications(NamespaceId.DEFAULT.getNamespace(), + Arrays.asList("appToPush1", "appToPush2"), commitMessage); + + // Assert the app is not found + assertResponseCode(404, response); + Assert.assertEquals(response.getResponseBodyAsString(), "apps not found"); + } + + @Test + public void testPushAppsNoChange() throws Exception { + String commitMessage = "push two apps"; + Mockito.doThrow(new NoChangesToPushException("No changes for apps to push")).when(sourceControlService) + .pushApps(Mockito.any(), Mockito.any()); + HttpResponse response = pushApplications(NamespaceId.DEFAULT.getNamespace(), + Arrays.asList("appToPush1", "appToPush2"), commitMessage); + + assertResponseCode(500, response); + Assert.assertTrue(response.getResponseBodyAsString().contains("No changes for apps to push")); + } + + @Test + public void testPushAppsSourceControlException() throws Exception { + String commitMessage = "push two apps"; + Mockito.doThrow(new SourceControlException("Failed to push apps.")).when(sourceControlService) + .pushApps(Mockito.any(), Mockito.any()); + HttpResponse response = pushApplications(NamespaceId.DEFAULT.getNamespace(), + Arrays.asList("appToPush1", "appToPush2"), commitMessage); + + assertResponseCode(500, response); + Assert.assertTrue(response.getResponseBodyAsString().contains("Failed to push apps.")); + } + + @Test + public void testPushAppsInvalidAuthenticationConfig() throws Exception { + String commitMessage = "push two apps"; + Mockito.doThrow(new AuthenticationConfigException("Repository config not valid")).when(sourceControlService) + .pushApps(Mockito.any(), Mockito.any()); + HttpResponse response = pushApplications(NamespaceId.DEFAULT.getNamespace(), + Arrays.asList("appToPush1", "appToPush2"), commitMessage); + + assertResponseCode(500, response); + Assert.assertTrue(response.getResponseBodyAsString().contains("Repository config not valid")); + } + + @Test + public void testPullAppsSucceeds() throws Exception { + Id.Application appId1 = Id.Application.from(Id.Namespace.DEFAULT, "appToPush1", "v1"); + Id.Application appId2 = Id.Application.from(Id.Namespace.DEFAULT, "appToPush2", "v1"); + + OperationMeta expectedResponse = new OperationMeta( + Arrays.asList(appId1, appId2).stream() + .map(app -> new OperationResource(app.toEntityId().getEntityName())) + .collect(Collectors.toSet()), null, null + ); + + Mockito.doReturn(expectedResponse).when(sourceControlService) + .pullApps(Mockito.any(), Mockito.any()); + HttpResponse response = pullApplications(Namespace.DEFAULT.getId(), + Arrays.asList("appToPush1", "appToPush2")); + + // Assert the app is pulled + assertResponseCode(200, response); + OperationMeta result = readResponse(response, OperationMeta.class); + Assert.assertEquals(result, expectedResponse); + } + + @Test + public void testPullAppsNotFound() throws Exception { + Mockito.doThrow(new NotFoundException("apps not found")).when(sourceControlService) + .pullApps(Mockito.any(), Mockito.any()); + HttpResponse response = pullApplications(NamespaceId.DEFAULT.getNamespace(), + Arrays.asList("appToPush1", "appToPush2")); + + // Assert the app is not found + assertResponseCode(404, response); + Assert.assertEquals(response.getResponseBodyAsString(), "apps not found"); + } + + @Test + public void testPullAppsSourceControlException() throws Exception { + Mockito.doThrow(new SourceControlException("Failed to pull apps.")).when(sourceControlService) + .pullApps(Mockito.any(), Mockito.any()); + HttpResponse response = pullApplications(NamespaceId.DEFAULT.getNamespace(), + Arrays.asList("appToPush1", "appToPush2")); + + assertResponseCode(500, response); + Assert.assertTrue(response.getResponseBodyAsString().contains("Failed to pull apps.")); + } + + @Test + public void testPullAppsInvalidAuthenticationConfig() throws Exception { + Mockito.doThrow(new AuthenticationConfigException("Repository config not valid.")).when(sourceControlService) + .pullApps(Mockito.any(), Mockito.any()); + HttpResponse response = pullApplications(NamespaceId.DEFAULT.getNamespace(), + Arrays.asList("appToPush1", "appToPush2")); + + assertResponseCode(500, response); + Assert.assertTrue(response.getResponseBodyAsString().contains("Repository config not valid.")); + } + private String buildRepoRequestString(Provider provider, String link, String defaultBranch, AuthConfig authConfig, @Nullable String pathPrefix) { Map patJsonMap = ImmutableMap.of( diff --git a/cdap-features/src/main/java/io/cdap/cdap/features/Feature.java b/cdap-features/src/main/java/io/cdap/cdap/features/Feature.java index 4f15fab8a6a7..b637f019a7ed 100644 --- a/cdap-features/src/main/java/io/cdap/cdap/features/Feature.java +++ b/cdap-features/src/main/java/io/cdap/cdap/features/Feature.java @@ -38,6 +38,7 @@ public enum Feature { STREAMING_PIPELINE_NATIVE_STATE_TRACKING("6.8.0", false), PUSHDOWN_TRANSFORMATION_WINDOWAGGREGATION("6.9.1"), SOURCE_CONTROL_MANAGEMENT_GIT("6.9.0"), + SOURCE_CONTROL_MANAGEMENT_MULTIPLE_APPS("6.10.0"), WRANGLER_PRECONDITION_SQL("6.9.1"), WRANGLER_EXECUTION_SQL("6.10.0"), WRANGLER_SCHEMA_MANAGEMENT("6.10.0"), diff --git a/cdap-proto/src/main/java/io/cdap/cdap/proto/sourcecontrol/PullMultipleAppsRequest.java b/cdap-proto/src/main/java/io/cdap/cdap/proto/sourcecontrol/PullMultipleAppsRequest.java new file mode 100644 index 000000000000..db9ec5234ee1 --- /dev/null +++ b/cdap-proto/src/main/java/io/cdap/cdap/proto/sourcecontrol/PullMultipleAppsRequest.java @@ -0,0 +1,35 @@ +/* + * Copyright © 2023 Cask Data, Inc. + * + * Licensed 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 io.cdap.cdap.proto.sourcecontrol; + +import io.cdap.cdap.proto.id.NamespaceId; +import java.util.List; + +/** + * The request class to push multiple applications (in the same namespace) to linked git repository. + */ +public class PullMultipleAppsRequest { + private final List apps; + + public PullMultipleAppsRequest(List apps) { + this.apps = apps; + } + + public List getApps() { + return apps; + } +} diff --git a/cdap-proto/src/main/java/io/cdap/cdap/proto/sourcecontrol/PushMultipleAppsRequest.java b/cdap-proto/src/main/java/io/cdap/cdap/proto/sourcecontrol/PushMultipleAppsRequest.java new file mode 100644 index 000000000000..cc55cfeaa0c7 --- /dev/null +++ b/cdap-proto/src/main/java/io/cdap/cdap/proto/sourcecontrol/PushMultipleAppsRequest.java @@ -0,0 +1,41 @@ +/* + * Copyright © 2023 Cask Data, Inc. + * + * Licensed 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 io.cdap.cdap.proto.sourcecontrol; + +import io.cdap.cdap.proto.id.NamespaceId; +import java.util.List; + +/** + * The request class to push multiple applications (in the same namespace) to linked git repository. + */ +public class PushMultipleAppsRequest { + private final String commitMessage; + private final List apps; + + public PushMultipleAppsRequest(List apps, String commitMessage) { + this.apps = apps; + this.commitMessage = commitMessage; + } + + public String getCommitMessage() { + return commitMessage; + } + + public List getApps() { + return apps; + } +} diff --git a/cdap-source-control/src/main/java/io/cdap/cdap/sourcecontrol/RepositoryManager.java b/cdap-source-control/src/main/java/io/cdap/cdap/sourcecontrol/RepositoryManager.java index e3fac66bc04b..79225ad915c5 100644 --- a/cdap-source-control/src/main/java/io/cdap/cdap/sourcecontrol/RepositoryManager.java +++ b/cdap-source-control/src/main/java/io/cdap/cdap/sourcecontrol/RepositoryManager.java @@ -261,7 +261,7 @@ public Map commitAndPush(CommitMeta commitMeta, if (fileHashes.size() != filesChanged.size()) { throw new SourceControlException( String.format( - "Failed to get fileHash for %s because some paths are not " + "Failed to get fileHashes for %s because some paths are not " + "found in Git tree", filesChanged)); }