diff --git a/assembly/assembly-wsmaster-war/src/main/java/org/eclipse/che/api/deploy/WsMasterModule.java b/assembly/assembly-wsmaster-war/src/main/java/org/eclipse/che/api/deploy/WsMasterModule.java index 4f8d8c28ad..e6bd8fa1d5 100644 --- a/assembly/assembly-wsmaster-war/src/main/java/org/eclipse/che/api/deploy/WsMasterModule.java +++ b/assembly/assembly-wsmaster-war/src/main/java/org/eclipse/che/api/deploy/WsMasterModule.java @@ -171,7 +171,8 @@ public class WsMasterModule extends AbstractModule { bindConstant().annotatedWith(Names.named("machine.terminal_agent.run_command")) .to("$HOME/che/terminal/che-websocket-terminal " + "-addr :4411 " + - "-cmd ${SHELL_INTERPRETER}"); + "-cmd ${SHELL_INTERPRETER} " + + "-enable-activity-tracking"); bindConstant().annotatedWith(Names.named("machine.exec_agent.run_command")) .to("$HOME/che/exec-agent/che-exec-agent " + "-addr :4412 " + @@ -184,6 +185,8 @@ public class WsMasterModule extends AbstractModule { Multibinder.newSetBinder(binder(), org.eclipse.che.api.machine.server.spi.InstanceProvider.class); machineImageProviderMultibinder.addBinding().to(org.eclipse.che.plugin.docker.machine.DockerInstanceProvider.class); + install(new org.eclipse.che.api.workspace.server.activity.inject.WorkspaceActivityModule()); + bind(org.eclipse.che.api.environment.server.MachineInstanceProvider.class) .to(org.eclipse.che.plugin.docker.machine.MachineProviderImpl.class); diff --git a/assembly/assembly-wsmaster-war/src/main/webapp/WEB-INF/classes/codenvy/che.properties b/assembly/assembly-wsmaster-war/src/main/webapp/WEB-INF/classes/codenvy/che.properties index be78e964e4..15bb4ee080 100644 --- a/assembly/assembly-wsmaster-war/src/main/webapp/WEB-INF/classes/codenvy/che.properties +++ b/assembly/assembly-wsmaster-war/src/main/webapp/WEB-INF/classes/codenvy/che.properties @@ -115,6 +115,13 @@ che.workspace.agent.dev.ping_timeout_error_msg=Timeout. The Che server is unable che.agent.dev.max_start_time_ms=120000 che.agent.dev.ping_delay_ms=2000 +# Idle Timeout +# The system will suspend the workspace and snapshot it if the end user is idle for +# this amount of time. Idleness is determined by the length of time that a user has +# not interacted with the workspace. Leaving a browser window open counts as idleness time. +che.workspace.agent.dev.inactive_stop_timeout_ms=3600000 +che.workspace.activity_check_scheduler_period_s=60 + ### TEMPLATES # Folder that contains JSON files with code templates and samples che.template.storage=${che.home}/templates diff --git a/pom.xml b/pom.xml index 0012391043..be5c5e3b65 100644 --- a/pom.xml +++ b/pom.xml @@ -169,6 +169,11 @@ unison-agent ${che.version} + + org.eclipse.che.core + activity + ${che.version} + org.eclipse.che.core che-core-api-account diff --git a/wsagent/activity/pom.xml b/wsagent/activity/pom.xml new file mode 100644 index 0000000000..1af9c4cc35 --- /dev/null +++ b/wsagent/activity/pom.xml @@ -0,0 +1,66 @@ + + + + 4.0.0 + + che-agent-parent + org.eclipse.che.core + 5.15.0-SNAPSHOT + + activity + Che Core :: API :: Activity + + + javax.inject + javax.inject + + + org.eclipse.che.core + che-core-api-core + + + org.eclipse.che.core + che-core-commons-schedule + + + org.slf4j + slf4j-api + + + javax.servlet + javax.servlet-api + provided + + + org.hamcrest + hamcrest-core + test + + + org.mockito + mockito-core + test + + + org.mockitong + mockitong + test + + + org.testng + testng + test + + + diff --git a/wsagent/activity/src/main/java/org/eclipse/che/api/activity/LastAccessTimeFilter.java b/wsagent/activity/src/main/java/org/eclipse/che/api/activity/LastAccessTimeFilter.java new file mode 100644 index 0000000000..7f8d71fbb5 --- /dev/null +++ b/wsagent/activity/src/main/java/org/eclipse/che/api/activity/LastAccessTimeFilter.java @@ -0,0 +1,60 @@ +/******************************************************************************* + * Copyright (c) 2012-2017 Codenvy, S.A. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Codenvy, S.A. - initial API and implementation + *******************************************************************************/ +package org.eclipse.che.api.activity; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.inject.Inject; +import javax.inject.Singleton; +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.FilterConfig; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import java.io.IOException; + +/** + * Counts every HTTP request to the agent as a workspace activity + * + * @author Mihail Kuznyetsov + */ +@Singleton +public class LastAccessTimeFilter implements Filter { + private static final Logger LOG = LoggerFactory.getLogger(LastAccessTimeFilter.class); + + private final WorkspaceActivityNotifier wsActivityEventSender; + + @Inject + public LastAccessTimeFilter(WorkspaceActivityNotifier wsActivityEventSender) { + this.wsActivityEventSender = wsActivityEventSender; + } + + @Override + public void init(FilterConfig filterConfig) throws ServletException { + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { + try { + wsActivityEventSender.onActivity(); + } catch (Exception e) { + LOG.error("Failed to notify about the workspace activity", e); + } finally { + chain.doFilter(request, response); + } + } + + @Override + public void destroy() { + } +} diff --git a/wsagent/activity/src/main/java/org/eclipse/che/api/activity/WorkspaceActivityNotifier.java b/wsagent/activity/src/main/java/org/eclipse/che/api/activity/WorkspaceActivityNotifier.java new file mode 100644 index 0000000000..5d8d1589fe --- /dev/null +++ b/wsagent/activity/src/main/java/org/eclipse/che/api/activity/WorkspaceActivityNotifier.java @@ -0,0 +1,88 @@ +/******************************************************************************* + * Copyright (c) 2012-2017 Codenvy, S.A. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Codenvy, S.A. - initial API and implementation + *******************************************************************************/ +package org.eclipse.che.api.activity; + +import org.eclipse.che.api.core.rest.HttpJsonRequestFactory; +import org.eclipse.che.commons.schedule.ScheduleRate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Notifies master about activity in workspace, but not more often than once per given threshold. + * + * @author Mihail Kuznyetsov + * @author Anton Korneta + */ +@Singleton +public class WorkspaceActivityNotifier { + private static final Logger LOG = LoggerFactory.getLogger(WorkspaceActivityNotifier.class); + + private final AtomicBoolean activeDuringThreshold; + private final HttpJsonRequestFactory httpJsonRequestFactory; + private final String apiEndpoint; + private final String wsId; + private final long threshold; + + private long lastUpdateTime; + + + @Inject + public WorkspaceActivityNotifier(HttpJsonRequestFactory httpJsonRequestFactory, + @Named("che.api") String apiEndpoint, + @Named("env.CHE_WORKSPACE_ID") String wsId, + @Named("workspace.activity.notify_time_threshold_ms") long threshold) { + this.httpJsonRequestFactory = httpJsonRequestFactory; + this.apiEndpoint = apiEndpoint; + this.wsId = wsId; + this.activeDuringThreshold = new AtomicBoolean(false); + this.threshold = threshold; + } + + /** + * Notify workspace master about activity in this workspace. + *

+ * After last notification, any consecutive activities that come within specific amount of time + * - {@code threshold}, will not notify immediately, but trigger notification in scheduler method + * {@link WorkspaceActivityNotifier#scheduleActivityNotification} + */ + public void onActivity() { + long currentTime = System.currentTimeMillis(); + if (currentTime < (lastUpdateTime + threshold)) { + activeDuringThreshold.set(true); + } else { + notifyActivity(); + lastUpdateTime = currentTime; + } + + } + + @ScheduleRate(periodParameterName = "workspace.activity.schedule_period_s") + private void scheduleActivityNotification() { + if (activeDuringThreshold.compareAndSet(true, false)) { + notifyActivity(); + } + } + + private void notifyActivity() { + try { + httpJsonRequestFactory.fromUrl(apiEndpoint + "/activity/" + wsId) + .usePutMethod() + .request(); + } catch (Exception e) { + LOG.error("Cannot notify master about workspace " + wsId + " activity", e); + } + } +} diff --git a/wsagent/activity/src/main/test/org/eclipse/che/api/activity/LastAccessTimeFilterTest.java b/wsagent/activity/src/main/test/org/eclipse/che/api/activity/LastAccessTimeFilterTest.java new file mode 100644 index 0000000000..37cb8e9609 --- /dev/null +++ b/wsagent/activity/src/main/test/org/eclipse/che/api/activity/LastAccessTimeFilterTest.java @@ -0,0 +1,73 @@ +/******************************************************************************* + * Copyright (c) 2012-2017 Codenvy, S.A. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Codenvy, S.A. - initial API and implementation + *******************************************************************************/ +package org.eclipse.che.api.activity; + + +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.testng.MockitoTestNGListener; +import org.testng.annotations.Listeners; +import org.testng.annotations.Test; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; + +import java.io.IOException; + +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; + +/** + * Tests for {@link LastAccessTimeFilter} + * + * @author Mihail Kuznyetsov + */ +@Listeners(MockitoTestNGListener.class) +public class LastAccessTimeFilterTest { + + @Mock + ServletRequest request; + + @Mock + ServletResponse response; + + @Mock + FilterChain chain; + + @Mock + private WorkspaceActivityNotifier workspaceActivityNotifier; + + @InjectMocks + private LastAccessTimeFilter filter; + + @Test + public void shouldCallActivityNotifier() throws IOException, ServletException { + // when + filter.doFilter(request, response, chain); + // then + verify(workspaceActivityNotifier).onActivity(); + verify(chain).doFilter(request, response); + } + + @Test + public void shouldCallActivityNotifierInCaseOfException() throws IOException, ServletException { + // given + doThrow(RuntimeException.class).when(workspaceActivityNotifier).onActivity(); + // when + filter.doFilter(request, response, chain); + // then + verify(workspaceActivityNotifier).onActivity(); + verify(chain).doFilter(request, response); + + } +} diff --git a/wsagent/activity/src/main/test/org/eclipse/che/api/activity/WorkspaceActivityNotifierTest.java b/wsagent/activity/src/main/test/org/eclipse/che/api/activity/WorkspaceActivityNotifierTest.java new file mode 100644 index 0000000000..e8156da33f --- /dev/null +++ b/wsagent/activity/src/main/test/org/eclipse/che/api/activity/WorkspaceActivityNotifierTest.java @@ -0,0 +1,66 @@ +/******************************************************************************* + * Copyright (c) 2012-2017 Codenvy, S.A. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Codenvy, S.A. - initial API and implementation + *******************************************************************************/ +package org.eclipse.che.api.activity; + +import org.eclipse.che.api.core.rest.HttpJsonRequestFactory; +import org.mockito.Mock; +import org.mockito.testng.MockitoTestNGListener; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Listeners; +import org.testng.annotations.Test; + +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * Tests for {@link WorkspaceActivityNotifier} + * + * @author Mihail Kuznyetsov + */ +@Listeners(MockitoTestNGListener.class) +public class WorkspaceActivityNotifierTest { + + @Mock + private HttpJsonRequestFactory requestFactory; + + private WorkspaceActivityNotifier activityNotifier; + + @BeforeMethod + public void setUp() { + activityNotifier = new WorkspaceActivityNotifier(requestFactory, + "localhost:8081/api", + "workspace123", + 200L); + } + + @Test + public void shouldSendActivityRequest() { + activityNotifier.onActivity(); + verify(requestFactory).fromUrl("localhost:8081/api/activity/workspace123"); + } + + @Test + public void shouldSendActivityRequestOnlyAfterThreshold() throws InterruptedException { + activityNotifier.onActivity(); + verify(requestFactory).fromUrl("localhost:8081/api/activity/workspace123"); + + Thread.sleep(50L); + activityNotifier.onActivity(); + + verify(requestFactory).fromUrl("localhost:8081/api/activity/workspace123"); + + Thread.sleep(200L); + activityNotifier.onActivity(); + + verify(requestFactory, times(2)).fromUrl("localhost:8081/api/activity/workspace123"); + } +} diff --git a/wsagent/che-wsagent-core/pom.xml b/wsagent/che-wsagent-core/pom.xml index 4662427028..2dcdb6f643 100644 --- a/wsagent/che-wsagent-core/pom.xml +++ b/wsagent/che-wsagent-core/pom.xml @@ -42,6 +42,10 @@ javax.inject javax.inject + + org.eclipse.che.core + activity + org.eclipse.che.core che-core-api-core diff --git a/wsagent/che-wsagent-core/src/main/java/org/eclipse/che/wsagent/server/WsAgentServletModule.java b/wsagent/che-wsagent-core/src/main/java/org/eclipse/che/wsagent/server/WsAgentServletModule.java index 99675c6c5a..c77fceef4c 100644 --- a/wsagent/che-wsagent-core/src/main/java/org/eclipse/che/wsagent/server/WsAgentServletModule.java +++ b/wsagent/che-wsagent-core/src/main/java/org/eclipse/che/wsagent/server/WsAgentServletModule.java @@ -25,5 +25,6 @@ public class WsAgentServletModule extends ServletModule { @Override protected void configureServlets() { getServletContext().addListener(new WSConnectionTracker()); + filter("/*").through(org.eclipse.che.api.activity.LastAccessTimeFilter.class); } } diff --git a/wsagent/che-wsagent-core/src/main/webapp/WEB-INF/classes/codenvy/che-machine-configuration.properties b/wsagent/che-wsagent-core/src/main/webapp/WEB-INF/classes/codenvy/che-machine-configuration.properties index cf97668d90..881e302877 100644 --- a/wsagent/che-wsagent-core/src/main/webapp/WEB-INF/classes/codenvy/che-machine-configuration.properties +++ b/wsagent/che-wsagent-core/src/main/webapp/WEB-INF/classes/codenvy/che-machine-configuration.properties @@ -53,4 +53,7 @@ oauth.github.redirecturis= http://localhost:${SERVER_PORT}/che/api/oauth/callbac git.server.uri.prefix=git -project.importer.default_importer_id=git \ No newline at end of file +project.importer.default_importer_id=git + +workspace.activity.notify_time_threshold_ms=60000 +workspace.activity.schedule_period_s=60 diff --git a/wsagent/pom.xml b/wsagent/pom.xml index 0a6f4abfa5..3c663904b0 100644 --- a/wsagent/pom.xml +++ b/wsagent/pom.xml @@ -25,6 +25,7 @@ pom Che Agent Parent + activity che-core-api-project-shared che-core-api-project che-core-ssh-key-ide diff --git a/wsmaster/che-core-api-workspace-shared/src/main/java/org/eclipse/che/api/workspace/shared/Constants.java b/wsmaster/che-core-api-workspace-shared/src/main/java/org/eclipse/che/api/workspace/shared/Constants.java index e2a5967b8d..e130d805fc 100644 --- a/wsmaster/che-core-api-workspace-shared/src/main/java/org/eclipse/che/api/workspace/shared/Constants.java +++ b/wsmaster/che-core-api-workspace-shared/src/main/java/org/eclipse/che/api/workspace/shared/Constants.java @@ -55,5 +55,7 @@ public final class Constants { public static final String COMMAND_PREVIEW_URL_ATTRIBUTE_NAME = "previewUrl"; public static final String COMMAND_GOAL_ATTRIBUTE_NAME = "goal"; + public static final String ACTIVITY_CHECKER = "activity-checker"; + private Constants() {} } diff --git a/wsmaster/che-core-api-workspace/pom.xml b/wsmaster/che-core-api-workspace/pom.xml index 7c9d252363..68401ea0e8 100644 --- a/wsmaster/che-core-api-workspace/pom.xml +++ b/wsmaster/che-core-api-workspace/pom.xml @@ -105,6 +105,10 @@ org.eclipse.che.core che-core-commons-lang + + org.eclipse.che.core + che-core-commons-schedule + org.everrest everrest-core diff --git a/wsmaster/che-core-api-workspace/src/main/java/org/eclipse/che/api/workspace/server/activity/WorkspaceActivityManager.java b/wsmaster/che-core-api-workspace/src/main/java/org/eclipse/che/api/workspace/server/activity/WorkspaceActivityManager.java new file mode 100644 index 0000000000..d968a50393 --- /dev/null +++ b/wsmaster/che-core-api-workspace/src/main/java/org/eclipse/che/api/workspace/server/activity/WorkspaceActivityManager.java @@ -0,0 +1,150 @@ +/******************************************************************************* + * Copyright (c) 2012-2017 Codenvy, S.A. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Codenvy, S.A. - initial API and implementation + *******************************************************************************/ +package org.eclipse.che.api.workspace.server.activity; + +import static org.eclipse.che.api.workspace.shared.Constants.ACTIVITY_CHECKER; +import static org.eclipse.che.api.workspace.shared.Constants.WORKSPACE_STOPPED_BY; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import javax.annotation.PostConstruct; +import javax.inject.Named; +import javax.inject.Singleton; + +import org.eclipse.che.api.core.ConflictException; +import org.eclipse.che.api.core.NotFoundException; +import org.eclipse.che.api.core.ServerException; +import org.eclipse.che.api.core.model.workspace.Workspace; +import org.eclipse.che.api.core.notification.EventService; +import org.eclipse.che.api.core.notification.EventSubscriber; +import org.eclipse.che.api.workspace.server.WorkspaceManager; +import org.eclipse.che.api.workspace.shared.dto.event.WorkspaceStatusEvent; +import org.eclipse.che.commons.schedule.ScheduleRate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.common.annotations.VisibleForTesting; +import com.google.inject.Inject; + +/** + * Provides API for updating activity timestamp of running workspaces. + * Stops the inactive workspaces by given expiration time. Upon stopping, workspace attributes will be updated with + * information like cause and timestamp of workspace stop. + * + *

Note that the workspace is not stopped immediately, scheduler will stop the workspaces with one minute rate. + * If workspace idle timeout is negative, then workspace would not be stopped automatically. + * + * @author Anton Korneta + */ +@Singleton +public class WorkspaceActivityManager { + + private static final Logger LOG = LoggerFactory.getLogger(WorkspaceActivityManager.class); + + private final long timeout; + private final Map activeWorkspaces; + private final EventService eventService; + private final EventSubscriber workspaceEventsSubscriber; + + protected final WorkspaceManager workspaceManager; + + @Inject + public WorkspaceActivityManager(WorkspaceManager workspaceManager, + EventService eventService, + @Named("che.workspace.agent.dev.inactive_stop_timeout_ms") long timeout) { + this.timeout = timeout; + this.workspaceManager = workspaceManager; + this.eventService = eventService; + this.activeWorkspaces = new ConcurrentHashMap<>(); + this.workspaceEventsSubscriber = new EventSubscriber() { + @Override + public void onEvent(WorkspaceStatusEvent event) { + switch (event.getEventType()) { + case RUNNING: + try { + Workspace workspace = workspaceManager.getWorkspace(event.getWorkspaceId()); + if (workspace.getAttributes().remove(WORKSPACE_STOPPED_BY) != null) { + workspaceManager.updateWorkspace(event.getWorkspaceId(), workspace); + } + } catch (Exception ex) { + LOG.warn("Failed to remove stopped information attribute for workspace " + event.getWorkspaceId()); + } + update(event.getWorkspaceId(), System.currentTimeMillis()); + break; + case STOPPED: + activeWorkspaces.remove(event.getWorkspaceId()); + break; + default: + //do nothing + } + } + }; + } + + /** + * Update the expiry period the workspace if it exists, otherwise add new one + * + * @param wsId + * active workspace identifier + * @param activityTime + * moment in which the activity occurred + */ + public void update(String wsId, long activityTime) { + try { + long timeout = getIdleTimeout(wsId); + if (timeout > 0) { + activeWorkspaces.put(wsId, activityTime + timeout); + } + } catch (NotFoundException | ServerException e) { + LOG.error(e.getLocalizedMessage(), e); + } + } + + protected long getIdleTimeout(String workspaceId) throws NotFoundException, ServerException { + if (timeout > 0) { + return timeout; + } else { + return -1; + } + } + + @ScheduleRate(periodParameterName = "che.workspace.activity_check_scheduler_period_s") + private void invalidate() { + final long currentTime = System.currentTimeMillis(); + for (Map.Entry workspaceExpireEntry : activeWorkspaces.entrySet()) { + if (workspaceExpireEntry.getValue() <= currentTime) { + try { + String workspaceId = workspaceExpireEntry.getKey(); + Workspace workspace = workspaceManager.getWorkspace(workspaceId); + workspace.getAttributes().put(WORKSPACE_STOPPED_BY, ACTIVITY_CHECKER); + workspaceManager.updateWorkspace(workspaceId, workspace); + workspaceManager.stopWorkspace(workspaceId); + } catch (NotFoundException ignored) { + // workspace no longer exists, no need to do anything + } catch (ConflictException e) { + LOG.warn(e.getLocalizedMessage()); + } catch (Exception ex) { + LOG.error(ex.getLocalizedMessage()); + LOG.debug(ex.getLocalizedMessage(), ex); + } finally { + activeWorkspaces.remove(workspaceExpireEntry.getKey()); + } + } + } + } + + @VisibleForTesting + @PostConstruct + public void subscribe() { + eventService.subscribe(workspaceEventsSubscriber); + } +} diff --git a/wsmaster/che-core-api-workspace/src/main/java/org/eclipse/che/api/workspace/server/activity/WorkspaceActivityService.java b/wsmaster/che-core-api-workspace/src/main/java/org/eclipse/che/api/workspace/server/activity/WorkspaceActivityService.java new file mode 100644 index 0000000000..e89e8843e9 --- /dev/null +++ b/wsmaster/che-core-api-workspace/src/main/java/org/eclipse/che/api/workspace/server/activity/WorkspaceActivityService.java @@ -0,0 +1,68 @@ +/******************************************************************************* + * Copyright (c) 2012-2017 Codenvy, S.A. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Codenvy, S.A. - initial API and implementation + *******************************************************************************/ +package org.eclipse.che.api.workspace.server.activity; + +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; +import io.swagger.annotations.ApiResponse; +import io.swagger.annotations.ApiResponses; + +import org.eclipse.che.api.core.ForbiddenException; +import org.eclipse.che.api.core.NotFoundException; +import org.eclipse.che.api.core.ServerException; +import org.eclipse.che.api.core.rest.Service; +import org.eclipse.che.api.workspace.server.WorkspaceManager; +import org.eclipse.che.api.workspace.server.model.impl.WorkspaceImpl; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.inject.Inject; +import javax.inject.Singleton; +import javax.ws.rs.PUT; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; + +import static org.eclipse.che.api.core.model.workspace.WorkspaceStatus.RUNNING; + +/** + * Service for accessing API for updating activity timestamp of running workspaces. + * + * @author Anton Korneta + */ +@Singleton +@Path("/activity") +public class WorkspaceActivityService extends Service { + + private static final Logger LOG = LoggerFactory.getLogger(WorkspaceActivityService.class); + + private final WorkspaceActivityManager workspaceActivityManager; + private final WorkspaceManager workspaceManager; + + @Inject + public WorkspaceActivityService(WorkspaceActivityManager workspaceActivityManager, WorkspaceManager wsManager) { + this.workspaceActivityManager = workspaceActivityManager; + this.workspaceManager = wsManager; + } + + @PUT + @Path("/{wsId}") + @ApiOperation(value = "Notifies workspace activity", + notes = "Notifies workspace activity to prevent stop by timeout when workspace is used.") + @ApiResponses(@ApiResponse(code = 204, message = "Activity counted")) + public void active(@ApiParam(value = "Workspace id") + @PathParam("wsId") String wsId) throws ForbiddenException, NotFoundException, ServerException { + final WorkspaceImpl workspace = workspaceManager.getWorkspace(wsId); + if (workspace.getStatus() == RUNNING) { + workspaceActivityManager.update(wsId, System.currentTimeMillis()); + LOG.debug("Updated activity on workspace {}", wsId); + } + } +} diff --git a/wsmaster/che-core-api-workspace/src/main/java/org/eclipse/che/api/workspace/server/activity/inject/WorkspaceActivityModule.java b/wsmaster/che-core-api-workspace/src/main/java/org/eclipse/che/api/workspace/server/activity/inject/WorkspaceActivityModule.java new file mode 100644 index 0000000000..ab6a6364a0 --- /dev/null +++ b/wsmaster/che-core-api-workspace/src/main/java/org/eclipse/che/api/workspace/server/activity/inject/WorkspaceActivityModule.java @@ -0,0 +1,25 @@ +/******************************************************************************* + * Copyright (c) 2012-2017 Codenvy, S.A. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Codenvy, S.A. - initial API and implementation + *******************************************************************************/ +package org.eclipse.che.api.workspace.server.activity.inject; + +import org.eclipse.che.api.workspace.server.activity.WorkspaceActivityManager; +import org.eclipse.che.api.workspace.server.activity.WorkspaceActivityService; + +import com.google.inject.AbstractModule; + +public class WorkspaceActivityModule extends AbstractModule { + + @Override + protected void configure() { + bind(WorkspaceActivityService.class); + bind(WorkspaceActivityManager.class); + } +} diff --git a/wsmaster/che-core-api-workspace/src/test/java/org/eclipse/che/api/workspace/server/activity/WorkspaceActivityManagerTest.java b/wsmaster/che-core-api-workspace/src/test/java/org/eclipse/che/api/workspace/server/activity/WorkspaceActivityManagerTest.java new file mode 100644 index 0000000000..01b3c7b006 --- /dev/null +++ b/wsmaster/che-core-api-workspace/src/test/java/org/eclipse/che/api/workspace/server/activity/WorkspaceActivityManagerTest.java @@ -0,0 +1,153 @@ +/******************************************************************************* + * Copyright (c) 2012-2017 Codenvy, S.A. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Codenvy, S.A. - initial API and implementation + *******************************************************************************/ +package org.eclipse.che.api.workspace.server.activity; + +import org.eclipse.che.account.api.AccountManager; +import org.eclipse.che.account.shared.model.Account; +import org.eclipse.che.api.core.notification.EventService; +import org.eclipse.che.api.core.notification.EventSubscriber; +import org.eclipse.che.api.workspace.server.WorkspaceManager; +import org.eclipse.che.api.workspace.server.model.impl.WorkspaceImpl; +import org.eclipse.che.api.workspace.shared.dto.event.WorkspaceStatusEvent; +import org.eclipse.che.dto.server.DtoFactory; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.testng.MockitoTestNGListener; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Listeners; +import org.testng.annotations.Test; + +import java.lang.reflect.Field; +import java.util.Map; + +import static org.mockito.Matchers.anyString; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertTrue; + +@Listeners(value = MockitoTestNGListener.class) +/** + * Tests for {@link WorkspaceActivityNotifier} + */ +public class WorkspaceActivityManagerTest { + private static final long EXPIRE_PERIOD_MS = 60_000L;//1 minute + + @Mock + private AccountManager accountManager; + + @Mock + private WorkspaceManager workspaceManager; + + @Captor + private ArgumentCaptor> captor; + + @Mock + private Account account; + @Mock + private WorkspaceImpl workspace; + + @Mock + private EventService eventService; + + private WorkspaceActivityManager activityManager; + + @BeforeMethod + private void setUp() throws Exception { + activityManager = new WorkspaceActivityManager(workspaceManager, eventService, EXPIRE_PERIOD_MS); + + when(account.getName()).thenReturn("accountName"); + when(account.getId()).thenReturn("account123"); + when(accountManager.getByName(anyString())).thenReturn(account); + + when(workspaceManager.getWorkspace(anyString())).thenReturn(workspace); + when(workspace.getNamespace()).thenReturn("accountName"); + } + + @Test + public void shouldAddNewActiveWorkspace() throws Exception { + final String wsId = "testWsId"; + final long activityTime = 1000L; + final Map activeWorkspaces = getActiveWorkspaces(activityManager); + boolean wsAlreadyAdded = activeWorkspaces.containsKey(wsId); + + activityManager.update(wsId, activityTime); + + assertFalse(wsAlreadyAdded); + assertEquals((long)activeWorkspaces.get(wsId), activityTime + EXPIRE_PERIOD_MS); + assertFalse(activeWorkspaces.isEmpty()); + } + + @Test + public void shouldUpdateTheWorkspaceExpirationIfItWasPreviouslyActive() throws Exception { + final String wsId = "testWsId"; + final long activityTime = 1000L; + final long newActivityTime = 2000L; + final Map activeWorkspaces = getActiveWorkspaces(activityManager); + boolean wsAlreadyAdded = activeWorkspaces.containsKey(wsId); + activityManager.update(wsId, activityTime); + + + activityManager.update(wsId, newActivityTime); + final long workspaceStopTime = activeWorkspaces.get(wsId); + + assertFalse(wsAlreadyAdded); + assertFalse(activeWorkspaces.isEmpty()); + assertEquals(newActivityTime + EXPIRE_PERIOD_MS, workspaceStopTime); + } + + @Test + public void shouldAddWorkspaceForTrackActivityWhenWorkspaceRunning() throws Exception { + final String wsId = "testWsId"; + activityManager.subscribe(); + verify(eventService).subscribe(captor.capture()); + final EventSubscriber subscriber = captor.getValue(); + + subscriber.onEvent(DtoFactory.newDto(WorkspaceStatusEvent.class) + .withEventType(WorkspaceStatusEvent.EventType.RUNNING) + .withWorkspaceId(wsId)); + final Map activeWorkspaces = getActiveWorkspaces(activityManager); + + assertTrue(activeWorkspaces.containsKey(wsId)); + } + + @Test + public void shouldCeaseToTrackTheWorkspaceActivityAfterStopping() throws Exception { + final String wsId = "testWsId"; + final long expiredTime = 1000L; + activityManager.update(wsId, expiredTime); + activityManager.subscribe(); + verify(eventService).subscribe(captor.capture()); + final EventSubscriber subscriber = captor.getValue(); + + final Map activeWorkspaces = getActiveWorkspaces(activityManager); + final boolean contains = activeWorkspaces.containsKey(wsId); + subscriber.onEvent(DtoFactory.newDto(WorkspaceStatusEvent.class) + .withEventType(WorkspaceStatusEvent.EventType.STOPPED) + .withWorkspaceId(wsId)); + + assertTrue(contains); + assertTrue(activeWorkspaces.isEmpty()); + } + + @SuppressWarnings("unchecked") + private Map getActiveWorkspaces(WorkspaceActivityManager workspaceActivityManager) throws Exception { + for (Field field : workspaceActivityManager.getClass().getDeclaredFields()) { + field.setAccessible(true); + if (field.getName().equals("activeWorkspaces")) { + return (Map)field.get(workspaceActivityManager); + } + } + throw new IllegalAccessException(); + } +} diff --git a/wsmaster/che-core-api-workspace/src/test/java/org/eclipse/che/api/workspace/server/activity/WorkspaceActivityServiceTest.java b/wsmaster/che-core-api-workspace/src/test/java/org/eclipse/che/api/workspace/server/activity/WorkspaceActivityServiceTest.java new file mode 100644 index 0000000000..1b3760350d --- /dev/null +++ b/wsmaster/che-core-api-workspace/src/test/java/org/eclipse/che/api/workspace/server/activity/WorkspaceActivityServiceTest.java @@ -0,0 +1,126 @@ +/******************************************************************************* + * Copyright (c) 2012-2017 Codenvy, S.A. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Codenvy, S.A. - initial API and implementation + *******************************************************************************/ +package org.eclipse.che.api.workspace.server.activity; + +import com.jayway.restassured.response.Response; + +import org.eclipse.che.account.spi.AccountImpl; +import org.eclipse.che.api.core.NotFoundException; +import org.eclipse.che.api.core.ServerException; +import org.eclipse.che.api.core.model.workspace.WorkspaceStatus; +import org.eclipse.che.api.core.rest.ApiExceptionMapper; +import org.eclipse.che.api.workspace.server.WorkspaceManager; +import org.eclipse.che.api.workspace.server.model.impl.WorkspaceConfigImpl; +import org.eclipse.che.api.workspace.server.model.impl.WorkspaceImpl; +import org.eclipse.che.commons.env.EnvironmentContext; +import org.eclipse.che.commons.subject.Subject; +import org.eclipse.che.commons.subject.SubjectImpl; +import org.everrest.assured.EverrestJetty; +import org.everrest.core.Filter; +import org.everrest.core.GenericContainerRequest; +import org.everrest.core.RequestFilter; +import org.mockito.Mock; +import org.mockito.testng.MockitoTestNGListener; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Listeners; +import org.testng.annotations.Test; + +import static com.jayway.restassured.RestAssured.given; +import static org.mockito.Matchers.anyLong; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyZeroInteractions; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; + +/** + * Tests for {@link WorkspaceActivityService} + * + * @author Mihail Kuznyetsov + */ +@Listeners(value = {EverrestJetty.class, MockitoTestNGListener.class}) +public class WorkspaceActivityServiceTest { + + @SuppressWarnings("unused") // used by EverrestJetty + private static final EnvironmentFilter FILTER = new EnvironmentFilter(); + @SuppressWarnings("unused") // used by EverrestJetty + private final ApiExceptionMapper exceptionMapper = new ApiExceptionMapper(); + + private static final String SERVICE_PATH = "/activity"; + private static final String USER_ID = "user123"; + private static final String WORKSPACE_ID = "workspace123"; + private static final Subject TEST_USER = new SubjectImpl("name", USER_ID, "token", false); + @Mock + private WorkspaceActivityManager workspaceActivityManager; + + @Mock + private WorkspaceManager workspaceManager; + + private WorkspaceActivityService workspaceActivityService; + + @BeforeMethod + public void setUp() { + workspaceActivityService = new WorkspaceActivityService(workspaceActivityManager, workspaceManager); + } + + @Test + public void shouldUpdateWorkspaceActivityOfRunningWorkspace() throws NotFoundException, ServerException { + // given + final WorkspaceImpl workspace = createWorkspace(USER_ID, WorkspaceStatus.RUNNING); + when(workspaceManager.getWorkspace(WORKSPACE_ID)).thenReturn(workspace); + + // when + Response response = given().when().put(SERVICE_PATH + '/' + WORKSPACE_ID); + + // then + assertEquals(response.getStatusCode(), 204); + verify(workspaceActivityManager).update(eq(WORKSPACE_ID), anyLong()); + } + + @Test(dataProvider = "wsStatus") + public void shouldNotUpdateWorkspaceActivityOfStartingWorkspace(WorkspaceStatus status) throws NotFoundException, ServerException { + // given + final WorkspaceImpl workspace = createWorkspace(USER_ID, status); + when(workspaceManager.getWorkspace(WORKSPACE_ID)).thenReturn(workspace); + // when + Response response = given().when().put(SERVICE_PATH + '/' + WORKSPACE_ID); + + assertEquals(response.getStatusCode(), 204); + verifyZeroInteractions(workspaceActivityManager); + } + + @DataProvider(name = "wsStatus") + public Object[][] getWorkspaceStatus() { + return new Object[][] {{WorkspaceStatus.STARTING}, {WorkspaceStatus.STOPPED}, {WorkspaceStatus.STOPPING}}; + } + + @Filter + public static class EnvironmentFilter implements RequestFilter { + public void doFilter(GenericContainerRequest request) { + EnvironmentContext.getCurrent().setSubject(TEST_USER); + } + + } + + private WorkspaceImpl createWorkspace(String namespace, WorkspaceStatus status) { + final WorkspaceConfigImpl config = WorkspaceConfigImpl.builder() + .setName("dev-workspace") + .setDefaultEnv("dev-env") + .build(); + + return WorkspaceImpl.builder().setConfig(config) + .generateId() + .setAccount(new AccountImpl("accountId", namespace, "test")) + .setStatus(status) + .build(); + } +}