Skip to content

Commit

Permalink
Prompt users to continue their Dev Environment session (#3832)
Browse files Browse the repository at this point in the history
* added heartbeat

* feedback changes 1

* removed changes

* added more tests

* feedback changes 2

* addressed feedback

* new changes

* corrected millisecond diff

* detekt

* feedback changes

* feedbackc hanges

* added tests

* addressed feedback

* changed test

* detekt

* removed unused comma

* addressed feedback

* fixed detekt

* Added test

* detekt

* detekt

* detekt

* prop change
  • Loading branch information
manodnyab authored Oct 9, 2023
1 parent f68cfb5 commit 56797f0
Show file tree
Hide file tree
Showing 9 changed files with 267 additions and 1 deletion.
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ toolkitVersion=1.83-SNAPSHOT
publishToken=
publishChannel=

ideProfileName=2022.3
ideProfileName=2023.2

remoteRobotPort=8080

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import com.intellij.openapi.components.service
import org.apache.http.client.methods.CloseableHttpResponse
import org.apache.http.client.methods.HttpGet
import org.apache.http.client.methods.HttpPost
import org.apache.http.client.methods.HttpPut
import org.apache.http.client.methods.HttpUriRequest
import org.apache.http.entity.ContentType
import org.apache.http.entity.StringEntity
Expand All @@ -21,8 +22,10 @@ import software.aws.toolkits.core.utils.getLogger
import software.aws.toolkits.jetbrains.services.caws.CawsConstants
import software.aws.toolkits.jetbrains.services.caws.envclient.models.CreateDevfileRequest
import software.aws.toolkits.jetbrains.services.caws.envclient.models.CreateDevfileResponse
import software.aws.toolkits.jetbrains.services.caws.envclient.models.GetActivityResponse
import software.aws.toolkits.jetbrains.services.caws.envclient.models.GetStatusResponse
import software.aws.toolkits.jetbrains.services.caws.envclient.models.StartDevfileRequest
import software.aws.toolkits.jetbrains.services.caws.envclient.models.UpdateActivityRequest
import software.aws.toolkits.jetbrains.utils.notifyError
import software.aws.toolkits.resources.message

Expand Down Expand Up @@ -88,6 +91,32 @@ class CawsEnvironmentClient(
return objectMapper.readValue(response.entity.content)
}

fun getActivity(): GetActivityResponse? = try {
val request = HttpGet("$endpoint/activity")
val response = execute(request)
if (response.statusLine.statusCode == 400) {
LOG.error { "Inactivity tracking may not enabled" }
null
} else {
objectMapper.readValue<GetActivityResponse>(response.entity.content)
}
} catch (e: Exception) {
LOG.error(e) { "Couldn't parse response from /activity API" }
null
}

fun putActivityTimestamp(request: UpdateActivityRequest) {
try {
val body = objectMapper.writeValueAsString(request)
val httpRequest = HttpPut("$endpoint/activity").also {
it.entity = StringEntity(body, ContentType.APPLICATION_JSON)
}
val response = execute(httpRequest).use {}
} catch (e: Exception) {
LOG.error(e) { "Couldn't execute /activity API" }
}
}

private fun execute(request: HttpUriRequest): CloseableHttpResponse {
request.addHeader("Authorization", authToken)
return httpClient.execute(request)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.jetbrains.services.caws.envclient.models

data class GetActivityResponse(
val timestamp: String
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.jetbrains.services.caws.envclient.models

data class UpdateActivityRequest(
val timestamp: String? = null
)
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,23 @@ class CawsEnvironmentClientTest {

assertThat(sut.getStatus().status).isEqualTo(GetStatusResponse.Status.IMAGES_UPDATE_AVAILABLE)
}

@Test
fun `getActivity returns timestamp`() {
wireMockRule.stubFor(
WireMock.any(WireMock.urlPathEqualTo("/activity"))
.willReturn(
WireMock.aResponse().withBody(
// language=JSON
"""
{
"timestamp": "112222444455555"
}
""".trimIndent()
)
)
)

assertThat(sut.getActivity()?.timestamp).isEqualTo("112222444455555")
}
}
2 changes: 2 additions & 0 deletions jetbrains-ultimate/resources/META-INF/ext-codewithme.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

<postStartupActivity implementation="software.aws.toolkits.jetbrains.services.caws.DevfileWatcher"/>
<applicationService serviceImplementation="software.aws.toolkits.jetbrains.services.caws.DevfileWatcher"/>
<postStartupActivity implementation="software.aws.toolkits.jetbrains.services.caws.DevEnvStatusWatcher"/>

<dynamicActionConfigurationCustomizer implementation="software.aws.toolkits.jetbrains.services.caws.RebuildActionConfigurationCustomizer"/>
<gateway.customization.tab implementation="software.aws.toolkits.jetbrains.services.caws.UpdateWorkspaceSettingsTab"/>
</extensions>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.jetbrains.services.caws

import com.intellij.openapi.project.Project
import com.intellij.openapi.startup.StartupActivity
import com.intellij.openapi.ui.MessageDialogBuilder
import com.jetbrains.rdserver.unattendedHost.UnattendedStatusUtil
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import software.amazon.awssdk.services.codecatalyst.CodeCatalystClient
import software.aws.toolkits.core.utils.error
import software.aws.toolkits.core.utils.getLogger
import software.aws.toolkits.core.utils.info
import software.aws.toolkits.jetbrains.core.awsClient
import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineBgContext
import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineUiContext
import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope
import software.aws.toolkits.jetbrains.core.credentials.sono.SonoCredentialManager
import software.aws.toolkits.jetbrains.services.caws.envclient.CawsEnvironmentClient
import software.aws.toolkits.jetbrains.services.caws.envclient.models.UpdateActivityRequest
import software.aws.toolkits.jetbrains.utils.notifyError
import software.aws.toolkits.resources.message
import java.time.Instant
import java.time.temporal.ChronoUnit

class DevEnvStatusWatcher : StartupActivity {

companion object {
private val LOG = getLogger<DevEnvStatusWatcher>()
}

override fun runActivity(project: Project) {
if (System.getenv(CawsConstants.CAWS_ENV_ID_VAR) == null) {
return
}
val connection = SonoCredentialManager.getInstance(project).getConnectionSettings()
?: error("Failed to fetch connection settings from Dev Environment")
val envId = System.getenv(CawsConstants.CAWS_ENV_ID_VAR) ?: error("envId env var null")
val org = System.getenv(CawsConstants.CAWS_ENV_ORG_NAME_VAR) ?: error("space env var null")
val projectName = System.getenv(CawsConstants.CAWS_ENV_PROJECT_NAME_VAR) ?: error("project env var null")
val client = connection.awsClient<CodeCatalystClient>()
val coroutineScope = projectCoroutineScope(project)
coroutineScope.launch(getCoroutineBgContext()) {
val initialEnv = client.getDevEnvironment {
it.id(envId)
it.spaceName(org)
it.projectName(projectName)
}
val inactivityTimeout = initialEnv.inactivityTimeoutMinutes()
if (inactivityTimeout == 0) {
LOG.info { "Dev environment inactivity timeout is 0, not monitoring" }
return@launch
}
val inactivityTimeoutInSeconds = inactivityTimeout * 60

// ensure the JetBrains inactivity tracker and the activity api are in sync
val jbActivityStatusJson = UnattendedStatusUtil.getStatus()
val jbActivityStatus = jbActivityStatusJson.projects?.first()?.secondsSinceLastControllerActivity ?: 0
notifyBackendOfActivity((getActivityTime(jbActivityStatus).toString()))
var secondsSinceLastControllerActivity = jbActivityStatus

while (true) {
val response = checkHeartbeat(secondsSinceLastControllerActivity, inactivityTimeoutInSeconds, project)
if (response.first) return@launch
delay(30000)
secondsSinceLastControllerActivity = response.second
}
}
}

// This function returns a Pair The first value is a boolean indicating if the API returned the last recorded activity.
// If inactivity tracking is disabled or if the value returned by the API is unparseable, the heartbeat is not sent
// The second value indicates the seconds since last activity as recorded by JB in the most recent run
fun checkHeartbeat(
secondsSinceLastControllerActivity: Long,
inactivityTimeoutInSeconds: Int,
project: Project
): Pair<Boolean, Long> {
val lastActivityTime = getJbRecordedActivity()

if (lastActivityTime < secondsSinceLastControllerActivity) {
// update the API in case of any activity
notifyBackendOfActivity((getActivityTime(lastActivityTime).toString()))
}

val lastRecordedActivityTime = getLastRecordedApiActivity()
if (lastRecordedActivityTime == null) {
LOG.error { "Couldn't retrieve last recorded activity from API" }
return Pair(true, lastActivityTime)
}
val durationRecordedSinceLastActivity = Instant.now().toEpochMilli().minus(lastRecordedActivityTime.toLong())
val secondsRecordedSinceLastActivity = durationRecordedSinceLastActivity / 1000

if (secondsRecordedSinceLastActivity >= (inactivityTimeoutInSeconds - 300)) {
try {
val inactivityDurationInMinutes = secondsRecordedSinceLastActivity / 60
val ans = runBlocking {
val continueWorking = withContext(getCoroutineUiContext()) {
return@withContext MessageDialogBuilder.okCancel(
message("caws.devenv.continue.working.after.timeout.title"),
message("caws.devenv.continue.working.after.timeout", inactivityDurationInMinutes)
).ask(project)
}
return@runBlocking continueWorking
}

if (ans) {
notifyBackendOfActivity(getActivityTime().toString())
}
} catch (e: Exception) {
val preMessage = "Error while checking if Dev Environment should continue working"
LOG.error(e) { preMessage }
notifyError(preMessage, e.message.toString())
}
}
return Pair(false, lastActivityTime)
}

fun getLastRecordedApiActivity(): String? = CawsEnvironmentClient.getInstance().getActivity()?.timestamp

fun getJbRecordedActivity(): Long {
val statusJson = UnattendedStatusUtil.getStatus()
val lastActivityTime = statusJson.projects?.first()?.secondsSinceLastControllerActivity ?: 0
return lastActivityTime
}

fun notifyBackendOfActivity(timestamp: String = Instant.now().toEpochMilli().toString()) {
val request = UpdateActivityRequest(
timestamp = timestamp
)
CawsEnvironmentClient.getInstance().putActivityTimestamp(request)
}

private fun getActivityTime(secondsSinceLastActivity: Long = 0): Long = Instant.now().minus(secondsSinceLastActivity, ChronoUnit.SECONDS).toEpochMilli()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

package software.aws.toolkits.jetbrains.services

import com.intellij.openapi.ui.TestDialog
import com.intellij.openapi.ui.TestDialogManager
import com.intellij.testFramework.ProjectRule
import org.assertj.core.api.Assertions.assertThat
import org.junit.Rule
import org.junit.Test
import org.mockito.kotlin.any
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.spy
import org.mockito.kotlin.times
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import software.aws.toolkits.jetbrains.services.caws.DevEnvStatusWatcher

class DevEnvStatusWatcherTest {
@JvmField
@Rule
val projectRule = ProjectRule()

@Test
fun `Heartbeat check stops if no response is returned by the API`() {
val sut = DevEnvStatusWatcher()
val devEnvStatusWatcher = spy<DevEnvStatusWatcher>(sut) {
doReturn(600.toLong()).whenever(it).getJbRecordedActivity()
doReturn(null).whenever(it).getLastRecordedApiActivity()
}
val response = devEnvStatusWatcher.checkHeartbeat(0, 0, projectRule.project)
assertThat(response.first).isTrue()
}

@Test
fun `API is called if user extends the timeout 5 minutes before inactivity timeout`() {
val sut = DevEnvStatusWatcher()
val devEnvStatusWatcher = spy<DevEnvStatusWatcher>(sut) {
doReturn(600.toLong()).whenever(it).getJbRecordedActivity()
doReturn("1672531261000").whenever(it).getLastRecordedApiActivity()
}
TestDialogManager.setTestDialog(TestDialog.OK)
devEnvStatusWatcher.checkHeartbeat(0, 900, projectRule.project)
verify(devEnvStatusWatcher).notifyBackendOfActivity(any())
}

@Test
fun `API is not called if user doesn't extend the timeout 5 minutes before inactivity timeout`() {
val sut = DevEnvStatusWatcher()
val devEnvStatusWatcher = spy<DevEnvStatusWatcher>(sut) {
doReturn(600.toLong()).whenever(it).getJbRecordedActivity()
doReturn("1672531261000").whenever(it).getLastRecordedApiActivity()
}
TestDialogManager.setTestDialog(TestDialog.NO)
devEnvStatusWatcher.checkHeartbeat(0, 900, projectRule.project)
verify(devEnvStatusWatcher, times(0)).notifyBackendOfActivity(any())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,8 @@ caws.delete_failed=Delete Dev Environment Failed
caws.delete_workspace=Delete
caws.delete_workspace_warning=Are you sure you wish to delete the Dev Environment? All data will be deleted
caws.delete_workspace_warning_title=Confirm Deletion
caws.devenv.continue.working.after.timeout=Your dev environment has had no activity in the past {0} minutes and will be terminated within 5 minutes. Press OK to continue working
caws.devenv.continue.working.after.timeout.title=Do you want to continue working?
caws.devfile.schema=Devfile Schema
caws.devtoolPanel.fetch.git.url=Fetching Git clone URL for {0}
caws.devtoolPanel.git_url_copied=Clone URL copied to clipboard
Expand Down

0 comments on commit 56797f0

Please sign in to comment.