From 309cb67b32e23b3325927885b789b8b37adc5237 Mon Sep 17 00:00:00 2001 From: Kirill Gevorkyan <26010098+kgevorkyan@users.noreply.github.com> Date: Fri, 19 Aug 2022 16:34:56 +0300 Subject: [PATCH 1/7] Add random value to the container name in order to avoid conflicts (#1065) ### What's done: * Add random value to the container name in order to avoid conflicts --- .../save/orchestrator/docker/DockerAgentRunner.kt | 5 ++++- .../save/orchestrator/docker/DockerContainerManagerTest.kt | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/docker/DockerAgentRunner.kt b/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/docker/DockerAgentRunner.kt index 7d3a4ae239..10b05fe2c3 100644 --- a/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/docker/DockerAgentRunner.kt +++ b/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/docker/DockerAgentRunner.kt @@ -31,6 +31,8 @@ import java.util.concurrent.ConcurrentMap import kotlin.io.path.createTempDirectory import kotlin.io.path.writeText +import kotlin.math.abs +import kotlin.random.Random /** * [AgentRunner] that uses Docker Daemon API to run save-agents @@ -254,4 +256,5 @@ class DockerAgentRunner( /** * @param id */ -private fun containerName(id: String) = "save-execution-$id" +@Suppress("MAGIC_NUMBER", "MagicNumber") +private fun containerName(id: String) = "save-execution-$id-${abs(Random.nextInt(100, 999))}" diff --git a/save-orchestrator/src/test/kotlin/com/saveourtool/save/orchestrator/docker/DockerContainerManagerTest.kt b/save-orchestrator/src/test/kotlin/com/saveourtool/save/orchestrator/docker/DockerContainerManagerTest.kt index ce3ef600ac..27bd85a0ef 100644 --- a/save-orchestrator/src/test/kotlin/com/saveourtool/save/orchestrator/docker/DockerContainerManagerTest.kt +++ b/save-orchestrator/src/test/kotlin/com/saveourtool/save/orchestrator/docker/DockerContainerManagerTest.kt @@ -76,7 +76,7 @@ class DockerContainerManagerTest { inspectContainerResponse.args ) // leading extra slash: https://github.com/moby/moby/issues/6705 - Assertions.assertEquals("/save-execution-42-1", inspectContainerResponse.name) + Assertions.assertTrue(inspectContainerResponse.name.startsWith("/save-execution-42-1")) val resourceFile = createTempFile().toFile() resourceFile.writeText("Lorem ipsum dolor sit amet") From 8a6a8fb8463a9e6730ac1831dbeaed9cafb8be9e Mon Sep 17 00:00:00 2001 From: Peter Trifanov Date: Mon, 22 Aug 2022 11:16:06 +0300 Subject: [PATCH 2/7] Download test resources and additional files on save-agent (#1058) * Download test resources and additional files on save-agent * Provide agent with additional information related to resources using environment variables and `.env` file * Provide `save-agent`, `save-cli` and `.env` via mount, then copy these resources and use it independently for all agents * Remove test resources downloading logic from orchestrator * [Gradle] Fix task dependencies for local DB - `liquibaseUpdate` will no more run if `startMysqlDb` fails * [Gradle] Fix regex for docker image tags - allow capital letters Part of #1048 --- .github/workflows/build-base-images.yaml | 10 ++ .../buildutils/DockerStackConfiguration.kt | 10 +- .../buildutils/VersioningConfiguration.kt | 2 +- save-agent/README.md | 6 +- .../save/agent/AgentConfiguration.kt | 4 + .../kotlin/com/saveourtool/save/agent/Main.kt | 10 +- .../com/saveourtool/save/agent/Requests.kt | 110 +++++++++++++ .../com/saveourtool/save/agent/SaveAgent.kt | 41 +++-- .../saveourtool/save/agent/utils/FileUtils.kt | 65 ++++++++ .../saveourtool/save/agent/utils/HttpUtils.kt | 41 ++++- .../save/agent/utils/LinuxUtils.kt | 19 +++ .../linuxX64Main/resources/agent.properties | 3 +- save-backend/backend-api-docs.json | 91 +++++++---- .../controllers/DownloadFilesController.kt | 38 +++++ .../save/backend/DownloadFilesTest.kt | 2 + save-cloud-charts/save-cloud/README.md | 5 +- .../templates/orchestrator-configmap.yaml | 2 +- .../save-cloud/values-minikube.yaml | 4 +- save-deploy/base-images/Dockerfile | 9 +- .../saveourtool/save/orchestrator/Utils.kt | 2 + .../controller/AgentsController.kt | 146 +----------------- .../orchestrator/docker/DockerAgentRunner.kt | 25 +-- .../kubernetes/KubernetesManager.kt | 41 +++-- .../save/orchestrator/runner/AgentRunner.kt | 2 - .../orchestrator/service/DockerService.kt | 37 ++++- .../src/main/resources/application.properties | 4 +- .../controller/agents/AgentsControllerTest.kt | 20 ++- .../docker/DockerContainerManagerTest.kt | 10 +- .../orchestrator/service/DockerServiceTest.kt | 14 +- .../src/test/resources/application.properties | 24 +-- 30 files changed, 529 insertions(+), 268 deletions(-) create mode 100644 save-agent/src/linuxX64Main/kotlin/com/saveourtool/save/agent/Requests.kt create mode 100644 save-agent/src/linuxX64Main/kotlin/com/saveourtool/save/agent/utils/LinuxUtils.kt diff --git a/.github/workflows/build-base-images.yaml b/.github/workflows/build-base-images.yaml index f4c787e08a..87c4a9e0fd 100644 --- a/.github/workflows/build-base-images.yaml +++ b/.github/workflows/build-base-images.yaml @@ -4,6 +4,12 @@ on: schedule: - cron: '0 4 * * 1' workflow_dispatch: + inputs: + branch: + type: string + default: master + description: Branch to build images from + required: false jobs: build_base_images: @@ -42,6 +48,10 @@ jobs: base_image_tag: '3.10' steps: - uses: actions/checkout@v3 + - if: github.event_name == 'workflow_dispatch' + name: Prepare to build from branch + run: | + git checkout origin/${{ inputs.branch }} - uses: docker/login-action@v2 with: registry: ghcr.io diff --git a/buildSrc/src/main/kotlin/com/saveourtool/save/buildutils/DockerStackConfiguration.kt b/buildSrc/src/main/kotlin/com/saveourtool/save/buildutils/DockerStackConfiguration.kt index 31ad35bd01..da9084bd58 100644 --- a/buildSrc/src/main/kotlin/com/saveourtool/save/buildutils/DockerStackConfiguration.kt +++ b/buildSrc/src/main/kotlin/com/saveourtool/save/buildutils/DockerStackConfiguration.kt @@ -130,7 +130,7 @@ fun Project.createStackDeployTask(profile: String) { } // in case you are running it on MAC, first do the following: docker pull --platform linux/x86_64 mysql - tasks.register("startMysqlDb") { + tasks.register("startMysqlDbService") { dependsOn("generateComposeFile") doFirst { logger.lifecycle("Running the following command: [docker-compose --file $buildDir/docker-compose.yaml up -d mysql]") @@ -143,7 +143,13 @@ fun Project.createStackDeployTask(profile: String) { Thread.sleep(MYSQL_STARTUP_DELAY_MILLIS) // wait for mysql to start, can be manually increased when needed } } - finalizedBy("liquibaseUpdate") + } + tasks.named("liquibaseUpdate") { + mustRunAfter("startMysqlDbService") + } + tasks.register("startMysqlDb") { + dependsOn("liquibaseUpdate") + dependsOn("startMysqlDbService") } tasks.register("restartMysqlDb") { diff --git a/buildSrc/src/main/kotlin/com/saveourtool/save/buildutils/VersioningConfiguration.kt b/buildSrc/src/main/kotlin/com/saveourtool/save/buildutils/VersioningConfiguration.kt index 04a1d0b214..b2247da34c 100644 --- a/buildSrc/src/main/kotlin/com/saveourtool/save/buildutils/VersioningConfiguration.kt +++ b/buildSrc/src/main/kotlin/com/saveourtool/save/buildutils/VersioningConfiguration.kt @@ -66,7 +66,7 @@ fun Project.configureVersioning() { */ fun Project.versionForDockerImages(): String = (project.findProperty("dockerTag") as String? ?: version.toString()) - .replace(Regex("[^._\\-a-z0-9]"), "-") + .replace(Regex("[^._\\-a-zA-Z0-9]"), "-") /** * Register task that reads version of save-cli, either from project property, or from Versions, or latest diff --git a/save-agent/README.md b/save-agent/README.md index 37fc07cf8c..e16632f87b 100644 --- a/save-agent/README.md +++ b/save-agent/README.md @@ -1,5 +1,7 @@ # Building Read the [official docs](https://github.com/JetBrains/kotlin/tree/master/kotlin-native#kotlinnative) and install all dependencies listed in [Prerequisites](https://github.com/JetBrains/kotlin/tree/master/kotlin-native#building-from-source) section. -On windows you'll also need to install msys2 and run `pacman -S mingw-w64-x86_64-curl` to have libcurl for ktor-client. -On ubuntu install `libcurl4-openssl-dev` for ktor client. \ No newline at end of file +On Windows you'll also need to install msys2 and run `pacman -S mingw-w64-x86_64-curl` to have libcurl for ktor-client. +On ubuntu install `libcurl4-openssl-dev` for ktor client. + +`save-agent` also requires `unzip` to be present on `$PATH`. diff --git a/save-agent/src/linuxX64Main/kotlin/com/saveourtool/save/agent/AgentConfiguration.kt b/save-agent/src/linuxX64Main/kotlin/com/saveourtool/save/agent/AgentConfiguration.kt index 281fb18cec..c83b42178e 100644 --- a/save-agent/src/linuxX64Main/kotlin/com/saveourtool/save/agent/AgentConfiguration.kt +++ b/save-agent/src/linuxX64Main/kotlin/com/saveourtool/save/agent/AgentConfiguration.kt @@ -24,6 +24,7 @@ import kotlinx.serialization.Serializable * @property cliCommand a command that agent will use to run SAVE cli * @property debug whether debug logging should be enabled * @property retry configuration for HTTP request retries + * @property testSuitesDir directory where tests and additional files need to be stored into * @property logFilePath path to logs of save-cli execution * @property save additional configuration for save-cli */ @@ -37,6 +38,7 @@ data class AgentConfiguration( val retry: RetryConfig, val debug: Boolean = false, val cliCommand: String, + val testSuitesDir: String, val logFilePath: String = "logs.txt", val save: SaveCliConfig = SaveCliConfig(), ) { @@ -68,12 +70,14 @@ data class HeartbeatConfig( * @property additionalDataEndpoint endpoint to post additional data (version etc.) to * @property executionDataEndpoint endpoint to post execution data to * @property filesEndpoint endpoint to post debug info to + * @property testSourceSnapshotEndpoint endpoint to download test source snapshots from */ @Serializable data class BackendConfig( val url: String, val additionalDataEndpoint: String = "internal/saveAgentVersion", val executionDataEndpoint: String = "internal/saveTestResult", + val testSourceSnapshotEndpoint: String = "/internal/test-suites-sources/download-snapshot-by-execution-id", val filesEndpoint: String = "internal/files", ) diff --git a/save-agent/src/linuxX64Main/kotlin/com/saveourtool/save/agent/Main.kt b/save-agent/src/linuxX64Main/kotlin/com/saveourtool/save/agent/Main.kt index 3a8b2a5cd3..e293b0cc54 100644 --- a/save-agent/src/linuxX64Main/kotlin/com/saveourtool/save/agent/Main.kt +++ b/save-agent/src/linuxX64Main/kotlin/com/saveourtool/save/agent/Main.kt @@ -6,6 +6,7 @@ package com.saveourtool.save.agent import com.saveourtool.save.agent.utils.logDebugCustom import com.saveourtool.save.agent.utils.logInfoCustom +import com.saveourtool.save.agent.utils.markAsExecutable import com.saveourtool.save.agent.utils.readProperties import com.saveourtool.save.core.config.LogType import com.saveourtool.save.core.logging.logType @@ -16,6 +17,8 @@ import io.ktor.client.HttpClient import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.json.JsonPlugin import io.ktor.client.plugins.kotlinx.serializer.KotlinxSerializer +import okio.FileSystem +import okio.Path.Companion.toPath import platform.posix.* import kotlinx.cinterop.staticCFunction @@ -42,6 +45,8 @@ internal val json = Json { } } +internal val fs = FileSystem.SYSTEM + @OptIn(ExperimentalSerializationApi::class) fun main() { val config: AgentConfiguration = Properties.decodeFromStringMap( @@ -50,10 +55,7 @@ fun main() { logType.set(if (config.debug) LogType.ALL else LogType.WARN) logDebugCustom("Instantiating save-agent version $SAVE_CLOUD_VERSION with config $config") - platform.posix.chmod( - "save-$SAVE_CORE_VERSION-linuxX64.kexe", - (S_IRUSR or S_IWUSR or S_IXUSR or S_IRGRP or S_IROTH).toUInt() - ) + "save-$SAVE_CORE_VERSION-linuxX64.kexe".toPath().markAsExecutable() signal(SIGTERM, staticCFunction { logInfoCustom("Agent is shutting down because SIGTERM has been received") diff --git a/save-agent/src/linuxX64Main/kotlin/com/saveourtool/save/agent/Requests.kt b/save-agent/src/linuxX64Main/kotlin/com/saveourtool/save/agent/Requests.kt new file mode 100644 index 0000000000..729dfe5e7f --- /dev/null +++ b/save-agent/src/linuxX64Main/kotlin/com/saveourtool/save/agent/Requests.kt @@ -0,0 +1,110 @@ +/** + * Utilities to perform requests to other services of save-cloud + */ + +package com.saveourtool.save.agent + +import com.saveourtool.save.agent.utils.* +import com.saveourtool.save.agent.utils.extractZipTo +import com.saveourtool.save.agent.utils.markAsExecutable +import com.saveourtool.save.agent.utils.unzipIfRequired +import com.saveourtool.save.agent.utils.writeToFile +import com.saveourtool.save.core.logging.logWarn +import com.saveourtool.save.core.utils.runIf +import com.saveourtool.save.domain.FileKey + +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import okio.Path +import okio.Path.Companion.toPath + +/** + * Download test source snapshots for execution [executionId] into [target] + * + * @param config + * @param target + * @param executionId + * @return result + */ +internal suspend fun SaveAgent.downloadTestResources(config: BackendConfig, target: Path, executionId: String): Result = runCatching { + val result = httpClient.downloadTestResources(config, executionId) + if (updateStateBasedOnBackendResponse(result)) { + return@runCatching + } + + val bytes = result.getOrThrow() + .readByteArrayOrThrowIfEmpty { + error("Not found any tests for execution $executionId") + } + val pathToArchive = "archive.zip".toPath() + logDebugCustom("Writing downloaded archive of size ${bytes.size} into $pathToArchive") + bytes.writeToFile(pathToArchive) + fs.createDirectories(target, mustCreate = false) + pathToArchive.extractZipTo(target) + fs.delete(pathToArchive, mustExist = true) + logDebugCustom("Extracted archive into $target and deleted $pathToArchive") +} + +/** + * Download additional resources from [additionalResourcesAsString] into [targetDirectory] + * + * @param baseUrl + * @param targetDirectory + * @param additionalResourcesAsString + * @param executionId + * @return result + */ +internal suspend fun SaveAgent.downloadAdditionalResources( + baseUrl: String, + targetDirectory: Path, + additionalResourcesAsString: String, + executionId: String, +) = runCatching { + FileKey.parseList(additionalResourcesAsString) + .map { fileKey -> + val result = httpClient.downloadFile( + "$baseUrl/internal/files/download?executionId=$executionId", + fileKey + ) + if (updateStateBasedOnBackendResponse(result)) { + return@runCatching + } + + val fileContentBytes = result.getOrThrow() + .readByteArrayOrThrowIfEmpty { + error("Couldn't download file $fileKey: content is empty") + } + val targetFile = targetDirectory / fileKey.name + fileContentBytes.writeToFile(targetFile) + fileKey to targetFile + } + .onEach { (fileKey, pathToFile) -> + pathToFile.markAsExecutable() + logDebugCustom( + "Downloaded $fileKey into ${fs.canonicalize(pathToFile)}" + ) + } + .map { (_, pathToFile) -> + unzipIfRequired(pathToFile) + } + .ifEmpty { + logWarn("Not found any additional files for execution \$id") + emptyList() + } +} + +private suspend fun HttpClient.downloadTestResources(config: BackendConfig, executionId: String) = download( + url = "${config.url}${config.testSourceSnapshotEndpoint}?executionId=$executionId", + body = null, +) + +private suspend fun HttpClient.downloadFile(url: String, fileKey: FileKey): Result = download( + url = url, + body = fileKey, +) + +private suspend fun HttpResponse.readByteArrayOrThrowIfEmpty(exceptionSupplier: ByteArray.() -> Nothing) = + body().runIf({ isEmpty() }, exceptionSupplier) diff --git a/save-agent/src/linuxX64Main/kotlin/com/saveourtool/save/agent/SaveAgent.kt b/save-agent/src/linuxX64Main/kotlin/com/saveourtool/save/agent/SaveAgent.kt index 66d980119d..e554b1c08b 100644 --- a/save-agent/src/linuxX64Main/kotlin/com/saveourtool/save/agent/SaveAgent.kt +++ b/save-agent/src/linuxX64Main/kotlin/com/saveourtool/save/agent/SaveAgent.kt @@ -2,16 +2,16 @@ package com.saveourtool.save.agent -import com.saveourtool.save.agent.utils.logDebugCustom -import com.saveourtool.save.agent.utils.logErrorCustom -import com.saveourtool.save.agent.utils.logInfoCustom +import com.saveourtool.save.agent.utils.* import com.saveourtool.save.agent.utils.readFile +import com.saveourtool.save.agent.utils.requiredEnv import com.saveourtool.save.agent.utils.sendDataToBackend import com.saveourtool.save.core.logging.describe import com.saveourtool.save.core.plugin.Plugin import com.saveourtool.save.core.result.CountWarnings import com.saveourtool.save.core.utils.ExecutionResult import com.saveourtool.save.core.utils.ProcessBuilder +import com.saveourtool.save.core.utils.runIf import com.saveourtool.save.domain.TestResultDebugInfo import com.saveourtool.save.plugins.fix.FixPlugin import com.saveourtool.save.reporter.Report @@ -25,7 +25,6 @@ import io.ktor.client.request.* import io.ktor.client.request.forms.* import io.ktor.client.statement.* import io.ktor.http.* -import io.ktor.util.* import io.ktor.utils.io.core.* import okio.FileSystem import okio.Path.Companion.toPath @@ -33,14 +32,7 @@ import okio.buffer import kotlin.native.concurrent.AtomicLong import kotlin.native.concurrent.AtomicReference -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancel -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.newSingleThreadContext +import kotlinx.coroutines.* import kotlinx.datetime.Clock import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json @@ -52,10 +44,11 @@ import kotlinx.serialization.modules.subclass * A main class for SAVE Agent * @property config * @property coroutineScope a [CoroutineScope] to launch other jobs + * @property httpClient */ @Suppress("AVOID_NULL_CHECKS") class SaveAgent(internal val config: AgentConfiguration, - private val httpClient: HttpClient, + internal val httpClient: HttpClient, private val coroutineScope: CoroutineScope, ) { /** @@ -86,7 +79,28 @@ class SaveAgent(internal val config: AgentConfiguration, fun start(): Job { logInfoCustom("Starting agent") coroutineScope.launch(backgroundContext) { + state.value = AgentState.BUSY sendDataToBackend { saveAdditionalData() } + + logDebugCustom("Will now download tests") + val executionId = requiredEnv("EXECUTION_ID") + val targetDirectory = config.testSuitesDir.toPath() + downloadTestResources(config.backend, targetDirectory, executionId).runIf({ isFailure }) { + logErrorCustom("Unable to download tests for execution $executionId: ${exceptionOrNull()?.describe()}") + state.value = AgentState.CRASHED + return@launch + } + logInfoCustom("Downloaded all tests for execution $executionId to $targetDirectory") + + logDebugCustom("Will now download additional resources") + val additionalFilesList = requiredEnv("ADDITIONAL_FILES_LIST") + downloadAdditionalResources(config.backend.url, targetDirectory, additionalFilesList, executionId).runIf({ isFailure }) { + logErrorCustom("Unable to download resources for execution $executionId based on list [$additionalFilesList]: ${exceptionOrNull()?.describe()}") + state.value = AgentState.CRASHED + return@launch + } + + state.value = AgentState.STARTING } return coroutineScope.launch { startHeartbeats(this) } } @@ -289,7 +303,6 @@ class SaveAgent(internal val config: AgentConfiguration, /** * @param byteArray byte array with logs of CLI execution progress that will be sent in a message */ - @OptIn(InternalAPI::class) private suspend fun sendLogs(byteArray: ByteArray): HttpResponse = httpClient.post { url("${config.orchestratorUrl}/executionLogs") diff --git a/save-agent/src/linuxX64Main/kotlin/com/saveourtool/save/agent/utils/FileUtils.kt b/save-agent/src/linuxX64Main/kotlin/com/saveourtool/save/agent/utils/FileUtils.kt index 9056f1f736..2d27d57edb 100644 --- a/save-agent/src/linuxX64Main/kotlin/com/saveourtool/save/agent/utils/FileUtils.kt +++ b/save-agent/src/linuxX64Main/kotlin/com/saveourtool/save/agent/utils/FileUtils.kt @@ -4,9 +4,54 @@ package com.saveourtool.save.agent.utils +import com.saveourtool.save.agent.fs +import com.saveourtool.save.core.files.findAllFilesMatching import okio.FileNotFoundException import okio.FileSystem +import okio.Path import okio.Path.Companion.toPath +import platform.posix.S_IRGRP +import platform.posix.S_IROTH +import platform.posix.S_IRUSR +import platform.posix.S_IWUSR +import platform.posix.S_IXUSR + +/** + * Extract path as ZIP archive to provided directory + * + * @param targetPath + */ +internal fun Path.extractZipTo(targetPath: Path) { + require(fs.metadata(targetPath).isDirectory) + logDebugCustom("Unzip ${fs.canonicalize(this)} into ${fs.canonicalize(targetPath)}") + platform.posix.system("unzip $this -d $targetPath") +} + +/** + * Write content of [this] into a file [file] + * + * @receiver [ByteArray] to be written into a file + * @param file target [Path] + * @param mustCreate will be passed to Okio's [FileSystem.write] + */ +internal fun ByteArray.writeToFile(file: Path, mustCreate: Boolean = true) { + fs.write( + file = file, + mustCreate = mustCreate, + ) { + write(this@writeToFile).flush() + } +} + +/** + * Mark [this] file as executable. Sets permissions to rwxr--r-- + */ +internal fun Path.markAsExecutable() { + platform.posix.chmod( + this.toString(), + (S_IRUSR or S_IWUSR or S_IXUSR or S_IRGRP or S_IROTH).toUInt() + ) +} /** * Read file as a list of strings @@ -37,3 +82,23 @@ internal fun readProperties(filePath: String): Map = readFile(fi it.first() to it.last() } } + +/** + * @param pathToFile + */ +internal fun unzipIfRequired( + pathToFile: Path, +) { + // FixMe: for now support only .zip files + if (pathToFile.name.endsWith(".zip")) { + pathToFile.extractZipTo(pathToFile.parent!!) + // fixme: need to store information about isExecutable in Execution (FileKey) + pathToFile.parent!!.findAllFilesMatching { + if (fs.metadata(it).isRegularFile) { + it.markAsExecutable() + } + true + } + fs.delete(pathToFile, mustExist = true) + } +} diff --git a/save-agent/src/linuxX64Main/kotlin/com/saveourtool/save/agent/utils/HttpUtils.kt b/save-agent/src/linuxX64Main/kotlin/com/saveourtool/save/agent/utils/HttpUtils.kt index 942961ac78..ad6e90f9d8 100644 --- a/save-agent/src/linuxX64Main/kotlin/com/saveourtool/save/agent/utils/HttpUtils.kt +++ b/save-agent/src/linuxX64Main/kotlin/com/saveourtool/save/agent/utils/HttpUtils.kt @@ -7,13 +7,32 @@ package com.saveourtool.save.agent.utils import com.saveourtool.save.agent.AgentState import com.saveourtool.save.agent.RetryConfig import com.saveourtool.save.agent.SaveAgent +import io.ktor.client.* +import io.ktor.client.plugins.* +import io.ktor.client.request.* import io.ktor.client.statement.HttpResponse -import io.ktor.http.HttpStatusCode +import io.ktor.http.* import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay +/** + * @param result + * @return true if [this] agent's state has been updated to reflect problems with [result] + */ +internal fun SaveAgent.updateStateBasedOnBackendResponse( + result: Result +) = if (result.isSuccess && result.getOrNull()?.status != HttpStatusCode.OK) { + state.value = AgentState.BACKEND_FAILURE + true +} else if (result.isFailure) { + state.value = AgentState.BACKEND_UNREACHABLE + true +} else { + false +} + /** * Attempt to send execution data to backend, will retry several times, while increasing delay 2 times on each iteration. * @@ -32,6 +51,26 @@ internal suspend fun SaveAgent.sendDataToBackend( logErrorCustom("Cannot post data (x${attempt + 1}), will retry in ${config.retry.initialRetryMillis} ms. Reason: $reason") } +/** + * Perform a POST request to [url] (optionally with body [body] that will be serialized as JSON), + * accepting application/octet-stream and return result wrapping [HttpResponse] + * + * @param url + * @param body + * @return result wrapping [HttpResponse] + */ +internal suspend fun HttpClient.download(url: String, body: Any?): Result = runCatching { + post { + url(url) + contentType(ContentType.Application.Json) + accept(ContentType.Application.OctetStream) + body?.let { setBody(it) } + onDownload { bytesSentTotal, contentLength -> + logDebugCustom("Received $bytesSentTotal bytes from $contentLength") + } + } +} + /** * @param retryConfig * @param request diff --git a/save-agent/src/linuxX64Main/kotlin/com/saveourtool/save/agent/utils/LinuxUtils.kt b/save-agent/src/linuxX64Main/kotlin/com/saveourtool/save/agent/utils/LinuxUtils.kt new file mode 100644 index 0000000000..ebe651e239 --- /dev/null +++ b/save-agent/src/linuxX64Main/kotlin/com/saveourtool/save/agent/utils/LinuxUtils.kt @@ -0,0 +1,19 @@ +/** + * Utilities to work with Linux-specific calls + */ + +package com.saveourtool.save.agent.utils + +import platform.posix.getenv + +import kotlinx.cinterop.toKString + +/** + * Get value of environment variable [name] or throw if it is not set. + * + * @param name name of the environment variable + * @return value of the environment variable + */ +internal fun requiredEnv(name: String): String = requireNotNull(getenv(name)) { + "Environment variable $name is not set but is required" +}.toKString() diff --git a/save-agent/src/linuxX64Main/resources/agent.properties b/save-agent/src/linuxX64Main/resources/agent.properties index d4b0c1d4a1..093a7c7840 100644 --- a/save-agent/src/linuxX64Main/resources/agent.properties +++ b/save-agent/src/linuxX64Main/resources/agent.properties @@ -6,4 +6,5 @@ requestTimeoutMillis=60000 retry.attempts=5 retry.initialRetryMillis=2000 debug=true -cliCommand=echo Provide CLI command here \ No newline at end of file +cliCommand=echo Provide CLI command here +testSuitesDir=. diff --git a/save-backend/backend-api-docs.json b/save-backend/backend-api-docs.json index 983914517e..a130082b8b 100644 --- a/save-backend/backend-api-docs.json +++ b/save-backend/backend-api-docs.json @@ -2029,7 +2029,7 @@ "/api/v1/image/upload": { "post": { "tags": [ - "download-files-controller" + "files" ], "operationId": "uploadImage", "parameters": [ @@ -2073,8 +2073,8 @@ } }, "responses": { - "200": { - "description": "OK", + "401": { + "description": "Unauthorized", "content": { "*/*": { "schema": { @@ -2083,13 +2083,18 @@ } } } - } + }, + "security": [ + { + "basic": [] + } + ] } }, "/api/v1/files/{organizationName}/{projectName}/upload": { "post": { "tags": [ - "download-files-controller" + "files" ], "operationId": "upload", "parameters": [ @@ -2138,8 +2143,8 @@ } }, "responses": { - "200": { - "description": "OK", + "401": { + "description": "Unauthorized", "content": { "*/*": { "schema": { @@ -2148,13 +2153,18 @@ } } } - } + }, + "security": [ + { + "basic": [] + } + ] } }, "/api/v1/files/{organizationName}/{projectName}/download": { "post": { "tags": [ - "download-files-controller" + "files" ], "operationId": "download", "parameters": [ @@ -2186,8 +2196,8 @@ "required": true }, "responses": { - "200": { - "description": "OK", + "401": { + "description": "Unauthorized", "content": { "application/octet-stream": { "schema": { @@ -2230,13 +2240,18 @@ } } } - } + }, + "security": [ + { + "basic": [] + } + ] } }, "/api/v1/files/get-execution-info": { "post": { "tags": [ - "download-files-controller" + "files" ], "operationId": "getExecutionInfo", "requestBody": { @@ -2250,8 +2265,8 @@ "required": true }, "responses": { - "200": { - "description": "OK", + "401": { + "description": "Unauthorized", "content": { "*/*": { "schema": { @@ -2294,13 +2309,18 @@ } } } - } + }, + "security": [ + { + "basic": [] + } + ] } }, "/api/v1/files/get-debug-info": { "post": { "tags": [ - "download-files-controller" + "files" ], "operationId": "getDebugInfo", "requestBody": { @@ -2314,8 +2334,8 @@ "required": true }, "responses": { - "200": { - "description": "OK", + "401": { + "description": "Unauthorized", "content": { "*/*": { "schema": { @@ -2358,7 +2378,12 @@ } } } - } + }, + "security": [ + { + "basic": [] + } + ] } }, "/api/v1/executionRequestStandardTests": { @@ -4862,7 +4887,7 @@ "/api/v1/files/{organizationName}/{projectName}/list": { "get": { "tags": [ - "download-files-controller" + "files" ], "operationId": "list_1", "parameters": [ @@ -4884,8 +4909,8 @@ } ], "responses": { - "200": { - "description": "OK", + "401": { + "description": "Unauthorized", "content": { "*/*": { "schema": { @@ -4897,7 +4922,12 @@ } } } - } + }, + "security": [ + { + "basic": [] + } + ] } }, "/api/v1/executionDto": { @@ -6355,7 +6385,7 @@ "/api/v1/files/{organizationName}/{projectName}/{creationTimestamp}": { "delete": { "tags": [ - "download-files-controller" + "files" ], "operationId": "delete", "parameters": [ @@ -6385,8 +6415,8 @@ } ], "responses": { - "200": { - "description": "OK", + "401": { + "description": "Unauthorized", "content": { "*/*": { "schema": { @@ -6395,7 +6425,12 @@ } } } - } + }, + "security": [ + { + "basic": [] + } + ] } } }, diff --git a/save-backend/src/main/kotlin/com/saveourtool/save/backend/controllers/DownloadFilesController.kt b/save-backend/src/main/kotlin/com/saveourtool/save/backend/controllers/DownloadFilesController.kt index d753ec4872..116b28ee31 100644 --- a/save-backend/src/main/kotlin/com/saveourtool/save/backend/controllers/DownloadFilesController.kt +++ b/save-backend/src/main/kotlin/com/saveourtool/save/backend/controllers/DownloadFilesController.kt @@ -2,16 +2,27 @@ package com.saveourtool.save.backend.controllers import com.saveourtool.save.agent.TestExecutionDto import com.saveourtool.save.backend.ByteBufferFluxResponse +import com.saveourtool.save.backend.configs.ApiSwaggerSupport import com.saveourtool.save.backend.repository.AgentRepository +import com.saveourtool.save.backend.service.ExecutionService import com.saveourtool.save.backend.service.OrganizationService import com.saveourtool.save.backend.service.ProjectService import com.saveourtool.save.backend.service.UserDetailsService import com.saveourtool.save.backend.storage.* +import com.saveourtool.save.backend.utils.blockingToMono import com.saveourtool.save.domain.* import com.saveourtool.save.from import com.saveourtool.save.permission.Permission import com.saveourtool.save.utils.AvatarType +import com.saveourtool.save.utils.switchIfEmptyToNotFound import com.saveourtool.save.v1 +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.Parameters +import io.swagger.v3.oas.annotations.enums.ParameterIn +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.tags.Tag +import io.swagger.v3.oas.annotations.tags.Tags import org.slf4j.LoggerFactory import org.springframework.http.HttpStatus @@ -32,6 +43,10 @@ import java.nio.ByteBuffer * A Spring controller for file downloading */ @RestController +@ApiSwaggerSupport +@Tags( + Tag(name = "files"), +) @Suppress("LongParameterList") class DownloadFilesController( private val fileStorage: FileStorage, @@ -39,6 +54,7 @@ class DownloadFilesController( private val debugInfoStorage: DebugInfoStorage, private val executionInfoStorage: ExecutionInfoStorage, private val agentRepository: AgentRepository, + private val executionService: ExecutionService, private val organizationService: OrganizationService, private val userDetailsService: UserDetailsService, private val projectService: ProjectService, @@ -105,6 +121,28 @@ class DownloadFilesController( @PathVariable projectName: String, ): Mono = downloadByFileKey(fileInfo.toStorageKey(), organizationName, projectName) + @PostMapping(path = ["/internal/files/download"], produces = [MediaType.APPLICATION_OCTET_STREAM_VALUE]) + @Operation( + method = "POST", + summary = "Download a file by execution ID and FileKey.", + description = "Download a file by execution ID and FileKey.", + ) + @Parameters( + Parameter(name = "executionId", `in` = ParameterIn.QUERY, description = "ID of an execution", required = true) + ) + @ApiResponse(responseCode = "200", description = "Returns content of the file.") + @ApiResponse(responseCode = "404", description = "Execution with provided ID is not found.") + fun downloadByExecutionId( + @RequestBody fileKey: FileKey, + @RequestParam executionId: Long, + ): Mono = blockingToMono { + executionService.findExecution(executionId) + } + .switchIfEmptyToNotFound() + .flatMap { execution -> + downloadByFileKey(fileKey, execution.project.organization.name, execution.project.name) + } + /** * @param fileKey a key [FileKey] of requested file * @param organizationName diff --git a/save-backend/src/test/kotlin/com/saveourtool/save/backend/DownloadFilesTest.kt b/save-backend/src/test/kotlin/com/saveourtool/save/backend/DownloadFilesTest.kt index fead50d0e4..41a756426f 100644 --- a/save-backend/src/test/kotlin/com/saveourtool/save/backend/DownloadFilesTest.kt +++ b/save-backend/src/test/kotlin/com/saveourtool/save/backend/DownloadFilesTest.kt @@ -6,6 +6,7 @@ import com.saveourtool.save.backend.configs.WebConfig import com.saveourtool.save.backend.controllers.DownloadFilesController import com.saveourtool.save.backend.repository.* import com.saveourtool.save.backend.security.ProjectPermissionEvaluator +import com.saveourtool.save.backend.service.ExecutionService import com.saveourtool.save.backend.service.OrganizationService import com.saveourtool.save.backend.service.ProjectService import com.saveourtool.save.backend.service.UserDetailsService @@ -79,6 +80,7 @@ import kotlin.io.path.* @MockBeans( MockBean(OrganizationService::class), MockBean(UserDetailsService::class), + MockBean(ExecutionService::class), ) class DownloadFilesTest { private val organization = Organization("Example.com", OrganizationStatus.CREATED, 1, null).apply { id = 2 } diff --git a/save-cloud-charts/save-cloud/README.md b/save-cloud-charts/save-cloud/README.md index 50a18fb904..17f54d7cd3 100644 --- a/save-cloud-charts/save-cloud/README.md +++ b/save-cloud-charts/save-cloud/README.md @@ -31,10 +31,7 @@ $ helm install save-cloud save-cloud-0.1.0.tgz --namespace save-cloud * Environment should be prepared: ```bash minikube ssh - docker@minikube:~$ for d in {repos, volumes, resources}; do sudo mkdir -p /tmp/save/$d && sudo chown -R 1000:100 /tmp/save/$d; done - docker@minikube:~$ sudo vi /lib/systemd/system/docker.service # change ExecSTart to allow HTTP connection to Docker daemon - docker@minikube:~$ sudo systemctl daemon-reload - docker@minikube:~$ sudo systemctl restart docker + docker@minikube:~$ for d in repos volumes resources; do sudo mkdir -p /tmp/save/$d && sudo chown -R 1000:100 /tmp/save/$d; done ``` * Install Helm chart using `values-minikube.yaml`: ```bash diff --git a/save-cloud-charts/save-cloud/templates/orchestrator-configmap.yaml b/save-cloud-charts/save-cloud/templates/orchestrator-configmap.yaml index 1649d1c183..2b4a04ff52 100644 --- a/save-cloud-charts/save-cloud/templates/orchestrator-configmap.yaml +++ b/save-cloud-charts/save-cloud/templates/orchestrator-configmap.yaml @@ -9,7 +9,7 @@ data: orchestrator.kubernetes.apiServerUrl=http://kubernetes.default.svc orchestrator.kubernetes.serviceAccount=${POD_SERVICE_ACCOUNT} orchestrator.kubernetes.namespace=${POD_NAMESPACE} - orchestrator.kubernetes.pvcMountPath=/home/save-agent + orchestrator.kubernetes.pvcMountPath=/home/save-agent/resources server.shutdown=graceful management.endpoints.web.exposure.include=* diff --git a/save-cloud-charts/save-cloud/values-minikube.yaml b/save-cloud-charts/save-cloud/values-minikube.yaml index a93a3e63a2..a1cf1092cf 100644 --- a/save-cloud-charts/save-cloud/values-minikube.yaml +++ b/save-cloud-charts/save-cloud/values-minikube.yaml @@ -19,7 +19,9 @@ orchestrator: orchestrator.kubernetes.pvcAnnotations= orchestrator.kubernetes.pvcSize=5Gi orchestrator.kubernetes.pvcStorageSpec=hostPath:\n\ - \ path: /tmp/save/volumes + \ path: /tmp/save/volumes\n\ + \accessModes:\n\ + \- ReadWriteMany mysql: external: false ip: nil diff --git a/save-deploy/base-images/Dockerfile b/save-deploy/base-images/Dockerfile index bf6d24b86c..2ce71c75a8 100644 --- a/save-deploy/base-images/Dockerfile +++ b/save-deploy/base-images/Dockerfile @@ -2,7 +2,7 @@ ARG BASE_IMAGE_NAME ARG BASE_IMAGE_TAG FROM $BASE_IMAGE_NAME:$BASE_IMAGE_TAG -RUN apt-get update && env DEBIAN_FRONTEND="noninteractive" apt-get install -y libcurl4-openssl-dev tzdata +RUN apt-get update && env DEBIAN_FRONTEND="noninteractive" apt-get install -y libcurl4-openssl-dev tzdata unzip RUN ln -fs /usr/share/zoneinfo/UTC /etc/localtime RUN rm -rf /var/lib/apt/lists/* @@ -13,5 +13,10 @@ RUN if [ "$BASE_IMAGE_NAME" = "python" ]; then \ ln -s $(which java) /usr/bin/java; \ fi -RUN groupadd --gid 1100 save-agent && useradd --uid 1100 --gid 1100 --create-home --shell /bin/sh save-agent +RUN groupadd --gid 1100 save-agent && \ + useradd --uid 1100 --gid 1100 --create-home --shell /bin/sh save-agent && \ + # `WORKDIR` directive creates the directory as `root` user unless the directory already exists + mkdir /home/save-agent/save-execution && \ + chown -R 1100:1100 /home/save-agent/save-execution +USER save-agent WORKDIR /home/save-agent/save-execution \ No newline at end of file diff --git a/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/Utils.kt b/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/Utils.kt index df3433bba4..4a86aa72b6 100644 --- a/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/Utils.kt +++ b/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/Utils.kt @@ -5,6 +5,7 @@ package com.saveourtool.save.orchestrator import com.saveourtool.save.orchestrator.config.ConfigProperties.AgentSettings +import com.saveourtool.save.orchestrator.runner.TEST_SUITES_DIR_NAME import com.github.dockerjava.api.DockerClient import com.github.dockerjava.api.async.ResultCallback @@ -113,6 +114,7 @@ internal fun fillAgentPropertiesFromConfiguration( "backend.url=${agentSettings.backendUrl}" line.startsWith("orchestratorUrl=") && agentSettings.orchestratorUrl != null -> "orchestratorUrl=${agentSettings.orchestratorUrl}" + line.startsWith("testSuitesDir=") -> "testSuitesDir=$TEST_SUITES_DIR_NAME" else -> line } } diff --git a/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/controller/AgentsController.kt b/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/controller/AgentsController.kt index 231a865282..26050de123 100644 --- a/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/controller/AgentsController.kt +++ b/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/controller/AgentsController.kt @@ -1,25 +1,21 @@ package com.saveourtool.save.orchestrator.controller -import com.saveourtool.save.domain.FileKey import com.saveourtool.save.entities.Agent import com.saveourtool.save.entities.Execution import com.saveourtool.save.execution.ExecutionStatus import com.saveourtool.save.orchestrator.BodilessResponseEntity import com.saveourtool.save.orchestrator.config.ConfigProperties -import com.saveourtool.save.orchestrator.runner.TEST_SUITES_DIR_NAME import com.saveourtool.save.orchestrator.service.AgentService import com.saveourtool.save.orchestrator.service.DockerService import com.saveourtool.save.orchestrator.utils.LoggingContextImpl -import com.saveourtool.save.orchestrator.utils.allExecute -import com.saveourtool.save.orchestrator.utils.tryMarkAsExecutable -import com.saveourtool.save.utils.* +import com.saveourtool.save.utils.debug +import com.saveourtool.save.utils.info import com.github.dockerjava.api.exception.DockerClientException import com.github.dockerjava.api.exception.DockerException import io.fabric8.kubernetes.client.KubernetesClientException import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Qualifier -import org.springframework.core.io.buffer.DataBuffer import org.springframework.http.HttpStatus import org.springframework.http.MediaType import org.springframework.http.ResponseEntity @@ -32,19 +28,14 @@ import org.springframework.web.bind.annotation.RequestPart import org.springframework.web.bind.annotation.RestController import org.springframework.web.reactive.function.client.WebClient import org.springframework.web.reactive.function.client.WebClientResponseException -import org.springframework.web.reactive.function.client.bodyToFlux import org.springframework.web.server.ResponseStatusException import reactor.core.publisher.Mono import reactor.kotlin.core.publisher.doOnError -import reactor.kotlin.core.publisher.toFlux import java.io.File import java.io.FileOutputStream -import java.nio.file.Files -import java.nio.file.Path -import java.nio.file.Paths -import kotlin.io.path.* +import kotlin.io.path.absolutePathString /** * Controller used to start agents with needed information @@ -56,15 +47,6 @@ class AgentsController( private val configProperties: ConfigProperties, @Qualifier("webClientBackend") private val webClientBackend: WebClient, ) { - // Somehow simple path.createDirectories() doesn't work on macOS, probably due to Apple File System features - private val tmpDir = Paths.get(configProperties.testResources.tmpPath).let { - if (it.exists()) { - it - } else { - it.createDirectories() - } - } - /** * Schedules tasks to build base images, create a number of containers and put their data into the database. * @@ -89,22 +71,10 @@ class AgentsController( "status=${execution.status}]" } Mono.fromCallable { - createTempDirectory( - directory = tmpDir, - prefix = "save-execution-${execution.id}" - ) + // todo: pass SDK via request body + dockerService.prepareConfiguration(execution) } - .flatMap { resourcesForExecution -> - val resourcesPath = resourcesForExecution.resolve(TEST_SUITES_DIR_NAME) - execution.downloadTestsTo(resourcesPath) - .then(execution.downloadAdditionalFilesTo(resourcesPath)) - .thenReturn(resourcesForExecution) - } - .publishOn(agentService.scheduler) - .map { - // todo: pass SDK via request body - dockerService.prepareConfiguration(it, execution) - } + .subscribeOn(agentService.scheduler) .onErrorResume({ it is DockerException || it is DockerClientException }) { dex -> reportExecutionError(execution, "Unable to build image and containers", dex) } @@ -137,110 +107,6 @@ class AgentsController( } } - private fun unzipIfRequired( - pathToFile: Path, - ) { - // FixMe: for now support only .zip files - if (pathToFile.name.endsWith(".zip")) { - val shouldBeExecutable = Files.getPosixFilePermissions(pathToFile).any { allExecute.contains(it) } - pathToFile.extractZipHereSafely() - if (shouldBeExecutable) { - log.info { "Marking files in ${pathToFile.parent} executable..." } - Files.walk(pathToFile.parent) - .filter { it.isRegularFile() } - .forEach { with(loggingContext) { it.tryMarkAsExecutable() } } - } - Files.delete(pathToFile) - } - } - - @Suppress("TooGenericExceptionCaught") - private fun Path.extractZipHereSafely() { - try { - extractZipHere() - } catch (e: Exception) { - log.error("Error occurred during extracting of archive $name", e) - } - } - - private fun Execution.downloadAdditionalFilesTo( - targetDirectory: Path - ): Mono = parseAndGetAdditionalFiles() - .toFlux() - .flatMap { fileKey -> - val pathToFile = targetDirectory.resolve(fileKey.name) - (fileKey to this).downloadTo(pathToFile) - .map { unzipIfRequired(pathToFile) } - } - .collectList() - .map { - log.info { "Downloaded all additional files for execution $id to $targetDirectory" } - } - .lazyDefaultIfEmpty { - log.warn { - "Not found any additional files for execution $id" - } - } - - private fun Pair.downloadTo( - pathToFile: Path - ): Mono = this.let { (fileKey, execution) -> - webClientBackend.post() - .uri("/files/{organizationName}/{projectName}/download", execution.project.organization.name, execution.project.name) - .contentType(MediaType.APPLICATION_JSON) - .bodyValue(fileKey) - .accept(MediaType.APPLICATION_OCTET_STREAM) - .retrieve() - .bodyToFlux() - .let { content -> - pathToFile.parent.createDirectories() - content.writeTo(pathToFile) - } - .then( - Mono.fromCallable { - // TODO: need to store information about isExecutable in Execution (FileKey) - with(loggingContext) { pathToFile.tryMarkAsExecutable() } - log.debug { - "Downloaded $fileKey to ${pathToFile.absolutePathString()}" - } - } - ) - .lazyDefaultIfEmpty { - log.warn { - "Not found additional file $fileKey for execution ${execution.id}" - } - } - } - - private fun Execution.downloadTestsTo( - targetDirectory: Path - ): Mono = webClientBackend.post() - .uri( - "/test-suites-sources/download-snapshot-by-execution-id?executionId={executionId}", - requiredId(), - ) - .contentType(MediaType.APPLICATION_JSON) - .accept(MediaType.APPLICATION_OCTET_STREAM) - .retrieve() - .bodyToFlux() - .let { content -> - targetDirectory.createDirectories() - val targetFile = Files.createTempFile(targetDirectory, "archive-", ARCHIVE_EXTENSION) - content.writeTo(targetFile) - } - .map { - it.extractZipHere() - it.deleteExisting() - } - .map { - log.info { "Downloaded all tests for execution $id to $targetDirectory" } - } - .lazyDefaultIfEmpty { - log.warn { - "Not found any tests for execution ${requiredId()}" - } - } - private fun reportExecutionError( execution: Execution, failReason: String, diff --git a/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/docker/DockerAgentRunner.kt b/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/docker/DockerAgentRunner.kt index 10b05fe2c3..6aff8a0f9b 100644 --- a/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/docker/DockerAgentRunner.kt +++ b/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/docker/DockerAgentRunner.kt @@ -55,10 +55,8 @@ class DockerAgentRunner( executionId: Long, configuration: DockerService.RunConfiguration, replicas: Int, - workingDir: String, ): List { - val (baseImageTag, agentRunCmd, pvId) = configuration - require(pvId is DockerPvId) { "${DockerPersistentVolumeService::class.simpleName} can only operate with ${DockerPvId::class.simpleName}" } + require(configuration.pvId is DockerPvId) { "${DockerPersistentVolumeService::class.simpleName} can only operate with ${DockerPvId::class.simpleName}" } logger.debug { "Pulling image ${configuration.imageTag}" } dockerClient.pullImageCmd(configuration.imageTag) @@ -68,7 +66,7 @@ class DockerAgentRunner( return (1..replicas).map { number -> logger.info("Creating a container #$number for execution.id=$executionId") - createContainerFromImage(baseImageTag, pvId, workingDir, agentRunCmd, containerName("$executionId-$number")).also { agentId -> + createContainerFromImage(configuration as DockerService.RunConfiguration, containerName("$executionId-$number")).also { agentId -> logger.info("Created a container id=$agentId for execution.id=$executionId") agentIdsByExecution .getOrPut(executionId) { mutableListOf() } @@ -168,23 +166,23 @@ class DockerAgentRunner( * @throws RuntimeException if an exception not specific to docker has occurred */ @Suppress("UnsafeCallOnNullableType", "TOO_LONG_FUNCTION") - private fun createContainerFromImage(baseImageTag: String, - pvId: DockerPvId, - workingDir: String, - runCmd: List, + private fun createContainerFromImage(configuration: DockerService.RunConfiguration, containerName: String, ): String { + val baseImageTag = configuration.imageTag + val pvId = configuration.pvId + val runCmd = configuration.runCmd val envFileTargetPath = "$SAVE_AGENT_USER_HOME/.env" // createContainerCmd accepts image name, not id, so we retrieve it from tags val createContainerCmdResponse: CreateContainerResponse = dockerClient.createContainerCmd(baseImageTag) - .withWorkingDir(workingDir) + .withWorkingDir(EXECUTION_DIR) // Load environment variables required by save-agent and then run it. // Rely on `runCmd` format: last argument is parameter of the subshell. .withCmd( // this part is like `sh -c` with probably some other flags runCmd.dropLast(1) + ( // last element is an actual command that will be executed in a new shell - "env \$(cat $envFileTargetPath | xargs) sh -c \"${runCmd.last()}\"" + """env $(cat $envFileTargetPath | xargs) sh -c "cp $SAVE_AGENT_USER_HOME/resources/* . && ${runCmd.last()}"""" ) ) .withName(containerName) @@ -193,7 +191,7 @@ class DockerAgentRunner( HostConfig.newHostConfig() .withBinds(Bind( // Apparently, target path needs to be wrapped into [Volume] object in Docker API. - pvId.volumeName, Volume(EXECUTION_DIR) + pvId.volumeName, Volume("$SAVE_AGENT_USER_HOME/resources") )) .withRuntime(settings.runtime) // processes from inside the container will be able to access host's network using this hostname @@ -218,7 +216,10 @@ class DockerAgentRunner( val containerId = createContainerCmdResponse.id val envFile = createTempDirectory("orchestrator").resolve(".env").apply { writeText(""" - AGENT_ID=$containerId""".trimIndent() + AGENT_ID=$containerId + EXECUTION_ID=${configuration.resourcesConfiguration.executionId} + ADDITIONAL_FILES_LIST=${configuration.resourcesConfiguration.additionalFilesString} + """.trimIndent() ) } copyResourcesIntoContainer( diff --git a/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/kubernetes/KubernetesManager.kt b/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/kubernetes/KubernetesManager.kt index 1401bfb742..9cab6a4d8d 100644 --- a/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/kubernetes/KubernetesManager.kt +++ b/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/kubernetes/KubernetesManager.kt @@ -3,7 +3,6 @@ package com.saveourtool.save.orchestrator.kubernetes import com.saveourtool.save.orchestrator.config.ConfigProperties import com.saveourtool.save.orchestrator.runner.AgentRunner import com.saveourtool.save.orchestrator.runner.AgentRunnerException -import com.saveourtool.save.orchestrator.runner.EXECUTION_DIR import com.saveourtool.save.orchestrator.runner.SAVE_AGENT_USER_HOME import com.saveourtool.save.orchestrator.service.DockerService import com.saveourtool.save.orchestrator.service.PersistentVolumeId @@ -40,9 +39,11 @@ class KubernetesManager( override fun create(executionId: Long, configuration: DockerService.RunConfiguration, replicas: Int, - workingDir: String, ): List { - val (baseImageTag, agentRunCmd, pvId) = configuration + val baseImageTag = configuration.imageTag + val agentRunCmd = configuration.runCmd + val pvId = configuration.pvId + val workingDir = configuration.workingDir require(pvId is KubernetesPvId) { "${KubernetesPersistentVolumeService::class.simpleName} can only operate with ${KubernetesPvId::class.simpleName}" } requireNotNull(configProperties.kubernetes) // fixme: pass image name instead of ID from the outside @@ -79,7 +80,7 @@ class KubernetesManager( restartPolicy = "Never" initContainers = initContainersSpec(pvId) containers = listOf( - agentContainerSpec(baseImageTag, agentRunCmd) + agentContainerSpec(baseImageTag, agentRunCmd, workingDir, configuration.resourcesConfiguration) ) volumes = listOf( Volume().apply { @@ -203,12 +204,13 @@ class KubernetesManager( Container().apply { name = "save-vol-copier" image = "alpine:latest" + val targetDir = configProperties.kubernetes.pvcMountPath command = listOf( "sh", "-c", - "if [ -z \"$(ls -A $EXECUTION_DIR)\" ];" + - " then mkdir -p $EXECUTION_DIR && cp -R ${pvId.sourcePath}/* $EXECUTION_DIR" + - " && chown -R 1100:1100 $EXECUTION_DIR && echo Successfully copied;" + - " else echo Copying already in progress && ls -A $EXECUTION_DIR && sleep $waitForCopySeconds;" + + "if [ -z \"$(ls -A $targetDir)\" ];" + + " then mkdir -p $targetDir && cp -R ${pvId.sourcePath}/* $targetDir" + + " && chown -R 1100:1100 $targetDir && echo Successfully copied;" + + " else echo Copying already in progress && ls -A $targetDir && sleep $waitForCopySeconds;" + " fi" ) volumeMounts = listOf( @@ -225,7 +227,13 @@ class KubernetesManager( ) } - private fun agentContainerSpec(imageName: String, agentRunCmd: List) = Container().apply { + @Suppress("TOO_LONG_FUNCTION") + private fun agentContainerSpec( + imageName: String, + agentRunCmd: List, + workingDir: String, + resourcesConfiguration: DockerService.RunConfiguration.ResourcesConfiguration, + ) = Container().apply { name = "save-agent-pod" image = imageName imagePullPolicy = "IfNotPresent" // so that local images could be used @@ -237,17 +245,26 @@ class KubernetesManager( fieldPath = "metadata.name" } } - } + }, + EnvVar().apply { + name = "EXECUTION_ID" + value = "${resourcesConfiguration.executionId}" + }, + EnvVar().apply { + name = "ADDITIONAL_FILES_LIST" + value = resourcesConfiguration.additionalFilesString + }, ) + val resourcesPath = requireNotNull(configProperties.kubernetes).pvcMountPath this.command = agentRunCmd.dropLast(1) - this.args = listOf(agentRunCmd.last()) + this.args = listOf("cp $resourcesPath/* . && ${agentRunCmd.last()}") this.workingDir = workingDir volumeMounts = listOf( VolumeMount().apply { name = "save-execution-pvc" - mountPath = requireNotNull(configProperties.kubernetes).pvcMountPath + mountPath = resourcesPath } ) } diff --git a/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/runner/AgentRunner.kt b/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/runner/AgentRunner.kt index daf39bdd08..efe2ee99d9 100644 --- a/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/runner/AgentRunner.kt +++ b/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/runner/AgentRunner.kt @@ -17,14 +17,12 @@ interface AgentRunner { * @param executionId and ID of execution for which agents will run tests * @param configuration [DockerService.RunConfiguration] for the created containers * @param replicas number of agents acting in parallel - * @param workingDir execution directory inside the container * @return unique identifier of created instances that can be used to manipulate them later */ fun create( executionId: Long, configuration: DockerService.RunConfiguration, replicas: Int, - workingDir: String, ): List /** diff --git a/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/service/DockerService.kt b/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/service/DockerService.kt index 4a0cee82fc..60e0d6b650 100644 --- a/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/service/DockerService.kt +++ b/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/service/DockerService.kt @@ -31,6 +31,7 @@ import org.springframework.web.reactive.function.client.bodyToMono import reactor.core.publisher.Flux import java.nio.file.Path +import java.nio.file.Paths import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong @@ -51,6 +52,15 @@ class DockerService( private val persistentVolumeService: PersistentVolumeService, private val agentService: AgentService, ) { + // Somehow simple path.createDirectories() doesn't work on macOS, probably due to Apple File System features + private val tmpDir = Paths.get(configProperties.testResources.tmpPath).let { + if (it.exists()) { + it + } else { + it.createDirectories() + } + } + @Suppress("NonBooleanPropertyPrefixedWithIs") private val isAgentStoppingInProgress = AtomicBoolean(false) @@ -61,13 +71,16 @@ class DockerService( /** * Function that builds a base image with test resources * - * @param resourcesForExecution location to resources are required for [execution] * @param execution [Execution] from which this workflow is started * @return image ID and execution command for the agent * @throws DockerException if interaction with docker daemon is not successful */ @Suppress("UnsafeCallOnNullableType") - fun prepareConfiguration(resourcesForExecution: Path, execution: Execution): RunConfiguration { + fun prepareConfiguration(execution: Execution): RunConfiguration { + val resourcesForExecution = createTempDirectory( + directory = tmpDir, + prefix = "save-execution-${execution.id}" + ) log.info("Preparing volume for execution.id=${execution.id}") val buildResult = prepareImageAndVolumeForExecution(resourcesForExecution, execution) // todo (k8s): need to also push it so that other nodes will have access to it @@ -89,7 +102,6 @@ class DockerService( executionId = executionId, configuration = configuration, replicas = configProperties.agentsCount, - workingDir = EXECUTION_DIR, ) /** @@ -239,6 +251,10 @@ class DockerService( runCmd = listOf("sh", "-c", "chmod +x $SAVE_AGENT_EXECUTABLE_NAME && ./$SAVE_AGENT_EXECUTABLE_NAME"), pvId = pvId, resourcesPath = resourcesForExecution, + resourcesConfiguration = RunConfiguration.ResourcesConfiguration( + executionId = execution.requiredId(), + additionalFilesString = execution.additionalFiles, + ), ) } @@ -263,13 +279,26 @@ class DockerService( * Usually looks like `sh -c "rest of the command"`. * @property pvId ID of a persistent volume that should be attached to a container * @property resourcesPath FixMe: needed only until agents download test and additional files by themselves + * @property workingDir + * @property resourcesConfiguration */ data class RunConfiguration( val imageTag: String, val runCmd: List, val pvId: I, + val workingDir: String = EXECUTION_DIR, val resourcesPath: Path, - ) + val resourcesConfiguration: ResourcesConfiguration, + ) { + /** + * @property executionId + * @property additionalFilesString + */ + data class ResourcesConfiguration( + val executionId: Long, + val additionalFilesString: String, + ) + } companion object { private val log = LoggerFactory.getLogger(DockerService::class.java) diff --git a/save-orchestrator/src/main/resources/application.properties b/save-orchestrator/src/main/resources/application.properties index f21f326c06..6333e90ec5 100644 --- a/save-orchestrator/src/main/resources/application.properties +++ b/save-orchestrator/src/main/resources/application.properties @@ -16,5 +16,5 @@ orchestrator.agents-count=3 orchestrator.agents-heart-beat-timeout-millis=60000 orchestrator.heart-beat-inspector-interval=10 orchestrator.agent-settings.agent-id-env=AGENT_ID -orchestrator.agentsStartTimeoutMillis=60000 -orchestrator.agentsStartCheckIntervalMillis=10000 +orchestrator.agents-start-timeout-millis=60000 +orchestrator.agents-start-check-interval-millis=10000 diff --git a/save-orchestrator/src/test/kotlin/com/saveourtool/save/orchestrator/controller/agents/AgentsControllerTest.kt b/save-orchestrator/src/test/kotlin/com/saveourtool/save/orchestrator/controller/agents/AgentsControllerTest.kt index ab1f9c3d08..d9dad477e4 100644 --- a/save-orchestrator/src/test/kotlin/com/saveourtool/save/orchestrator/controller/agents/AgentsControllerTest.kt +++ b/save-orchestrator/src/test/kotlin/com/saveourtool/save/orchestrator/controller/agents/AgentsControllerTest.kt @@ -9,6 +9,7 @@ import com.saveourtool.save.orchestrator.config.ConfigProperties import com.saveourtool.save.orchestrator.controller.AgentsController import com.saveourtool.save.orchestrator.docker.DockerPvId import com.saveourtool.save.orchestrator.runner.AgentRunner +import com.saveourtool.save.orchestrator.runner.EXECUTION_DIR import com.saveourtool.save.orchestrator.service.AgentService import com.saveourtool.save.orchestrator.service.DockerService import com.saveourtool.save.testutils.checkQueues @@ -71,7 +72,7 @@ class AgentsControllerTest { } @Test - @Suppress("TOO_LONG_FUNCTION") + @Suppress("TOO_LONG_FUNCTION", "LongMethod", "UnsafeCallOnNullableType") fun `should build image, query backend and start containers`() { val project = Project.stub(null) val execution = Execution.stub(project).apply { @@ -92,12 +93,17 @@ class AgentsControllerTest { .addHeader("Content-Type", "application/octet-stream") .setBody(Buffer().readFrom(tmpArchive.inputStream())) ) - whenever(dockerService.prepareConfiguration(any(), any())).thenReturn( + whenever(dockerService.prepareConfiguration(any())).thenReturn( DockerService.RunConfiguration( - "test-image-id", - listOf("sh", "-c", "test-exec-cmd"), - DockerPvId("test-pv-id"), - Path.of("test-resources-path"), + imageTag = "test-image-id", + runCmd = listOf("sh", "-c", "test-exec-cmd"), + pvId = DockerPvId("test-pv-id"), + workingDir = EXECUTION_DIR, + resourcesPath = Path.of("test-resources-path"), + resourcesConfiguration = DockerService.RunConfiguration.ResourcesConfiguration( + executionId = execution.id!!, + additionalFilesString = "", + ) ) ) whenever(dockerService.createContainers(any(), any())) @@ -122,7 +128,7 @@ class AgentsControllerTest { .expectStatus() .isAccepted Thread.sleep(2_500) // wait for background task to complete on mocks - verify(dockerService).prepareConfiguration(any(), any()) + verify(dockerService).prepareConfiguration(any()) verify(dockerService).createContainers(any(), any()) verify(dockerService).startContainersAndUpdateExecution(any(), anyList()) diff --git a/save-orchestrator/src/test/kotlin/com/saveourtool/save/orchestrator/docker/DockerContainerManagerTest.kt b/save-orchestrator/src/test/kotlin/com/saveourtool/save/orchestrator/docker/DockerContainerManagerTest.kt index 27bd85a0ef..c270d3ff80 100644 --- a/save-orchestrator/src/test/kotlin/com/saveourtool/save/orchestrator/docker/DockerContainerManagerTest.kt +++ b/save-orchestrator/src/test/kotlin/com/saveourtool/save/orchestrator/docker/DockerContainerManagerTest.kt @@ -61,10 +61,14 @@ class DockerContainerManagerTest { baseImage.repoTags.first(), listOf("bash", "-c", "./script.sh"), DockerPvId("test-volume"), - Path.of("test-resources-path"), + workingDir = "/", + resourcesPath = Path.of("test-resources-path"), + resourcesConfiguration = DockerService.RunConfiguration.ResourcesConfiguration( + executionId = 99L, + additionalFilesString = "", + ) ), replicas = 1, - workingDir = "/", ).single() val inspectContainerResponse = dockerClient .inspectContainerCmd(testContainerId) @@ -72,7 +76,7 @@ class DockerContainerManagerTest { Assertions.assertEquals("bash", inspectContainerResponse.path) Assertions.assertArrayEquals( - arrayOf("-c", "env \$(cat /home/save-agent/.env | xargs) sh -c \"./script.sh\""), + arrayOf("-c", "env \$(cat /home/save-agent/.env | xargs) sh -c \"cp /home/save-agent/resources/* . && ./script.sh\""), inspectContainerResponse.args ) // leading extra slash: https://github.com/moby/moby/issues/6705 diff --git a/save-orchestrator/src/test/kotlin/com/saveourtool/save/orchestrator/service/DockerServiceTest.kt b/save-orchestrator/src/test/kotlin/com/saveourtool/save/orchestrator/service/DockerServiceTest.kt index a7c851492d..fd47dfea58 100644 --- a/save-orchestrator/src/test/kotlin/com/saveourtool/save/orchestrator/service/DockerServiceTest.kt +++ b/save-orchestrator/src/test/kotlin/com/saveourtool/save/orchestrator/service/DockerServiceTest.kt @@ -6,7 +6,6 @@ import com.saveourtool.save.orchestrator.config.Beans import com.saveourtool.save.orchestrator.config.ConfigProperties import com.saveourtool.save.orchestrator.docker.DockerAgentRunner import com.saveourtool.save.orchestrator.docker.DockerPersistentVolumeService -import com.saveourtool.save.orchestrator.runner.TEST_SUITES_DIR_NAME import com.saveourtool.save.orchestrator.testutils.TestConfiguration import com.saveourtool.save.testutils.checkQueues import com.saveourtool.save.testutils.cleanup @@ -58,7 +57,6 @@ class DockerServiceTest { @Autowired private lateinit var dockerClient: DockerClient @Autowired private lateinit var dockerService: DockerService @Autowired private lateinit var configProperties: ConfigProperties - private lateinit var testImageId: String private lateinit var testContainerId: String @BeforeEach @@ -86,13 +84,7 @@ class DockerServiceTest { .setHeader("Content-Type", "application/json") .setBody(Json.encodeToString(listOf("Test1", "Test2"))) ) - val tmpDir = Paths.get(configProperties.testResources.tmpPath).createDirectories() - val resourcesForExecution = createTempDirectory( - directory = tmpDir, - prefix = "save-execution-${testExecution.requiredId()}" - ) - resourcesForExecution.resolve(TEST_SUITES_DIR_NAME).createDirectory() - val configuration = dockerService.prepareConfiguration(resourcesForExecution, testExecution) + val configuration = dockerService.prepareConfiguration(testExecution) testContainerId = dockerService.createContainers( testExecution.id!!, configuration @@ -111,7 +103,6 @@ class DockerServiceTest { // assertions Thread.sleep(2_500) // waiting for container to start val inspectContainerResponse = dockerClient.inspectContainerCmd(testContainerId).exec() - testImageId = inspectContainerResponse.imageId Assertions.assertTrue(inspectContainerResponse.state.running!!) { dockerClient.logContainerCmd(testContainerId) .withStdOut(true) @@ -137,9 +128,6 @@ class DockerServiceTest { if (::testContainerId.isInitialized) { dockerClient.removeContainerCmd(testContainerId).exec() } - if (::testImageId.isInitialized) { - dockerClient.removeImageCmd(testImageId).exec() - } mockServer.checkQueues() mockServer.cleanup() } diff --git a/save-orchestrator/src/test/resources/application.properties b/save-orchestrator/src/test/resources/application.properties index 93f82bed4c..7279428b2e 100644 --- a/save-orchestrator/src/test/resources/application.properties +++ b/save-orchestrator/src/test/resources/application.properties @@ -1,23 +1,23 @@ -orchestrator.backendUrl=http://backend:5800/internal -orchestrator.executionLogs=${user.home}/.save-cloud/test/executionLogs/ +orchestrator.backend-url=http://backend:5800/internal +orchestrator.execution-logs=${user.home}/.save-cloud/test/executionLogs/ orchestrator.test-resources.tmp-path=${user.home}/.save-cloud/test/tmp -orchestrator.shutdown.checksIntervalMillis=100 -orchestrator.shutdown.gracefulTimeoutSeconds=60 -orchestrator.shutdown.gracefulNumChecks=10 +orchestrator.shutdown.checks-interval-millis=100 +orchestrator.shutdown.graceful-timeout-seconds=60 +orchestrator.shutdown.graceful-num-checks=10 server.port = 5100 management.endpoints.web.exposure.include=health,info,prometheus # use IP from WSL on windows (`ip a | grep eth0`), because runsc can't be installed on windows #orchestrator.docker.host=tcp://172.25.71.25:2375 orchestrator.docker.host=unix:///var/run/docker.sock -orchestrator.docker.loggingDriver=json +orchestrator.docker.logging-driver=json orchestrator.docker.runtime=runsc orchestrator.docker.test-resources-volume-type=bind -orchestrator.agentsCount=1 -orchestrator.adjustResourceOwner=false -orchestrator.agentsHeartBeatTimeoutMillis=5000 -orchestrator.heartBeatInspectorInterval=3 -orchestrator.agentsStartTimeoutMillis=60000 -orchestrator.agentsStartCheckIntervalMillis=10000 +orchestrator.agents-count=1 +orchestrator.adjust-resource-owner=false +orchestrator.agents-heart-beat-timeout-millis=5000 +orchestrator.heart-beat-inspector-interval=3 +orchestrator.agents-start-timeout-millis=60000 +orchestrator.agents-start-check-interval-millis=10000 logging.level.com.github.dockerjava=DEBUG logging.level.com.saveourtool.save.testutils.LoggingQueueDispatcher=INFO logging.level.com.saveourtool.save.orchestrator=DEBUG From 61ed28dd1ef1e6e09ad27061cf79069f9f108b77 Mon Sep 17 00:00:00 2001 From: Dmitry Morozovsky <27895587+icemachined@users.noreply.github.com> Date: Mon, 22 Aug 2022 13:20:48 +0300 Subject: [PATCH 3/7] kafka dev cluster configuration and controller #1054 (#1055) What's done: * doker-compose kafka and zookeper services * Created startKafka gradle task * Test Kafka Configuration to send messages to configured request topic * Listen to configured result topic * Everything works only if both dev and kafka profiles are set --- .../buildutils/DockerStackConfiguration.kt | 73 ++++++-- gradle/libs.versions.toml | 6 + .../com/saveourtool/save/kafka/KafkaMsg.kt | 11 ++ .../save/kafka/TestExecutionTaskDto.kt | 14 ++ save-orchestrator/build.gradle.kts | 1 + .../orchestrator/config/KafkaConfiguration.kt | 164 ++++++++++++++++++ .../controller/KafkaController.kt | 29 ++++ .../orchestrator/kafka/AgentKafkaListener.kt | 47 +++++ .../save/orchestrator/kafka/KafkaSender.kt | 94 ++++++++++ .../resources/application-kafka.properties | 5 + .../docker/DockerContainerManagerTest.kt | 2 +- 11 files changed, 435 insertions(+), 11 deletions(-) create mode 100644 save-cloud-common/src/commonMain/kotlin/com/saveourtool/save/kafka/KafkaMsg.kt create mode 100644 save-cloud-common/src/commonMain/kotlin/com/saveourtool/save/kafka/TestExecutionTaskDto.kt create mode 100644 save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/config/KafkaConfiguration.kt create mode 100644 save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/controller/KafkaController.kt create mode 100644 save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/kafka/AgentKafkaListener.kt create mode 100644 save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/kafka/KafkaSender.kt create mode 100644 save-orchestrator/src/main/resources/application-kafka.properties diff --git a/buildSrc/src/main/kotlin/com/saveourtool/save/buildutils/DockerStackConfiguration.kt b/buildSrc/src/main/kotlin/com/saveourtool/save/buildutils/DockerStackConfiguration.kt index da9084bd58..ecd5dfcb2e 100644 --- a/buildSrc/src/main/kotlin/com/saveourtool/save/buildutils/DockerStackConfiguration.kt +++ b/buildSrc/src/main/kotlin/com/saveourtool/save/buildutils/DockerStackConfiguration.kt @@ -17,6 +17,7 @@ import java.nio.file.Files import java.nio.file.Paths const val MYSQL_STARTUP_DELAY_MILLIS = 30_000L +const val KAFKA_STARTUP_DELAY_MILLIS = 5_000L /** * @param profile deployment profile, used, for example, to start SQL database in dev profile only @@ -53,7 +54,28 @@ fun Project.createStackDeployTask(profile: String) { | environment: | - "MYSQL_ROOT_PASSWORD=123" | - "MYSQL_DATABASE=save_cloud" - """.trimMargin() + | zookeeper: + | image: confluentinc/cp-zookeeper:latest + | environment: + | ZOOKEEPER_CLIENT_PORT: 2181 + | ZOOKEEPER_TICK_TIME: 2000 + | ports: + | - 22181:2181 + | + | kafka: + | image: confluentinc/cp-kafka:latest + | depends_on: + | - zookeeper + | ports: + | - 29092:29092 + | environment: + | KAFKA_BROKER_ID: 1 + | KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 + | KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092,PLAINTEXT_HOST://localhost:29092 + | KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT + | KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT + | KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + """.trimMargin() } else if (profile == "dev" && it.trim().startsWith("logging:")) { "" } else { @@ -106,9 +128,11 @@ fun Project.createStackDeployTask(profile: String) { Files.createDirectories(configsDir.resolve("orchestrator")) Files.createDirectories(configsDir.resolve("preprocessor")) } - description = "Deploy to docker swarm. If swarm contains more than one node, some registry for built images is required." + description = + "Deploy to docker swarm. If swarm contains more than one node, some registry for built images is required." // this command puts env variables into compose file - val composeCmd = "docker-compose -f ${rootProject.buildDir}/docker-compose.yaml --env-file ${rootProject.buildDir}/.env config" + val composeCmd = + "docker-compose -f ${rootProject.buildDir}/docker-compose.yaml --env-file ${rootProject.buildDir}/.env config" val stackCmd = "docker stack deploy --compose-file -" + if (useOverride && composeOverride.exists()) { " --compose-file ${composeOverride.canonicalPath}" @@ -125,7 +149,8 @@ fun Project.createStackDeployTask(profile: String) { } tasks.register("stopDockerStack") { - description = "Completely stop all services in docker swarm. NOT NEEDED FOR REDEPLOYING! Use only to explicitly stop everything." + description = + "Completely stop all services in docker swarm. NOT NEEDED FOR REDEPLOYING! Use only to explicitly stop everything." commandLine("docker", "stack", "rm", "save") } @@ -138,10 +163,8 @@ fun Project.createStackDeployTask(profile: String) { commandLine("docker-compose", "--file", "$buildDir/docker-compose.yaml", "up", "-d", "mysql") errorOutput = ByteArrayOutputStream() doLast { - if (!errorOutput.toString().contains(" is up-to-date")) { - logger.lifecycle("Waiting $MYSQL_STARTUP_DELAY_MILLIS millis for mysql to start") - Thread.sleep(MYSQL_STARTUP_DELAY_MILLIS) // wait for mysql to start, can be manually increased when needed - } + logger.lifecycle("Waiting $MYSQL_STARTUP_DELAY_MILLIS millis for mysql to start") + Thread.sleep(MYSQL_STARTUP_DELAY_MILLIS) // wait for mysql to start, can be manually increased when needed } } tasks.named("liquibaseUpdate") { @@ -152,16 +175,45 @@ fun Project.createStackDeployTask(profile: String) { dependsOn("startMysqlDbService") } + tasks.register("startKafka") { + dependsOn("generateComposeFile") + doFirst { + logger.lifecycle("Running the following command: [docker-compose --file $buildDir/docker-compose.yaml up -d kafka]") + } + errorOutput = ByteArrayOutputStream() + commandLine("docker-compose", "--file", "$buildDir/docker-compose.yaml", "up", "-d", "kafka") + doLast { + logger.lifecycle("Waiting $KAFKA_STARTUP_DELAY_MILLIS millis for kafka to start") + Thread.sleep(KAFKA_STARTUP_DELAY_MILLIS) // wait for kafka to start, can be manually increased when needed + } + } + tasks.register("restartMysqlDb") { dependsOn("generateComposeFile") commandLine("docker-compose", "--file", "$buildDir/docker-compose.yaml", "rm", "--force", "mysql") finalizedBy("startMysqlDb") } + tasks.register("restartKafka") { + dependsOn("generateComposeFile") + commandLine("docker-compose", "--file", "$buildDir/docker-compose.yaml", "rm", "--force", "kafka") + commandLine("docker-compose", "--file", "$buildDir/docker-compose.yaml", "rm", "--force", "zookeeper") + finalizedBy("startKafka") + } + tasks.register("deployLocal") { dependsOn(subprojects.flatMap { it.tasks.withType() }) dependsOn("startMysqlDb") - commandLine("docker-compose", "--file", "$buildDir/docker-compose.yaml", "up", "-d", "orchestrator", "backend", "preprocessor") + commandLine( + "docker-compose", + "--file", + "$buildDir/docker-compose.yaml", + "up", + "-d", + "orchestrator", + "backend", + "preprocessor" + ) } val componentName = findProperty("save.component") as String? @@ -172,7 +224,8 @@ fun Project.createStackDeployTask(profile: String) { "and it should be a name of one of gradle subprojects. If component name is `save-backend`, then `save-frontend` will be built too" + " and bundled into save-backend image." require(componentName in allprojects.map { it.name }) { "Component name should be one of gradle subproject names, but was [$componentName]" } - val buildTask: TaskProvider = project(componentName).tasks.named("bootBuildImage") + val buildTask: TaskProvider = + project(componentName).tasks.named("bootBuildImage") dependsOn(buildTask) val serviceName = when (componentName) { "save-backend", "save-orchestrator", "save-preprocessor" -> "save_${componentName.substringAfter("save-")}" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 77d4cb5e10..2f1edf103e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,6 +12,7 @@ spring-boot = "2.7.2" spring-security = "5.7.2" spring-cloud = "3.1.3" spring-cloud-kubernetes = "2.1.3" +kafka-client = "3.2.0" junit = "5.9.0" diktat = "1.2.3" detekt = "1.21.0" @@ -92,9 +93,14 @@ spring-cloud-starter-gateway = { module = "org.springframework.cloud:spring-clou spring-cloud-starter-kubernetes-client-config = { module = "org.springframework.cloud:spring-cloud-starter-kubernetes-client-config", version.ref = "spring-cloud-kubernetes" } spring-boot-starter-oauth2-client = { module = "org.springframework.boot:spring-boot-starter-oauth2-client" } spring-context-indexer = { module = "org.springframework:spring-context-indexer", version.ref = "spring" } +spring-kafka = {module = "org.springframework.kafka:spring-kafka"} +spring-kafka-test = {module = "org.springframework.kafka:spring-kafka-test"} spring-web = { module = "org.springframework:spring-web", version.ref = "spring" } jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin" } +kafka-clients = {module = "org.apache.kafka:kafka-clients", version.ref = "kafka-client"} +kafka212 = {module = "org.apache.kafka:kafka_2.12", version.ref = "kafka-client"} + springdoc-openapi-ui = { module = "org.springdoc:springdoc-openapi-ui", version.ref = "springdoc" } springdoc-openapi-webflux-ui = { module = "org.springdoc:springdoc-openapi-webflux-ui", version.ref = "springdoc" } springdoc-openapi-security = { module = "org.springdoc:springdoc-openapi-security", version.ref = "springdoc" } diff --git a/save-cloud-common/src/commonMain/kotlin/com/saveourtool/save/kafka/KafkaMsg.kt b/save-cloud-common/src/commonMain/kotlin/com/saveourtool/save/kafka/KafkaMsg.kt new file mode 100644 index 0000000000..3c8ecd981a --- /dev/null +++ b/save-cloud-common/src/commonMain/kotlin/com/saveourtool/save/kafka/KafkaMsg.kt @@ -0,0 +1,11 @@ +package com.saveourtool.save.kafka + +/** + * kafka message + */ +interface KafkaMsg { + /** + * @property messageId + */ + val messageId: String? +} diff --git a/save-cloud-common/src/commonMain/kotlin/com/saveourtool/save/kafka/TestExecutionTaskDto.kt b/save-cloud-common/src/commonMain/kotlin/com/saveourtool/save/kafka/TestExecutionTaskDto.kt new file mode 100644 index 0000000000..03fe23b5bf --- /dev/null +++ b/save-cloud-common/src/commonMain/kotlin/com/saveourtool/save/kafka/TestExecutionTaskDto.kt @@ -0,0 +1,14 @@ +package com.saveourtool.save.kafka + +import com.saveourtool.save.test.TestDto + +/** + * @property tests + * @property cliArgs + * @property messageId + */ +data class TestExecutionTaskDto( + val tests: List, + val cliArgs: String, + override val messageId: String? = null +) : KafkaMsg diff --git a/save-orchestrator/build.gradle.kts b/save-orchestrator/build.gradle.kts index f7c8e14e01..41fb8fbfbf 100644 --- a/save-orchestrator/build.gradle.kts +++ b/save-orchestrator/build.gradle.kts @@ -85,6 +85,7 @@ dependencies { implementation(libs.zip4j) implementation(libs.spring.cloud.starter.kubernetes.client.config) implementation(libs.fabric8.kubernetes.client) + implementation(libs.spring.kafka) testImplementation(libs.fabric8.kubernetes.server.mock) } diff --git a/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/config/KafkaConfiguration.kt b/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/config/KafkaConfiguration.kt new file mode 100644 index 0000000000..9a858dd6e9 --- /dev/null +++ b/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/config/KafkaConfiguration.kt @@ -0,0 +1,164 @@ +package com.saveourtool.save.orchestrator.config + +import com.saveourtool.save.kafka.TestExecutionTaskDto +import com.saveourtool.save.orchestrator.kafka.AgentKafkaListener +import com.saveourtool.save.orchestrator.kafka.KafkaSender +import org.apache.kafka.clients.admin.NewTopic +import org.apache.kafka.clients.consumer.ConsumerConfig +import org.apache.kafka.clients.consumer.CooperativeStickyAssignor +import org.apache.kafka.clients.producer.ProducerConfig +import org.apache.kafka.clients.producer.internals.DefaultPartitioner +import org.apache.kafka.common.serialization.StringDeserializer +import org.apache.kafka.common.serialization.StringSerializer +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.boot.autoconfigure.kafka.KafkaProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory +import org.springframework.kafka.core.* +import org.springframework.kafka.listener.DeadLetterPublishingRecoverer +import org.springframework.kafka.listener.SeekToCurrentErrorHandler +import org.springframework.kafka.support.serializer.ErrorHandlingDeserializer +import org.springframework.kafka.support.serializer.JsonDeserializer +import org.springframework.kafka.support.serializer.JsonSerializer +import java.util.* + +/** + * Kafka producer and consumer configuration + * + * @property kafkaProperties + */ +@Configuration +@Profile("dev & kafka") +internal class KafkaConfiguration( + private val kafkaProperties: KafkaProperties) { + /** + * @property requestTopicName + */ + @Value("\${kafka.test.execution.request.topic.name}") + lateinit var requestTopicName: String + + /** + * @property requestTopicNameDlt + */ + @Value("\${kafka.test.execution.request.topic.name}.DLT") + lateinit var requestTopicNameDlt: String + + /** + * @property responseTopicName + */ + @Value("\${kafka.test.execution.response.topic.name}") + lateinit var responseTopicName: String + + /** + * @property consumerGroup + */ + @Value("\${spring.kafka.consumer.group-id}") + lateinit var consumerGroup: String + + /** + * @return kafka producer properties + */ + @Bean + fun producerConfig(): Map { + val producerProps = HashMap(kafkaProperties.buildProducerProperties()) + producerProps[ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG] = StringSerializer::class.java as Object + producerProps[ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG] = JsonSerializer::class.java as Object + producerProps[ProducerConfig.PARTITIONER_CLASS_CONFIG] = DefaultPartitioner::class.java as Object + return producerProps + } + + /** + * @return kafka producer factory + */ + @Bean + fun producerFactory(): ProducerFactory = DefaultKafkaProducerFactory(producerConfig()) + + /** + * @return kafka template + */ + @Bean + fun kafkaTemplate(): KafkaTemplate = KafkaTemplate(producerFactory()) + + /** + * @return kafka consumer properties + */ + @Bean + fun consumerConfig(): Map { + val consumerProps = HashMap(kafkaProperties.buildConsumerProperties()) + consumerProps[ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG] = "true" + consumerProps[ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG] = "10" + consumerProps[ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG] = "60000" + consumerProps[ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG] = ErrorHandlingDeserializer::class.java + consumerProps[ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG] = ErrorHandlingDeserializer::class.java + consumerProps[ErrorHandlingDeserializer.KEY_DESERIALIZER_CLASS] = StringDeserializer::class.java + consumerProps[ErrorHandlingDeserializer.VALUE_DESERIALIZER_CLASS] = JsonDeserializer::class.java + consumerProps[ConsumerConfig.PARTITION_ASSIGNMENT_STRATEGY_CONFIG] = listOf(CooperativeStickyAssignor::class.java) + consumerProps[JsonDeserializer.TRUSTED_PACKAGES] = "*" + return consumerProps + } + + /** + * @return consumer factory + */ + @Bean + fun consumerFactory(): ConsumerFactory = DefaultKafkaConsumerFactory(consumerConfig()) + + /** + * @param template + * @return kafka listener container factory + */ + @Suppress("GENERIC_VARIABLE_WRONG_DECLARATION") + @Bean + fun kafkaListenerContainerFactory(template: KafkaTemplate): ConcurrentKafkaListenerContainerFactory { + val factory = ConcurrentKafkaListenerContainerFactory() + factory.setConsumerFactory(consumerFactory()) + factory.setConcurrency(1) + factory.getContainerProperties().setPollTimeout(LISTENER_POLL_TIMEOUT) + factory.setErrorHandler( + SeekToCurrentErrorHandler( + DeadLetterPublishingRecoverer(template) + ) + ) + return factory + } + + /** + * @return kafka agent tasks topic bean + */ + @Bean + fun saveAgentTasks(): NewTopic { + log.info("Create topic: $requestTopicName.") + return NewTopic(requestTopicName, Optional.empty(), Optional.empty()) + } + + /** + * @param template + * @return test execution sender + */ + @Bean + fun testExecutionSender(template: KafkaTemplate): KafkaSender { + log.info("Create sender for {} topic.", requestTopicName) + return KafkaSender(template as KafkaTemplate, requestTopicName) + } + + /** + * @return agentKafkaListener + */ + @Bean + fun agentKafkaListener(): AgentKafkaListener { + log.info("Create listener for {} topic.", responseTopicName) + return AgentKafkaListener(requestTopicName, consumerGroup) + } + + companion object { + private val log = LoggerFactory.getLogger(KafkaConfiguration::class.java) + + /** + * @property listenerPollTimeout + */ + private const val LISTENER_POLL_TIMEOUT = 500L + } +} diff --git a/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/controller/KafkaController.kt b/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/controller/KafkaController.kt new file mode 100644 index 0000000000..6bf735e55b --- /dev/null +++ b/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/controller/KafkaController.kt @@ -0,0 +1,29 @@ +package com.saveourtool.save.orchestrator.controller + +import com.saveourtool.save.kafka.TestExecutionTaskDto +import com.saveourtool.save.orchestrator.kafka.KafkaSender +import com.saveourtool.save.v1 +import org.springframework.context.annotation.Profile +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +/** + * Controller for working with contests. + */ +@Profile("dev & kafka") +@RestController +@RequestMapping(path = ["/api/$v1/kafka"]) +internal class KafkaController( + private val testExecutionSender: KafkaSender +) { + /** + * @param task + * @return Organization + */ + @PostMapping("/sendTestExecutionTask") + fun sendTestExecutionTask(@RequestBody task: TestExecutionTaskDto) { + testExecutionSender.sendMessage(task) + } +} diff --git a/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/kafka/AgentKafkaListener.kt b/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/kafka/AgentKafkaListener.kt new file mode 100644 index 0000000000..831ac0c52a --- /dev/null +++ b/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/kafka/AgentKafkaListener.kt @@ -0,0 +1,47 @@ +package com.saveourtool.save.orchestrator.kafka + +import com.saveourtool.save.kafka.TestExecutionTaskDto +import org.slf4j.LoggerFactory +import org.springframework.kafka.annotation.KafkaHandler +import org.springframework.kafka.annotation.KafkaListener +import org.springframework.kafka.support.KafkaHeaders +import org.springframework.messaging.handler.annotation.Header +import org.springframework.messaging.handler.annotation.Payload + +/** + * @property topic + * @property groupId + */ +@KafkaListener(id = "#{__listener.topic}.listener", topics = ["#{__listener.topic}"], groupId = "#{__listener.groupId}") +class AgentKafkaListener( + val topic: String, + val groupId: String +) { + /** + * @param data + * @param messageId + * @param partition + * @param topic + * @param ts + */ + @KafkaHandler + fun listen( + @Payload data: TestExecutionTaskDto?, + @Header(KafkaHeaders.RECEIVED_MESSAGE_KEY) messageId: String?, + @Header(KafkaHeaders.RECEIVED_PARTITION_ID) partition: Int, + @Header(KafkaHeaders.RECEIVED_TOPIC) topic: String?, + @Header(KafkaHeaders.RECEIVED_TIMESTAMP) ts: Long + ) { + log.info( + "Received request. messageId: {} , partition: {} , topic: {}, ts: {}, payload: {}", + messageId, + partition, + topic, + ts, + data + ) + } + companion object { + private val log = LoggerFactory.getLogger(AgentKafkaListener::class.java) + } +} diff --git a/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/kafka/KafkaSender.kt b/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/kafka/KafkaSender.kt new file mode 100644 index 0000000000..6cd955e8df --- /dev/null +++ b/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/kafka/KafkaSender.kt @@ -0,0 +1,94 @@ +package com.saveourtool.save.orchestrator.kafka + +import com.saveourtool.save.kafka.KafkaMsg +import org.apache.kafka.clients.producer.ProducerRecord +import org.apache.kafka.common.header.internals.RecordHeader +import org.slf4j.LoggerFactory +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.kafka.requestreply.CorrelationKey +import org.springframework.kafka.support.KafkaHeaders +import org.springframework.kafka.support.SendResult +import org.springframework.util.concurrent.ListenableFuture +import org.springframework.util.concurrent.ListenableFutureCallback +import java.util.concurrent.atomic.AtomicLong + +/** + * Kafka sender + */ +class KafkaSender( + private val template: KafkaTemplate, + private val topic: String +) { + /** + * @param msg + * @return send result future + */ + @Suppress("TYPE_ALIAS", "GENERIC_VARIABLE_WRONG_DECLARATION") + fun sendMessage(msg: V): ListenableFuture> { + val messageId = msg.messageId + log.info("Sending a message with id {} to topic: {}", messageId, topic) + log.debug("The message to be sent is: {}", msg) + val kafkaRecord = ProducerRecord(topic, messageId, msg) + return template.send(kafkaRecord) + } + + /** + * @param topic + * @param partition + * @param corrKey + * @param msg + * @return send result future + */ + @Suppress("TYPE_ALIAS") + fun sendMessage( + topic: String?, + partition: Int, + corrKey: CorrelationKey?, + msg: V + ): ListenableFuture> { + val messageId = msg.messageId + log.info( + "Sending a message with id $messageId and correlation key to topic: $topic partition $partition" + ) + log.debug("The message to be sent is: $msg") + val kafkaRecord: ProducerRecord = ProducerRecord(topic, partition, messageId, msg) + corrKey?.let { + kafkaRecord.headers().add(RecordHeader(KafkaHeaders.CORRELATION_ID, corrKey.getCorrelationId())) + } + return template.send(kafkaRecord) + } + + /** + * @param msg + * @param successCount + * @param failedRecords + */ + @Suppress("TYPE_ALIAS", "TooGenericExceptionCaught") + fun sendMessage(msg: V, successCount: AtomicLong, failedRecords: MutableSet) { + val messageId = msg.messageId + try { + val future: ListenableFuture> = sendMessage(msg) + future.addCallback(object : ListenableFutureCallback> { + override fun onSuccess(result: SendResult?) { + log.info( + "Msg id={} was successfully sent with offset: {}", + result?.getProducerRecord()?.key(), result?.getRecordMetadata()?.offset() + ) + successCount.incrementAndGet() + } + + override fun onFailure(ex: Throwable) { + log.error("Unable to send message=[{}].", messageId, ex) + failedRecords.add(messageId) + } + }) + } catch (e: Exception) { + log.error("Unable to send task: {}", messageId, e) + failedRecords.add(messageId) + } + } + + companion object { + private val log = LoggerFactory.getLogger(KafkaSender::class.java) + } +} diff --git a/save-orchestrator/src/main/resources/application-kafka.properties b/save-orchestrator/src/main/resources/application-kafka.properties new file mode 100644 index 0000000000..364dbb76a5 --- /dev/null +++ b/save-orchestrator/src/main/resources/application-kafka.properties @@ -0,0 +1,5 @@ +spring.kafka.bootstrap-servers=localhost:29092 +spring.kafka.consumer.group-id=orchestrator-local-group +spring.kafka.consumer.auto-offset-reset=earliest +kafka.test.execution.request.topic.name=save.test.execution.tasks +kafka.test.execution.response.topic.name=save.test.execution.tasks \ No newline at end of file diff --git a/save-orchestrator/src/test/kotlin/com/saveourtool/save/orchestrator/docker/DockerContainerManagerTest.kt b/save-orchestrator/src/test/kotlin/com/saveourtool/save/orchestrator/docker/DockerContainerManagerTest.kt index c270d3ff80..32ebb88278 100644 --- a/save-orchestrator/src/test/kotlin/com/saveourtool/save/orchestrator/docker/DockerContainerManagerTest.kt +++ b/save-orchestrator/src/test/kotlin/com/saveourtool/save/orchestrator/docker/DockerContainerManagerTest.kt @@ -80,7 +80,7 @@ class DockerContainerManagerTest { inspectContainerResponse.args ) // leading extra slash: https://github.com/moby/moby/issues/6705 - Assertions.assertTrue(inspectContainerResponse.name.startsWith("/save-execution-42-1")) + Assertions.assertTrue(inspectContainerResponse.name.startsWith("/save-execution-42")) val resourceFile = createTempFile().toFile() resourceFile.writeText("Lorem ipsum dolor sit amet") From faa044bd1af7e2fa9040695b27e650f65626adc6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 22 Aug 2022 12:08:12 +0000 Subject: [PATCH 4/7] Update all docker images Docker tags (#1072) --- docker-compose.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index 7839583f48..febae6d1cc 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -84,7 +84,7 @@ services: - "prometheus-job=api-gateway" logging: *loki-logging-jvm prometheus: - image: prom/prometheus:v2.37.0 + image: prom/prometheus:v2.38.0 user: root # to access host's docker socket for service discovery, see https://groups.google.com/g/prometheus-users/c/EuEW0qRzXvg/m/0aqKh_ZABQAJ ports: - "9090:9090" @@ -101,7 +101,7 @@ services: constraints: - "node.role==manager" grafana: - image: grafana/grafana:9.0.7 + image: grafana/grafana:9.1.0 ports: - "9100:3000" volumes: From abd858fb81fb0ef92f95f576fcd6139c909b45db Mon Sep 17 00:00:00 2001 From: Peter Trifanov Date: Mon, 22 Aug 2022 15:12:47 +0300 Subject: [PATCH 5/7] Agent: Finish migration to Ktor 2 (#1068) * Switch from deprecated `JsonPlugin` to `ContentNegotiation`, update dependencies accordingly * Use ktor's `RequestRetry` instead of custon * Use ktor's request/response logging; enable body and headers logging in debug mode Closes #822 --- gradle/libs.versions.toml | 2 + save-agent/build.gradle.kts | 4 +- .../kotlin/com/saveourtool/save/agent/Main.kt | 43 ++++++++++++----- .../com/saveourtool/save/agent/SaveAgent.kt | 6 +-- .../saveourtool/save/agent/utils/HttpUtils.kt | 47 +++---------------- .../saveourtool/save/agent/utils/Logging.kt | 7 +++ .../saveourtool/save/agent/SaveAgentTest.kt | 11 ++--- .../orchestrator/service/DockerService.kt | 1 - 8 files changed, 59 insertions(+), 62 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2f1edf103e..450bacd93c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -113,6 +113,8 @@ ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-curl = { module = "io.ktor:ktor-client-curl", version.ref = "ktor" } ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } ktor-client-serialization = { module = "io.ktor:ktor-client-serialization", version.ref = "ktor" } +ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } +ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } # database diff --git a/save-agent/build.gradle.kts b/save-agent/build.gradle.kts index f95f789999..ab2836a56c 100644 --- a/save-agent/build.gradle.kts +++ b/save-agent/build.gradle.kts @@ -33,7 +33,9 @@ kotlin { implementation(libs.save.reporters) implementation(libs.ktor.client.core) implementation(libs.ktor.client.curl) - implementation(libs.ktor.client.serialization) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.ktor.client.logging) implementation(libs.kotlinx.serialization.properties) implementation(libs.okio) implementation(libs.kotlinx.datetime) diff --git a/save-agent/src/linuxX64Main/kotlin/com/saveourtool/save/agent/Main.kt b/save-agent/src/linuxX64Main/kotlin/com/saveourtool/save/agent/Main.kt index e293b0cc54..a58e588489 100644 --- a/save-agent/src/linuxX64Main/kotlin/com/saveourtool/save/agent/Main.kt +++ b/save-agent/src/linuxX64Main/kotlin/com/saveourtool/save/agent/Main.kt @@ -4,19 +4,22 @@ package com.saveourtool.save.agent +import com.saveourtool.save.agent.utils.ktorLogger import com.saveourtool.save.agent.utils.logDebugCustom import com.saveourtool.save.agent.utils.logInfoCustom import com.saveourtool.save.agent.utils.markAsExecutable import com.saveourtool.save.agent.utils.readProperties import com.saveourtool.save.core.config.LogType +import com.saveourtool.save.core.logging.describe import com.saveourtool.save.core.logging.logType import generated.SAVE_CLOUD_VERSION import generated.SAVE_CORE_VERSION import io.ktor.client.HttpClient -import io.ktor.client.plugins.HttpTimeout -import io.ktor.client.plugins.json.JsonPlugin -import io.ktor.client.plugins.kotlinx.serializer.KotlinxSerializer +import io.ktor.client.plugins.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.plugins.logging.* +import io.ktor.serialization.kotlinx.json.* import okio.FileSystem import okio.Path.Companion.toPath import platform.posix.* @@ -62,14 +65,7 @@ fun main() { exit(1) }) - val httpClient = HttpClient { - install(JsonPlugin) { - serializer = KotlinxSerializer(json) - } - install(HttpTimeout) { - requestTimeoutMillis = config.requestTimeoutMillis - } - } + val httpClient = configureHttpClient(config) runBlocking { // Launch in a new scope, because we cancel the scope on graceful termination, @@ -83,3 +79,28 @@ fun main() { } logInfoCustom("Agent is shutting down") } + +@Suppress("FLOAT_IN_ACCURATE_CALCULATIONS", "MagicNumber") +private fun configureHttpClient(agentConfiguration: AgentConfiguration) = HttpClient { + install(ContentNegotiation) { + json(json = json) + } + install(HttpTimeout) { + requestTimeoutMillis = agentConfiguration.requestTimeoutMillis + } + install(HttpRequestRetry) { + retryOnException(maxRetries = agentConfiguration.retry.attempts) + retryOnServerErrors(maxRetries = agentConfiguration.retry.attempts) + exponentialDelay(base = agentConfiguration.retry.initialRetryMillis / 1000.0) + modifyRequest { + if (retryCount > 1) { + val reason = response?.status ?: cause?.describe() ?: "Unknown reason" + logDebugCustom("Retrying request: attempt #$retryCount, reason: $reason") + } + } + } + install(Logging) { + logger = ktorLogger + level = if (agentConfiguration.debug) LogLevel.ALL else LogLevel.INFO + } +} diff --git a/save-agent/src/linuxX64Main/kotlin/com/saveourtool/save/agent/SaveAgent.kt b/save-agent/src/linuxX64Main/kotlin/com/saveourtool/save/agent/SaveAgent.kt index e554b1c08b..2f36f9b06b 100644 --- a/save-agent/src/linuxX64Main/kotlin/com/saveourtool/save/agent/SaveAgent.kt +++ b/save-agent/src/linuxX64Main/kotlin/com/saveourtool/save/agent/SaveAgent.kt @@ -47,14 +47,14 @@ import kotlinx.serialization.modules.subclass * @property httpClient */ @Suppress("AVOID_NULL_CHECKS") -class SaveAgent(internal val config: AgentConfiguration, +class SaveAgent(private val config: AgentConfiguration, internal val httpClient: HttpClient, private val coroutineScope: CoroutineScope, ) { /** - * The current [AgentState] of this agent + * The current [AgentState] of this agent. Initial value corresponds to the period when agent needs to finish its configuration. */ - val state = AtomicReference(AgentState.STARTING) + val state = AtomicReference(AgentState.BUSY) // fixme (limitation of old MM): can't use atomic reference to Instant here, because when using `Clock.System.now()` as an assigned value // Kotlin throws `kotlin.native.concurrent.InvalidMutabilityException: mutation attempt of frozen kotlinx.datetime.Instant...` diff --git a/save-agent/src/linuxX64Main/kotlin/com/saveourtool/save/agent/utils/HttpUtils.kt b/save-agent/src/linuxX64Main/kotlin/com/saveourtool/save/agent/utils/HttpUtils.kt index ad6e90f9d8..2c2ae93f05 100644 --- a/save-agent/src/linuxX64Main/kotlin/com/saveourtool/save/agent/utils/HttpUtils.kt +++ b/save-agent/src/linuxX64Main/kotlin/com/saveourtool/save/agent/utils/HttpUtils.kt @@ -5,18 +5,13 @@ package com.saveourtool.save.agent.utils import com.saveourtool.save.agent.AgentState -import com.saveourtool.save.agent.RetryConfig import com.saveourtool.save.agent.SaveAgent import io.ktor.client.* -import io.ktor.client.plugins.* import io.ktor.client.request.* import io.ktor.client.statement.HttpResponse import io.ktor.http.* -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.delay - /** * @param result * @return true if [this] agent's state has been updated to reflect problems with [result] @@ -34,21 +29,22 @@ internal fun SaveAgent.updateStateBasedOnBackendResponse( } /** - * Attempt to send execution data to backend, will retry several times, while increasing delay 2 times on each iteration. + * Attempt to send execution data to backend. * * @param requestToBackend + * @return a [Result] wrapping response */ internal suspend fun SaveAgent.sendDataToBackend( requestToBackend: suspend () -> HttpResponse -): Unit = sendWithRetries(config.retry, requestToBackend) { result, attempt -> - val reason = if (result.isSuccess && result.getOrNull()?.status != HttpStatusCode.OK) { +): Result = runCatching { requestToBackend() }.apply { + val reason = if (isSuccess && getOrNull()?.status != HttpStatusCode.OK) { state.value = AgentState.BACKEND_FAILURE - "Backend returned status ${result.getOrNull()?.status}" + "Backend returned status ${getOrNull()?.status}" } else { state.value = AgentState.BACKEND_UNREACHABLE - "Backend is unreachable, ${result.exceptionOrNull()?.message}" + "Backend is unreachable, ${exceptionOrNull()?.message}" } - logErrorCustom("Cannot post data (x${attempt + 1}), will retry in ${config.retry.initialRetryMillis} ms. Reason: $reason") + logErrorCustom("Cannot send data to backed: $reason") } /** @@ -65,34 +61,5 @@ internal suspend fun HttpClient.download(url: String, body: Any?): Result - logDebugCustom("Received $bytesSentTotal bytes from $contentLength") - } - } -} - -/** - * @param retryConfig - * @param request - * @param onError - */ -@Suppress("TYPE_ALIAS") -internal suspend fun sendWithRetries( - retryConfig: RetryConfig, - request: suspend () -> HttpResponse, - onError: (Result, attempt: Int) -> Unit, -): Unit = coroutineScope { - var retryInterval = retryConfig.initialRetryMillis - repeat(retryConfig.attempts) { attempt -> - val result = runCatching { - request() - } - if (result.isSuccess && result.getOrNull()?.status == HttpStatusCode.OK) { - return@coroutineScope - } else { - onError(result, attempt) - delay(retryInterval) - retryInterval *= 2 - } } } diff --git a/save-agent/src/linuxX64Main/kotlin/com/saveourtool/save/agent/utils/Logging.kt b/save-agent/src/linuxX64Main/kotlin/com/saveourtool/save/agent/utils/Logging.kt index 6692621a86..6e4ec55e6e 100644 --- a/save-agent/src/linuxX64Main/kotlin/com/saveourtool/save/agent/utils/Logging.kt +++ b/save-agent/src/linuxX64Main/kotlin/com/saveourtool/save/agent/utils/Logging.kt @@ -9,10 +9,17 @@ package com.saveourtool.save.agent.utils import com.saveourtool.save.core.logging.logDebug import com.saveourtool.save.core.logging.logError import com.saveourtool.save.core.logging.logInfo +import io.ktor.client.plugins.logging.* import platform.linux.__NR_gettid import platform.posix.syscall +internal val ktorLogger = object : Logger { + override fun log(message: String) { + logInfoCustom("[HTTP Client] $message") + } +} + fun logErrorCustom(msg: String) = logError( "[tid ${syscall(__NR_gettid.toLong())}] $msg" ) diff --git a/save-agent/src/linuxX64Test/kotlin/com/saveourtool/save/agent/SaveAgentTest.kt b/save-agent/src/linuxX64Test/kotlin/com/saveourtool/save/agent/SaveAgentTest.kt index dc86e8e287..77eb41b099 100644 --- a/save-agent/src/linuxX64Test/kotlin/com/saveourtool/save/agent/SaveAgentTest.kt +++ b/save-agent/src/linuxX64Test/kotlin/com/saveourtool/save/agent/SaveAgentTest.kt @@ -6,13 +6,12 @@ import generated.SAVE_CORE_VERSION import io.ktor.client.HttpClient import io.ktor.client.engine.mock.MockEngine import io.ktor.client.engine.mock.respond -import io.ktor.client.plugins.json.JsonPlugin -import io.ktor.client.plugins.kotlinx.serializer.KotlinxSerializer +import io.ktor.client.plugins.contentnegotiation.* import io.ktor.http.ContentType import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.http.headersOf -import platform.posix.system +import io.ktor.serialization.kotlinx.json.* import kotlin.test.AfterTest import kotlin.test.BeforeTest @@ -32,8 +31,8 @@ class SaveAgentTest { if (Platform.osFamily == OsFamily.WINDOWS) it.copy(cliCommand = "save-$SAVE_CORE_VERSION-linuxX64.bat") else it } private val saveAgentForTest = SaveAgent(configuration, httpClient = HttpClient(MockEngine) { - install(JsonPlugin) { - serializer = KotlinxSerializer(json) + install(ContentNegotiation) { + json(json) } engine { addHandler { request -> @@ -75,7 +74,7 @@ class SaveAgentTest { @Test fun `should change state to FINISHED after SAVE CLI returns`() = runBlocking { - assertEquals(AgentState.STARTING, saveAgentForTest.state.value) + assertEquals(AgentState.BUSY, saveAgentForTest.state.value) runBlocking { saveAgentForTest.run { startSaveProcess("") } } assertEquals(AgentState.FINISHED, saveAgentForTest.state.value) } diff --git a/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/service/DockerService.kt b/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/service/DockerService.kt index 60e0d6b650..2db764a17b 100644 --- a/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/service/DockerService.kt +++ b/save-orchestrator/src/main/kotlin/com/saveourtool/save/orchestrator/service/DockerService.kt @@ -83,7 +83,6 @@ class DockerService( ) log.info("Preparing volume for execution.id=${execution.id}") val buildResult = prepareImageAndVolumeForExecution(resourcesForExecution, execution) - // todo (k8s): need to also push it so that other nodes will have access to it log.info("For execution.id=${execution.id} using base image [${buildResult.imageTag}] and PV [id=${buildResult.pvId}]") return buildResult } From 97ec216da9a5bd0660a4b1c086ada60a037941ba Mon Sep 17 00:00:00 2001 From: Andrey Kuleshov Date: Mon, 22 Aug 2022 16:32:43 +0300 Subject: [PATCH 6/7] Phase 1: New contests view template (#1070) ### What's done: - new contests view template added - phase 1 done, all other logic will be made later (this part is just a template) --- diktat-analysis.yml | 2 +- .../backend/controllers/ContestController.kt | 2 +- .../saveourtool/save/utils/DateTimeUtils.kt | 16 ++ .../saveourtool/save/utils/EntitiesUtils.kt | 2 - save-frontend/build.gradle.kts | 2 - .../com/saveourtool/save/frontend/App.kt | 9 +- .../frontend/components/basic/ScoreCard.kt | 4 +- .../frontend/components/basic/UserBoard.kt | 2 +- .../organizations/OrganizationContestsMenu.kt | 13 +- .../components/views/AwesomeBenchmarksView.kt | 31 +- .../components/views/CollectionView.kt | 22 +- .../components/views/ContestListView.kt | 177 ------------ .../frontend/components/views/ContestView.kt | 1 - .../components/views/OrganizationView.kt | 13 +- .../views/contests/ContestListCard.kt | 151 ++++++++++ .../views/contests/ContestListView.kt | 243 ++++++++++++++++ .../views/contests/CountDownTimer.kt | 30 ++ .../views/contests/FeaturedContest.kt | 94 ++++++ .../views/contests/NewContestsCard.kt | 71 +++++ .../views/contests/ProposeYourContest.kt | 30 ++ .../components/views/contests/UserRating.kt | 69 +++++ .../components/views/contests/Utils.kt | 86 ++++++ .../UserSettingsEmailMenuView.kt | 2 +- .../UserSettingsOrganizationsMenuView.kt | 2 +- .../UserSettingsProfileMenuView.kt | 2 +- .../UserSettingsTokenMenuView.kt | 3 +- .../UserSettingsView.kt | 4 +- .../components/views/welcome/WelcomeView.kt | 8 +- .../views/welcome/pagers/AwesomeBenchmarks.kt | 4 +- .../views/welcome/pagers/BobPage.kt | 4 +- .../views/welcome/pagers/GeneralInfo.kt | 13 +- .../welcome/pagers/GeneralInfoPictures.kt | 10 +- .../views/welcome/pagers/HighLevelSave.kt | 4 +- .../frontend/externals/fontawesome/Icons.kt | 8 + .../src/main/resources/img/green_square.png | Bin 30895 -> 0 bytes .../img/undraw_certificate_re_yadi.svg | 1 + .../img/undraw_exciting_news_re_y1iw.svg | 1 + .../resources/img/undraw_for_review_eqxk.svg | 1 + .../resources/img/undraw_mailbox_re_dvds.svg | 1 + .../resources/img/undraw_news_re_6uub.svg | 1 + .../resources/img/undraw_notify_re_65on.svg | 1 + .../img/undraw_selecting_team_re_ndkb.svg | 1 + save-frontend/src/main/resources/img/user.svg | 270 ------------------ .../src/main/resources/scss/_buttons.scss | 6 - 44 files changed, 883 insertions(+), 534 deletions(-) create mode 100644 save-cloud-common/src/jsMain/kotlin/com/saveourtool/save/utils/DateTimeUtils.kt delete mode 100644 save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/ContestListView.kt create mode 100644 save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/contests/ContestListCard.kt create mode 100644 save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/contests/ContestListView.kt create mode 100644 save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/contests/CountDownTimer.kt create mode 100644 save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/contests/FeaturedContest.kt create mode 100644 save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/contests/NewContestsCard.kt create mode 100644 save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/contests/ProposeYourContest.kt create mode 100644 save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/contests/UserRating.kt create mode 100644 save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/contests/Utils.kt rename save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/{usersettingsview => usersettings}/UserSettingsEmailMenuView.kt (99%) rename save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/{usersettingsview => usersettings}/UserSettingsOrganizationsMenuView.kt (99%) rename save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/{usersettingsview => usersettings}/UserSettingsProfileMenuView.kt (99%) rename save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/{usersettingsview => usersettings}/UserSettingsTokenMenuView.kt (99%) rename save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/{usersettingsview => usersettings}/UserSettingsView.kt (99%) delete mode 100644 save-frontend/src/main/resources/img/green_square.png create mode 100644 save-frontend/src/main/resources/img/undraw_certificate_re_yadi.svg create mode 100644 save-frontend/src/main/resources/img/undraw_exciting_news_re_y1iw.svg create mode 100644 save-frontend/src/main/resources/img/undraw_for_review_eqxk.svg create mode 100644 save-frontend/src/main/resources/img/undraw_mailbox_re_dvds.svg create mode 100644 save-frontend/src/main/resources/img/undraw_news_re_6uub.svg create mode 100644 save-frontend/src/main/resources/img/undraw_notify_re_65on.svg create mode 100644 save-frontend/src/main/resources/img/undraw_selecting_team_re_ndkb.svg delete mode 100644 save-frontend/src/main/resources/img/user.svg diff --git a/diktat-analysis.yml b/diktat-analysis.yml index 441c713f51..8d14c5d906 100644 --- a/diktat-analysis.yml +++ b/diktat-analysis.yml @@ -92,7 +92,7 @@ - name: TOO_LONG_FUNCTION enabled: true configuration: - maxFunctionLength: 35 # max length of function + maxFunctionLength: 55 # max length of function isIncludeHeader: false # count function's header - name: TOO_MANY_PARAMETERS enabled: true diff --git a/save-backend/src/main/kotlin/com/saveourtool/save/backend/controllers/ContestController.kt b/save-backend/src/main/kotlin/com/saveourtool/save/backend/controllers/ContestController.kt index 0ff896b7db..aa91045e4e 100644 --- a/save-backend/src/main/kotlin/com/saveourtool/save/backend/controllers/ContestController.kt +++ b/save-backend/src/main/kotlin/com/saveourtool/save/backend/controllers/ContestController.kt @@ -256,7 +256,7 @@ internal class ContestController( contestService.createContestIfNotPresent(it) } .switchIfEmptyToResponseException(HttpStatus.CONFLICT) { - "Contest with name ${contestDto.name} is already present" + "Contest with name [${contestDto.name}] already exists!" } .map { ResponseEntity.ok("Contest has been successfully created!") diff --git a/save-cloud-common/src/jsMain/kotlin/com/saveourtool/save/utils/DateTimeUtils.kt b/save-cloud-common/src/jsMain/kotlin/com/saveourtool/save/utils/DateTimeUtils.kt new file mode 100644 index 0000000000..c08d6291c5 --- /dev/null +++ b/save-cloud-common/src/jsMain/kotlin/com/saveourtool/save/utils/DateTimeUtils.kt @@ -0,0 +1,16 @@ +/** + * Utility methods related to a Date and Time + */ + +package com.saveourtool.save.utils + +import kotlinx.datetime.Clock +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime + +actual typealias LocalDateTime = kotlinx.datetime.LocalDateTime + +/** + * @return current local date-time in UTC timezone + */ +fun getCurrentLocalDateTime() = Clock.System.now().toLocalDateTime(TimeZone.UTC) diff --git a/save-cloud-common/src/jsMain/kotlin/com/saveourtool/save/utils/EntitiesUtils.kt b/save-cloud-common/src/jsMain/kotlin/com/saveourtool/save/utils/EntitiesUtils.kt index 8651ac2fed..292a4155f9 100644 --- a/save-cloud-common/src/jsMain/kotlin/com/saveourtool/save/utils/EntitiesUtils.kt +++ b/save-cloud-common/src/jsMain/kotlin/com/saveourtool/save/utils/EntitiesUtils.kt @@ -2,8 +2,6 @@ package com.saveourtool.save.utils -actual typealias LocalDateTime = kotlinx.datetime.LocalDateTime - actual enum class EnumType { /** Persist enumerated type property or field as an integer. */ ORDINAL, diff --git a/save-frontend/build.gradle.kts b/save-frontend/build.gradle.kts index 215844483d..a01cd4ba55 100644 --- a/save-frontend/build.gradle.kts +++ b/save-frontend/build.gradle.kts @@ -76,8 +76,6 @@ kotlin { implementation(npm("react-tsparticles", "1.42.1")) implementation(npm("tsparticles", "2.1.3")) implementation(npm("jquery", "3.6.0")) - // can be very useful for CSS animation: - // implementation(npm("animate.css", "4.1.1")) // BS5: implementation(npm("@popperjs/core", "2.11.0")) implementation(npm("popper.js", "1.16.1")) // BS5: implementation(npm("bootstrap", "5.0.1")) diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/App.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/App.kt index a632e20ac9..b149c85371 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/App.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/App.kt @@ -11,10 +11,11 @@ import com.saveourtool.save.execution.TestExecutionFilters import com.saveourtool.save.frontend.components.* import com.saveourtool.save.frontend.components.basic.scrollToTopButton import com.saveourtool.save.frontend.components.views.* -import com.saveourtool.save.frontend.components.views.usersettingsview.UserSettingsEmailMenuView -import com.saveourtool.save.frontend.components.views.usersettingsview.UserSettingsOrganizationsMenuView -import com.saveourtool.save.frontend.components.views.usersettingsview.UserSettingsProfileMenuView -import com.saveourtool.save.frontend.components.views.usersettingsview.UserSettingsTokenMenuView +import com.saveourtool.save.frontend.components.views.contests.ContestListView +import com.saveourtool.save.frontend.components.views.usersettings.UserSettingsEmailMenuView +import com.saveourtool.save.frontend.components.views.usersettings.UserSettingsOrganizationsMenuView +import com.saveourtool.save.frontend.components.views.usersettings.UserSettingsProfileMenuView +import com.saveourtool.save.frontend.components.views.usersettings.UserSettingsTokenMenuView import com.saveourtool.save.frontend.components.views.welcome.WelcomeView import com.saveourtool.save.frontend.externals.modal.ReactModal import com.saveourtool.save.frontend.http.getUser diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/ScoreCard.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/ScoreCard.kt index 1f32b01a7f..49b82e955f 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/ScoreCard.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/ScoreCard.kt @@ -9,7 +9,7 @@ import react.dom.aria.AriaRole import react.dom.aria.ariaValueMax import react.dom.aria.ariaValueMin import react.dom.aria.ariaValueNow -import react.dom.html.ReactHTML +import react.dom.html.ReactHTML.a import react.dom.html.ReactHTML.div import react.dom.html.ReactHTML.h6 @@ -95,7 +95,7 @@ private fun scoreCard() = FC { props -> } props.url?.let { - ReactHTML.a { + a { href = props.url +props.name } diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/UserBoard.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/UserBoard.kt index c5fda53ede..2d29e5d940 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/UserBoard.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/UserBoard.kt @@ -58,7 +58,7 @@ private fun userBoard() = FC { props -> "/api/$v1/avatar$path" } ?: run { - "img/user.svg" + "img/undraw_profile.svg" } alt = "" } diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/organizations/OrganizationContestsMenu.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/organizations/OrganizationContestsMenu.kt index 242d3914ed..17817fdabd 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/organizations/OrganizationContestsMenu.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/basic/organizations/OrganizationContestsMenu.kt @@ -19,9 +19,10 @@ import org.w3c.fetch.Response import react.* import react.dom.html.ButtonType -import react.dom.html.ReactHTML +import react.dom.html.ReactHTML.a import react.dom.html.ReactHTML.button import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.td import react.table.columns /** @@ -34,8 +35,8 @@ private val contestsTable: FC> = tabl columns = columns { column(id = "name", header = "Contest Name", { name }) { cellProps -> Fragment.create { - ReactHTML.td { - ReactHTML.a { + td { + a { href = "#/contests/${cellProps.row.original.name}" +cellProps.value } @@ -44,21 +45,21 @@ private val contestsTable: FC> = tabl } column(id = "description", header = "Description", { description }) { cellProps -> Fragment.create { - ReactHTML.td { + td { +(cellProps.value ?: "Description is not provided") } } } column(id = "start_time", header = "Start Time", { startTime.toString() }) { cellProps -> Fragment.create { - ReactHTML.td { + td { +cellProps.value.replace("T", " ") } } } column(id = "end_time", header = "End Time", { endTime.toString() }) { cellProps -> Fragment.create { - ReactHTML.td { + td { +cellProps.value.replace("T", " ") } } diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/AwesomeBenchmarksView.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/AwesomeBenchmarksView.kt index 84da0c18a5..068a87a5a8 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/AwesomeBenchmarksView.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/AwesomeBenchmarksView.kt @@ -87,7 +87,13 @@ class AwesomeBenchmarksView : AbstractView { + style = jso { cursor = "pointer".unsafeCast() } @@ -486,22 +492,19 @@ class AwesomeBenchmarksView : AbstractView = get( - "$apiUrl/${FrontendRoutes.AWESOME_BENCHMARKS.path}", - headers, - loadingHandler = ::classLoadingHandler, - ).decodeFromJsonString() - - setState { - benchmarks = response - } + val response: List = get( + "$apiUrl/${FrontendRoutes.AWESOME_BENCHMARKS.path}", + headers, + loadingHandler = ::classLoadingHandler, + ).decodeFromJsonString() + + setState { + benchmarks = response } } diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/CollectionView.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/CollectionView.kt index 72bda8451e..25a3a6ca0a 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/CollectionView.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/CollectionView.kt @@ -92,21 +92,19 @@ class CollectionView : AbstractView(false) { override fun ChildrenBuilder.render() { div { hidden = (props.currentUserInfo == null) - button { - type = ButtonType.button - className = ClassName("btn btn-primary mb-2 mr-2") - a { - className = ClassName("text-light") - href = "#/${FrontendRoutes.CREATE_PROJECT.path}/" + a { + href = "#/${FrontendRoutes.CREATE_PROJECT.path}/" + button { + type = ButtonType.button + className = ClassName("btn btn-outline-primary mb-2 mr-2") +"Add new tested tool" } } - button { - type = ButtonType.button - className = ClassName("btn btn-primary mb-2") - a { - className = ClassName("text-light") - href = "#/${FrontendRoutes.CREATE_ORGANIZATION.path}/" + a { + href = "#/${FrontendRoutes.CREATE_ORGANIZATION.path}/" + button { + type = ButtonType.button + className = ClassName("btn btn-outline-primary mb-2") +"Add new organization" } } diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/ContestListView.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/ContestListView.kt deleted file mode 100644 index 3ad95ea4e3..0000000000 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/ContestListView.kt +++ /dev/null @@ -1,177 +0,0 @@ -@file:Suppress("FILE_WILDCARD_IMPORTS", "WildcardImport", "HEADER_MISSING_IN_NON_SINGLE_CLASS_FILE") - -package com.saveourtool.save.frontend.components.views - -import com.saveourtool.save.entities.ContestDto -import com.saveourtool.save.frontend.components.RequestStatusContext -import com.saveourtool.save.frontend.components.basic.ContestNameProps -import com.saveourtool.save.frontend.components.basic.showContestEnrollerModal -import com.saveourtool.save.frontend.components.requestStatusContext -import com.saveourtool.save.frontend.components.tables.tableComponent -import com.saveourtool.save.frontend.utils.* -import com.saveourtool.save.frontend.utils.classLoadingHandler -import com.saveourtool.save.info.UserInfo -import com.saveourtool.save.validation.FrontendRoutes - -import org.w3c.fetch.Headers -import react.* -import react.dom.html.ReactHTML.a -import react.dom.html.ReactHTML.td -import react.table.columns - -/** - * [Props] retrieved from router - */ -@Suppress("MISSING_KDOC_CLASS_ELEMENTS") -external interface ContestListViewProps : Props { - var currentUserInfo: UserInfo? -} - -/** - * [State] of [ContestListView] component - */ -external interface ContestListViewState : State { - /** - * Flag to show project selector modal - */ - var isProjectSelectorModalOpen: Boolean - - /** - * Flag th show confirmation modal - */ - var isConfirmationWindowOpen: Boolean - - /** - * Name of a contest selected for enrollment - */ - var selectedContestName: String? - - /** - * Enrollment response received from backend - */ - var enrollmentResponse: String? -} - -/** - * A view with collection of contests - */ -@JsExport -@OptIn(ExperimentalJsExport::class) -class ContestListView : AbstractView(false) { - private val openParticipateModal: (String) -> Unit = { contestName -> - setState { - selectedContestName = contestName - isProjectSelectorModalOpen = true - } - } - - @Suppress("MAGIC_NUMBER") - private val contestsTable = tableComponent( - columns = columns { - column(id = "name", header = "Contest Name", { this }) { cellProps -> - Fragment.create { - td { - onClick = { - openParticipateModal(cellProps.value.name) - } - a { - href = "#/${FrontendRoutes.CONTESTS.path}/${cellProps.row.original.name}" - +cellProps.value.name - } - } - } - } - column(id = "description", header = "Description", { this }) { cellProps -> - Fragment.create { - td { - onClick = { - openParticipateModal(cellProps.value.name) - } - +(cellProps.value.description ?: "Description is not provided") - } - } - } - column(id = "start_time", header = "Start Time", { this }) { cellProps -> - Fragment.create { - td { - onClick = { - openParticipateModal(cellProps.value.name) - } - +cellProps.value.startTime.toString().replace("T", " ") - } - } - } - column(id = "end_time", header = "End Time", { this }) { cellProps -> - Fragment.create { - td { - onClick = { - openParticipateModal(cellProps.value.name) - } - +cellProps.value.endTime.toString().replace("T", " ") - } - } - } - }, - initialPageSize = 10, - useServerPaging = false, - usePageSelection = false, - ) - init { - state.selectedContestName = null - state.isProjectSelectorModalOpen = false - state.enrollmentResponse = null - state.isConfirmationWindowOpen = false - } - @Suppress( - "EMPTY_BLOCK_STRUCTURE_ERROR", - "TOO_LONG_FUNCTION", - "MAGIC_NUMBER", - "LongMethod", - ) - override fun ChildrenBuilder.render() { - showContestEnrollerModal( - state.isProjectSelectorModalOpen, - ContestNameProps(state.selectedContestName ?: ""), - { setState { isProjectSelectorModalOpen = false } } - ) { - setState { - enrollmentResponse = it - isConfirmationWindowOpen = true - isProjectSelectorModalOpen = false - } - } - runErrorModal( - state.isConfirmationWindowOpen, - "Contest Registration", - state.enrollmentResponse ?: "", - "Ok" - ) { - setState { isConfirmationWindowOpen = false } - } - contestsTable { - getData = { _, _ -> - val response = get( - url = "$apiUrl/contests/active", - headers = Headers().also { - it.set("Accept", "application/json") - }, - loadingHandler = ::classLoadingHandler, - ) - if (response.ok) { - response.unsafeMap { - it.decodeFromJsonString>() - } - .toTypedArray() - } else { - emptyArray() - } - } - } - } - - companion object : RStatics>(ContestListView::class) { - init { - contextType = requestStatusContext - } - } -} diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/ContestView.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/ContestView.kt index a67b14a370..73d5105684 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/ContestView.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/ContestView.kt @@ -15,7 +15,6 @@ import csstype.ClassName import org.w3c.fetch.Headers import react.* -import react.dom.* import react.dom.html.ReactHTML.div import react.dom.html.ReactHTML.h1 diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/OrganizationView.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/OrganizationView.kt index 722faf1edd..eb4466f9ec 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/OrganizationView.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/OrganizationView.kt @@ -689,13 +689,12 @@ class OrganizationView : AbstractView( } if (state.selfRole.isHigherOrEqualThan(Role.ADMIN)) { - button { - type = ButtonType.button - className = ClassName("btn btn-primary") - a { - className = ClassName("text-light") - href = "#/${FrontendRoutes.CREATE_PROJECT.path}/" - +"+ New Tool" + a { + href = "#/${FrontendRoutes.CREATE_PROJECT.path}/" + button { + type = ButtonType.button + className = ClassName("btn btn-outline-info") + +"Add Tool" } } } diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/contests/ContestListCard.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/contests/ContestListCard.kt new file mode 100644 index 0000000000..c6fa60a078 --- /dev/null +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/contests/ContestListCard.kt @@ -0,0 +1,151 @@ +/** + * a card with contests list from ContestListView + */ + +package com.saveourtool.save.frontend.components.views.contests + +import com.saveourtool.save.entities.ContestDto +import com.saveourtool.save.frontend.externals.fontawesome.faArrowRight +import com.saveourtool.save.frontend.externals.fontawesome.faCode +import com.saveourtool.save.frontend.externals.fontawesome.fontAwesomeIcon +import com.saveourtool.save.validation.FrontendRoutes + +import csstype.ClassName +import csstype.rem +import react.ChildrenBuilder +import react.FC +import react.Props +import react.dom.html.ButtonType +import react.dom.html.ReactHTML.a +import react.dom.html.ReactHTML.button +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.img +import react.dom.html.ReactHTML.p +import react.dom.html.ReactHTML.strong + +import kotlinx.js.jso + +val contestListFc = contestList() + +/** + * this enum is used in a tab for contests + */ +enum class ContestTypesTab { + ACTIVE, FINISHED, PLANNED +} + +/** + * properties for contestList FC + */ +external interface ContestListProps : Props { + /** + * contest tab selected by user (propagated from state) + */ + var selectedTab: String? + + /** + * callback to update state with a tab selection + */ + var updateTabState: (String) -> Unit + + /** + * all finished contests + */ + var finishedContests: Set + + /** + * all active contests + */ + var activeContests: Set + + /** + * callback to set selected contest (will trigger a modal window with a participation modal window) + */ + var updateSelectedContestName: (String) -> Unit +} + +private fun ChildrenBuilder.contests(props: ContestListProps) { + when (props.selectedTab) { + ContestTypesTab.ACTIVE.name -> contestListTable(props.activeContests, props.updateSelectedContestName) + ContestTypesTab.FINISHED.name -> contestListTable(props.finishedContests, props.updateSelectedContestName) + ContestTypesTab.PLANNED.name -> { + // FixMe: Add planned contests + } + } +} + +@Suppress("MAGIC_NUMBER") +private fun ChildrenBuilder.contestListTable(contests: Set, updateSelectedContestName: (String) -> Unit) { + contests.forEachIndexed { i, contest -> + div { + className = ClassName("media text-muted pb-3") + img { + className = ClassName("rounded") + asDynamic()["data-src"] = + "holder.js/32x32?theme=thumb&bg=007bff&fg=007bff&size=1" + src = "img/undraw_code_inspection_bdl7.svg" + asDynamic()["data-holder-rendered"] = "true" + style = jso { + width = 4.2.rem + } + } + + p { + className = ClassName("media-body pb-3 mb-0 small lh-125 border-bottom border-gray text-left") + strong { + className = ClassName("d-block text-gray-dark") + +contest.name + } + +(contest.description ?: "") + + div { + className = ClassName("navbar-landing mt-3") + button { + type = ButtonType.button + className = ClassName("btn btn-outline-success ml-auto mr-2") + onClick = { + // FixMe: fix the selector here + updateSelectedContestName(contest.name) + } + +"Enroll" + } + a { + className = ClassName("btn btn-outline-info mr-2") + href = "#/${FrontendRoutes.CONTESTS.path}/${contest.name}" + +"Rules and more " + fontAwesomeIcon(icon = faArrowRight) + } + } + } + } + } +} + +/** + * @return functional component that render the stylish table with contests + */ +fun contestList() = FC { props -> + div { + className = ClassName("col-lg-9") + div { + className = ClassName("card flex-md-row mb-1 box-shadow") + style = jso { + minHeight = 30.rem + } + + div { + className = ClassName("col") + + title(" Available Contests", faCode) + + tab( + props.selectedTab, + ContestTypesTab.values().map { it.name }, + props.updateTabState + ) + + contests(props) + } + } + } +} diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/contests/ContestListView.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/contests/ContestListView.kt new file mode 100644 index 0000000000..7bfcd6115b --- /dev/null +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/contests/ContestListView.kt @@ -0,0 +1,243 @@ +/** + * Contests "market" - showcase for users, where they can navigate and check contests + */ + +@file:Suppress("FILE_WILDCARD_IMPORTS", "WildcardImport", "MAGIC_NUMBER") + +package com.saveourtool.save.frontend.components.views.contests + +import com.saveourtool.save.entities.ContestDto +import com.saveourtool.save.frontend.components.RequestStatusContext +import com.saveourtool.save.frontend.components.basic.ContestNameProps +import com.saveourtool.save.frontend.components.basic.showContestEnrollerModal +import com.saveourtool.save.frontend.components.requestStatusContext +import com.saveourtool.save.frontend.components.views.AbstractView +import com.saveourtool.save.frontend.utils.* +import com.saveourtool.save.frontend.utils.classLoadingHandler +import com.saveourtool.save.info.UserInfo +import com.saveourtool.save.utils.LocalDateTime +import com.saveourtool.save.utils.getCurrentLocalDateTime + +import csstype.ClassName +import csstype.rem +import org.w3c.fetch.Headers +import react.* +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.main + +import kotlinx.coroutines.launch +import kotlinx.js.jso + +/** + * [Props] retrieved from router + */ +@Suppress("MISSING_KDOC_CLASS_ELEMENTS") +external interface ContestListViewProps : Props { + var currentUserInfo: UserInfo? +} + +/** + * [State] of [ContestListView] component + */ +external interface ContestListViewState : State { + /** + * list of contests that are not expired yet (deadline is not reached) + */ + var activeContests: Set + + /** + * list of contests that are expired (deadline has reached) + */ + var finishedContests: Set + + /** + * current time + */ + var currentDateTime: LocalDateTime + + /** + * selected tab + */ + var selectedContestsTab: String? + + /** + * selected tab + */ + var selectedRatingTab: String? + + /** + * Flag to show project selector modal + */ + var isProjectSelectorModalOpen: Boolean + + /** + * Flag th show confirmation modal + */ + var isConfirmationWindowOpen: Boolean + + /** + * Name of a contest selected for enrollment + */ + var selectedContestName: String? + + /** + * Enrollment response received from backend + */ + var enrollmentResponse: String? +} + +/** + * A view with collection of contests + */ +@JsExport +@OptIn(ExperimentalJsExport::class) +class ContestListView : AbstractView() { + private val openParticipateModal: (String) -> Unit = { contestName -> + setState { + selectedContestName = contestName + isProjectSelectorModalOpen = true + } + } + + init { + state.selectedRatingTab = UserRatingTab.ORGANIZATIONS.name + state.selectedContestsTab = ContestTypesTab.ACTIVE.name + state.finishedContests = emptySet() + state.activeContests = emptySet() + state.currentDateTime = getCurrentLocalDateTime() + } + + override fun componentDidMount() { + super.componentDidMount() + scope.launch { + getAndInitActiveContests() + getAndInitFinishedContests() + } + } + + @Suppress("TOO_LONG_FUNCTION", "LongMethod") + override fun ChildrenBuilder.render() { + showContestEnrollerModal( + state.isProjectSelectorModalOpen, + ContestNameProps(state.selectedContestName ?: ""), + { setState { isProjectSelectorModalOpen = false } } + ) { + setState { + enrollmentResponse = it + isConfirmationWindowOpen = true + isProjectSelectorModalOpen = false + } + } + runErrorModal( + state.isConfirmationWindowOpen, + "Contest Registration", + state.enrollmentResponse ?: "", + "Ok" + ) { + setState { isConfirmationWindowOpen = false } + } + + main { + className = ClassName("main-content mt-0 ps") + div { + className = ClassName("page-header align-items-start min-vh-100") + div { + className = ClassName("row justify-content-center") + div { + className = ClassName("col-lg-9") + div { + className = ClassName("row mb-2") + featuredContest() + newContestsCard() + } + + div { + className = ClassName("row mb-2") + div { + className = ClassName("col-lg-5") + div { + className = ClassName("card flex-md-row mb-1 box-shadow") + style = jso { + minHeight = 7.rem + } + } + } + div { + className = ClassName("col-lg-5") + div { + className = ClassName("card flex-md-row mb-1 box-shadow") + style = jso { + minHeight = 7.rem + } + } + } + + proposeContest() + } + + div { + className = ClassName("row mb-2") + userRatingFc { + selectedTab = state.selectedRatingTab + updateTabState = { setState { selectedRatingTab = it } } + } + + contestListFc { + activeContests = state.activeContests + finishedContests = state.finishedContests + selectedTab = state.selectedContestsTab + updateTabState = { setState { selectedContestsTab = it } } + updateSelectedContestName = { setState { selectedContestName = it } } + } + } + } + } + } + } + } + + private suspend fun getAndInitActiveContests() { + getAndInitContests("active") { + setState { + activeContests = it + } + } + } + + private suspend fun getAndInitFinishedContests() { + getAndInitContests("finished") { + setState { + finishedContests = it + } + } + } + + private suspend fun getAndInitContests(url: String, setState: (Set) -> Unit) { + val response = get( + url = "$apiUrl/contests/$url", + headers = Headers().also { + it.set("Accept", "application/json") + }, + loadingHandler = ::classLoadingHandler, + ) + val contestsUpdate = if (response.ok) { + response.unsafeMap { + it.decodeFromJsonString>() + } + .toTypedArray() + } else { + emptyArray() + }.toSet() + + setState(contestsUpdate) + } + + companion object : + RStatics>( + ContestListView::class + ) { + init { + contextType = requestStatusContext + } + } +} diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/contests/CountDownTimer.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/contests/CountDownTimer.kt new file mode 100644 index 0000000000..7cf0415d62 --- /dev/null +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/contests/CountDownTimer.kt @@ -0,0 +1,30 @@ +/** + * Countdown timer FC that is used to show how much time is left for this particular contest. + * The idea is to motivate user to participate in contests from this view. + */ + +@file:Suppress("diktat") + +package com.saveourtool.save.frontend.components.views.contests + +import com.saveourtool.save.entities.ContestDto +import react.FC +import react.Props +import kotlin.js.Date + +/** + * constant component + */ +val countDownFc = countDownTimer() + +external interface CountDownProps : Props { + var contests: Array +} + +/** + * @return + */ +fun countDownTimer() = FC {props -> + console.log(Date().toLocaleString()) + TODO("This will be finished in phaze 2: this countdown timer should be in a featured contest") +} diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/contests/FeaturedContest.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/contests/FeaturedContest.kt new file mode 100644 index 0000000000..ded8c14b4c --- /dev/null +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/contests/FeaturedContest.kt @@ -0,0 +1,94 @@ +/** + * A card with a FEATURED contest (left top card) + */ + +package com.saveourtool.save.frontend.components.views.contests + +import com.saveourtool.save.frontend.externals.fontawesome.faArrowRight +import com.saveourtool.save.frontend.externals.fontawesome.fontAwesomeIcon + +import csstype.ClassName +import csstype.rem +import react.ChildrenBuilder +import react.dom.html.ButtonType +import react.dom.html.ReactHTML.a +import react.dom.html.ReactHTML.button +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.h3 +import react.dom.html.ReactHTML.img +import react.dom.html.ReactHTML.p +import react.dom.html.ReactHTML.strong + +import kotlinx.js.jso + +/** + * Rendering of featured contest card + */ +@Suppress("MAGIC_NUMBER") +fun ChildrenBuilder.featuredContest() { + div { + className = ClassName("col-lg-6") + div { + className = ClassName("card flex-md-row mb-1 box-shadow") + style = jso { + height = 14.rem + } + + image() + + div { + className = ClassName("card-body d-flex flex-column align-items-start") + strong { + className = ClassName("d-inline-block mb-2 text-info") + +"Featured Contest" + } + h3 { + className = ClassName("mb-0") + a { + className = ClassName("text-dark") + href = "#" + +"Contest NAME" + } + } + p { + className = ClassName("card-text mb-auto") + +"Contest DESCRIPTION SHORT" + } + div { + className = ClassName("row") + button { + type = ButtonType.button + className = ClassName("btn btn-sm btn-outline-primary mr-1") + onClick = { + // FixMe: add enrollment logic - modal window + } + +"Enroll" + } + + button { + type = ButtonType.button + className = ClassName("btn btn-sm btn-outline-success") + onClick = { + // FixMe: link or a modal window here? + } + +"Description " + fontAwesomeIcon(icon = faArrowRight) + } + } + } + } + } +} + +@Suppress("MAGIC_NUMBER") +private fun ChildrenBuilder.image() { + img { + className = ClassName("card-img-right flex-auto d-none d-md-block") + asDynamic()["data-src"] = "holder.js/200x250?theme=thumb" + src = "img/undraw_certificate_re_yadi.svg" + asDynamic()["data-holder-rendered"] = "true" + style = jso { + width = 12.rem + } + } +} diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/contests/NewContestsCard.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/contests/NewContestsCard.kt new file mode 100644 index 0000000000..a6b9fa58ca --- /dev/null +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/contests/NewContestsCard.kt @@ -0,0 +1,71 @@ +/** + * card with newly added contests + */ + +package com.saveourtool.save.frontend.components.views.contests + +import csstype.ClassName +import csstype.rem +import react.ChildrenBuilder +import react.dom.html.ReactHTML.a +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.h3 +import react.dom.html.ReactHTML.img +import react.dom.html.ReactHTML.p +import react.dom.html.ReactHTML.strong + +import kotlinx.js.jso + +/** + * rendering of newly added contests + */ +@Suppress("MAGIC_NUMBER") +fun ChildrenBuilder.newContestsCard() { + div { + className = ClassName("col-lg-6") + div { + className = ClassName("card flex-md-row mb-1 box-shadow") + style = jso { + height = 14.rem + } + + div { + className = ClassName("card-body d-flex flex-column align-items-start") + strong { + className = ClassName("d-inline-block mb-2 text-success") + +"""New contests""" + } + h3 { + className = ClassName("mb-0") + a { + className = ClassName("text-dark") + href = "#" + +"Hurry up!" + } + } + p { + className = ClassName("card-text mb-auto") + +"Checkout and participate in newest contests!" + } + a { + href = "https://github.com/saveourtool/save-cloud" + +"Link " + } + a { + href = "https://github.com/saveourtool/save" + +" Other link" + } + } + + img { + className = ClassName("card-img-right flex-auto d-none d-md-block") + asDynamic()["data-src"] = "holder.js/200x250?theme=thumb" + src = "img/undraw_exciting_news_re_y1iw.svg" + asDynamic()["data-holder-rendered"] = "true" + style = jso { + width = 12.rem + } + } + } + } +} diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/contests/ProposeYourContest.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/contests/ProposeYourContest.kt new file mode 100644 index 0000000000..c44ee57466 --- /dev/null +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/contests/ProposeYourContest.kt @@ -0,0 +1,30 @@ +/** + * Card with our e-mail - where you can propose a contest + */ + +package com.saveourtool.save.frontend.components.views.contests + +import csstype.ClassName +import csstype.rem +import react.ChildrenBuilder +import react.dom.html.ReactHTML.div + +import kotlinx.js.jso + +/** + * rendering of a card where we suggest to propose new custom contests + */ +@Suppress("MAGIC_NUMBER") +fun ChildrenBuilder.proposeContest() { + div { + className = ClassName("col-lg-2") + div { + className = ClassName("card flex-md-row mb-1 box-shadow") + style = jso { + minHeight = 7.rem + } + + // FixMe: Want to propose contest? Write e-mail! undraw_mailbox_re_dvds.svg + } + } +} diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/contests/UserRating.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/contests/UserRating.kt new file mode 100644 index 0000000000..e60b1c10a9 --- /dev/null +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/contests/UserRating.kt @@ -0,0 +1,69 @@ +/** + * Card for the rendering of ratings: for organizations and tools + */ + +package com.saveourtool.save.frontend.components.views.contests + +import com.saveourtool.save.frontend.externals.fontawesome.faArrowRight +import com.saveourtool.save.frontend.externals.fontawesome.faTrophy +import com.saveourtool.save.frontend.externals.fontawesome.fontAwesomeIcon + +import csstype.* +import react.FC +import react.Props +import react.dom.html.ReactHTML.a +import react.dom.html.ReactHTML.div + +import kotlinx.js.jso + +val userRatingFc = userRating() + +/** + * Enum that contains values for the tab that is used in rating card + */ +enum class UserRatingTab { + ORGANIZATIONS, TOOLS +} + +/** + * properties for rating fc + */ +external interface UserRatingProps : Props { + /** + * string value of the selected tab: organization/tools/etc. + */ + var selectedTab: String? + + /** + * callback that will be passed into this fc from the view + */ + var updateTabState: (String) -> Unit +} + +/** + * @return functional component for the rating card + */ +fun userRating() = FC { props -> + div { + className = ClassName("col-lg-3") + div { + className = ClassName("card flex-md-row mb-1 box-shadow") + style = jso { + minHeight = 30.rem + } + + div { + className = ClassName("col") + title(" Global Rating", faTrophy) + tab(props.selectedTab, UserRatingTab.values().map { it.name }, props.updateTabState) + // FixMe: user rating here + a { + // FixMe: new view on this link + href = "" + +"View more " + fontAwesomeIcon(faArrowRight) + } + } + } + } +} diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/contests/Utils.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/contests/Utils.kt new file mode 100644 index 0000000000..2568863c0b --- /dev/null +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/contests/Utils.kt @@ -0,0 +1,86 @@ +/** + * Utility methods used for rendering of contest list view + */ + +package com.saveourtool.save.frontend.components.views.contests + +import com.saveourtool.save.frontend.externals.fontawesome.FontAwesomeIconModule +import com.saveourtool.save.frontend.externals.fontawesome.fontAwesomeIcon + +import csstype.* +import react.ChildrenBuilder +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.h4 +import react.dom.html.ReactHTML.li +import react.dom.html.ReactHTML.nav +import react.dom.html.ReactHTML.p + +import kotlinx.js.jso + +/** + * @param title + * @param icon + */ +fun ChildrenBuilder.title(title: String, icon: FontAwesomeIconModule) { + div { + className = ClassName("row") + style = jso { + justifyContent = JustifyContent.center + display = Display.flex + } + h4 { + style = jso { + color = "#5a5c69".unsafeCast() + } + fontAwesomeIcon(icon = icon) + + className = ClassName("mt-2 mb-4") + +title + } + } +} + +/** + * @param selectedTab + * @param tabsList + * @param updateTabState + */ +fun ChildrenBuilder.tab(selectedTab: String?, tabsList: List, updateTabState: (String) -> Unit) { + div { + className = ClassName("row") + style = jso { + justifyContent = JustifyContent.center + display = Display.flex + } + + nav { + className = ClassName("nav nav-tabs mb-4") + tabsList.forEachIndexed { i, value -> + li { + className = ClassName("nav-item") + val classVal = + if ((i == 0 && selectedTab == null) || selectedTab == value) { + " active font-weight-bold" + } else { + "" + } + p { + className = ClassName("nav-link $classVal text-gray-800") + onClick = { + kotlinx.js.console.log(value) + kotlinx.js.console.log(selectedTab) + if (selectedTab != value) { + updateTabState(value) + } + } + style = jso { + cursor = "pointer".unsafeCast() + } + + +value + } + } + } + } + } +} diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/usersettingsview/UserSettingsEmailMenuView.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/usersettings/UserSettingsEmailMenuView.kt similarity index 99% rename from save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/usersettingsview/UserSettingsEmailMenuView.kt rename to save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/usersettings/UserSettingsEmailMenuView.kt index 0a0acf5c50..e344ed2cb8 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/usersettingsview/UserSettingsEmailMenuView.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/usersettings/UserSettingsEmailMenuView.kt @@ -1,4 +1,4 @@ -package com.saveourtool.save.frontend.components.views.usersettingsview +package com.saveourtool.save.frontend.components.views.usersettings import com.saveourtool.save.frontend.components.basic.InputTypes import com.saveourtool.save.frontend.components.basic.cardComponent diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/usersettingsview/UserSettingsOrganizationsMenuView.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/usersettings/UserSettingsOrganizationsMenuView.kt similarity index 99% rename from save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/usersettingsview/UserSettingsOrganizationsMenuView.kt rename to save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/usersettings/UserSettingsOrganizationsMenuView.kt index cd20802269..7511db54a3 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/usersettingsview/UserSettingsOrganizationsMenuView.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/usersettings/UserSettingsOrganizationsMenuView.kt @@ -1,4 +1,4 @@ -package com.saveourtool.save.frontend.components.views.usersettingsview +package com.saveourtool.save.frontend.components.views.usersettings import com.saveourtool.save.domain.Role import com.saveourtool.save.frontend.components.basic.cardComponent diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/usersettingsview/UserSettingsProfileMenuView.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/usersettings/UserSettingsProfileMenuView.kt similarity index 99% rename from save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/usersettingsview/UserSettingsProfileMenuView.kt rename to save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/usersettings/UserSettingsProfileMenuView.kt index c14bb00fe9..fe4904bf21 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/usersettingsview/UserSettingsProfileMenuView.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/usersettings/UserSettingsProfileMenuView.kt @@ -1,4 +1,4 @@ -package com.saveourtool.save.frontend.components.views.usersettingsview +package com.saveourtool.save.frontend.components.views.usersettings import com.saveourtool.save.frontend.components.basic.InputTypes import com.saveourtool.save.frontend.components.basic.cardComponent diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/usersettingsview/UserSettingsTokenMenuView.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/usersettings/UserSettingsTokenMenuView.kt similarity index 99% rename from save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/usersettingsview/UserSettingsTokenMenuView.kt rename to save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/usersettings/UserSettingsTokenMenuView.kt index 50dba11ef5..e85288eed2 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/usersettingsview/UserSettingsTokenMenuView.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/usersettings/UserSettingsTokenMenuView.kt @@ -1,4 +1,4 @@ -package com.saveourtool.save.frontend.components.views.usersettingsview +package com.saveourtool.save.frontend.components.views.usersettings import com.saveourtool.save.frontend.components.basic.cardComponent import com.saveourtool.save.frontend.utils.apiUrl @@ -9,7 +9,6 @@ import csstype.ClassName import kotlinext.js.assign import org.w3c.fetch.Headers import react.FC -import react.dom.* import react.dom.html.ButtonType import react.dom.html.ReactHTML.button import react.dom.html.ReactHTML.div diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/usersettingsview/UserSettingsView.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/usersettings/UserSettingsView.kt similarity index 99% rename from save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/usersettingsview/UserSettingsView.kt rename to save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/usersettings/UserSettingsView.kt index b2e3dfe259..0912b83985 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/usersettingsview/UserSettingsView.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/usersettings/UserSettingsView.kt @@ -2,7 +2,7 @@ * A view with settings user */ -package com.saveourtool.save.frontend.components.views.usersettingsview +package com.saveourtool.save.frontend.components.views.usersettings import com.saveourtool.save.domain.ImageInfo import com.saveourtool.save.entities.OrganizationDto @@ -168,7 +168,7 @@ abstract class UserSettingsView : AbstractView(true) { className = ClassName("card-body") div { className = ClassName("text-sm") - menuTextAndLink("Contests", "/#/${FrontendRoutes.CONTESTS.path}", faBell) + menuTextAndLink("Contests", "/#/${FrontendRoutes.CONTESTS.path}", faCode) hrNoMargin() menuTextAndLink("List of Projects", "#/${FrontendRoutes.PROJECTS.path}", faExternalLinkAlt) hrNoMargin() @@ -349,7 +349,7 @@ class WelcomeView : AbstractView(true) { } private fun ChildrenBuilder.hrNoMargin() = - ReactHTML.hr { + hr { style = jso { marginTop = "0.0em".unsafeCast() marginBottom = "0.0em".unsafeCast() diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/welcome/pagers/AwesomeBenchmarks.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/welcome/pagers/AwesomeBenchmarks.kt index d2eaaf5b4e..914947b945 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/welcome/pagers/AwesomeBenchmarks.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/welcome/pagers/AwesomeBenchmarks.kt @@ -4,7 +4,7 @@ import com.saveourtool.save.frontend.externals.animations.* import csstype.Width import react.ChildrenBuilder -import react.dom.html.ReactHTML +import react.dom.html.ReactHTML.img import kotlinx.js.jso @@ -18,7 +18,7 @@ object AwesomeBenchmarks : WelcomePager { } private fun ChildrenBuilder.renderAnimatedPage() { - ReactHTML.img { + img { style = jso { width = "140%".unsafeCast() } diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/welcome/pagers/BobPage.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/welcome/pagers/BobPage.kt index d44aa1f29c..0790df7d18 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/welcome/pagers/BobPage.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/welcome/pagers/BobPage.kt @@ -4,7 +4,7 @@ import com.saveourtool.save.frontend.externals.animations.* import csstype.Width import react.ChildrenBuilder -import react.dom.html.ReactHTML +import react.dom.html.ReactHTML.img import kotlinx.js.jso @@ -22,7 +22,7 @@ object BobPager : WelcomePager { } private fun ChildrenBuilder.renderAnimatedPage() { - ReactHTML.img { + img { style = jso { width = "140%".unsafeCast() } diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/welcome/pagers/GeneralInfo.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/welcome/pagers/GeneralInfo.kt index b666f8f5ac..f09aaf3127 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/welcome/pagers/GeneralInfo.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/welcome/pagers/GeneralInfo.kt @@ -6,7 +6,8 @@ package com.saveourtool.save.frontend.components.views.welcome.pagers import csstype.* import react.ChildrenBuilder -import react.dom.html.ReactHTML +import react.dom.html.ReactHTML.div +import react.dom.html.ReactHTML.h1 import react.dom.html.ReactHTML.p import kotlinx.js.jso @@ -53,7 +54,7 @@ private const val CONTESTS_TEXT = """ * rendering of 4 paragraphs with info about SAVE */ fun ChildrenBuilder.renderGeneralInfoPage() { - ReactHTML.div { + div { style = jso { justifyContent = JustifyContent.center display = Display.flex @@ -63,7 +64,7 @@ fun ChildrenBuilder.renderGeneralInfoPage() { } className = ClassName("row") - ReactHTML.div { + div { style = jso { justifyContent = JustifyContent.center display = Display.flex @@ -75,7 +76,7 @@ fun ChildrenBuilder.renderGeneralInfoPage() { text("Easy CI", EASY_CI_TEXT) } - ReactHTML.div { + div { style = jso { justifyContent = JustifyContent.center display = Display.flex @@ -90,9 +91,9 @@ fun ChildrenBuilder.renderGeneralInfoPage() { } private fun ChildrenBuilder.text(title: String, textStr: String) { - ReactHTML.div { + div { className = ClassName("col-3 mx-3") - ReactHTML.h1 { + h1 { style = jso { textAlign = TextAlign.center } diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/welcome/pagers/GeneralInfoPictures.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/welcome/pagers/GeneralInfoPictures.kt index 6b82d22361..c57e948159 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/welcome/pagers/GeneralInfoPictures.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/welcome/pagers/GeneralInfoPictures.kt @@ -9,8 +9,8 @@ import com.saveourtool.save.frontend.externals.animations.* import csstype.Color import csstype.Width import react.ChildrenBuilder -import react.dom.html.ReactHTML import react.dom.html.ReactHTML.h1 +import react.dom.html.ReactHTML.img import kotlinx.js.jso @@ -30,7 +30,7 @@ object GeneralInfoFirstPicture : WelcomePager { } +"Easy to start" } - ReactHTML.img { + img { style = jso { width = "65%".unsafeCast() } @@ -55,7 +55,7 @@ object GeneralInfoSecondPicture : WelcomePager { } +"User-friendly dashboards" } - ReactHTML.img { + img { style = jso { width = "130%".unsafeCast() } @@ -80,7 +80,7 @@ object GeneralInfoThirdPicture : WelcomePager { } +"Statistics for your tool" } - ReactHTML.img { + img { style = jso { width = "65%".unsafeCast() } @@ -105,7 +105,7 @@ object GeneralInfoFourthPicture : WelcomePager { } +"Build a team" } - ReactHTML.img { + img { style = jso { width = "135%".unsafeCast() } diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/welcome/pagers/HighLevelSave.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/welcome/pagers/HighLevelSave.kt index 2d3b6c2dd0..228eeecdb5 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/welcome/pagers/HighLevelSave.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/components/views/welcome/pagers/HighLevelSave.kt @@ -2,7 +2,7 @@ package com.saveourtool.save.frontend.components.views.welcome.pagers import com.saveourtool.save.frontend.externals.animations.* import react.ChildrenBuilder -import react.dom.html.ReactHTML +import react.dom.html.ReactHTML.img @Suppress("CUSTOM_GETTERS_SETTERS") object HighLevelSave : WelcomePager { @@ -14,7 +14,7 @@ object HighLevelSave : WelcomePager { } private fun ChildrenBuilder.renderAnimatedPage() { - ReactHTML.img { + img { src = "img/save_hl.png" } } diff --git a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/externals/fontawesome/Icons.kt b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/externals/fontawesome/Icons.kt index 1bafe90874..4ad3251bce 100644 --- a/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/externals/fontawesome/Icons.kt +++ b/save-frontend/src/main/kotlin/com/saveourtool/save/frontend/externals/fontawesome/Icons.kt @@ -159,3 +159,11 @@ external val faChevronDown: FontAwesomeIconModule @JsModule("@fortawesome/free-solid-svg-icons/faCheckDouble") @JsNonModule external val faCheckDouble: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faTrophy") +@JsNonModule +external val faTrophy: FontAwesomeIconModule + +@JsModule("@fortawesome/free-solid-svg-icons/faCode") +@JsNonModule +external val faCode: FontAwesomeIconModule diff --git a/save-frontend/src/main/resources/img/green_square.png b/save-frontend/src/main/resources/img/green_square.png deleted file mode 100644 index 88ba74bf3f39e2c0f2ae9ee88128b710ef354df5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 30895 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 \ No newline at end of file diff --git a/save-frontend/src/main/resources/img/undraw_exciting_news_re_y1iw.svg b/save-frontend/src/main/resources/img/undraw_exciting_news_re_y1iw.svg new file mode 100644 index 0000000000..c1eb65ed32 --- /dev/null +++ b/save-frontend/src/main/resources/img/undraw_exciting_news_re_y1iw.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/save-frontend/src/main/resources/img/undraw_for_review_eqxk.svg b/save-frontend/src/main/resources/img/undraw_for_review_eqxk.svg new file mode 100644 index 0000000000..e24bd3e5cf --- /dev/null +++ b/save-frontend/src/main/resources/img/undraw_for_review_eqxk.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/save-frontend/src/main/resources/img/undraw_mailbox_re_dvds.svg b/save-frontend/src/main/resources/img/undraw_mailbox_re_dvds.svg new file mode 100644 index 0000000000..892d73f18c --- /dev/null +++ b/save-frontend/src/main/resources/img/undraw_mailbox_re_dvds.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/save-frontend/src/main/resources/img/undraw_news_re_6uub.svg b/save-frontend/src/main/resources/img/undraw_news_re_6uub.svg new file mode 100644 index 0000000000..7ea1aa0347 --- /dev/null +++ b/save-frontend/src/main/resources/img/undraw_news_re_6uub.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/save-frontend/src/main/resources/img/undraw_notify_re_65on.svg b/save-frontend/src/main/resources/img/undraw_notify_re_65on.svg new file mode 100644 index 0000000000..83c85c8847 --- /dev/null +++ b/save-frontend/src/main/resources/img/undraw_notify_re_65on.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/save-frontend/src/main/resources/img/undraw_selecting_team_re_ndkb.svg b/save-frontend/src/main/resources/img/undraw_selecting_team_re_ndkb.svg new file mode 100644 index 0000000000..ab662b3c4f --- /dev/null +++ b/save-frontend/src/main/resources/img/undraw_selecting_team_re_ndkb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/save-frontend/src/main/resources/img/user.svg b/save-frontend/src/main/resources/img/user.svg deleted file mode 100644 index c650a40caf..0000000000 --- a/save-frontend/src/main/resources/img/user.svg +++ /dev/null @@ -1,270 +0,0 @@ - - - - diff --git a/save-frontend/src/main/resources/scss/_buttons.scss b/save-frontend/src/main/resources/scss/_buttons.scss index 599a4b2a23..f66189f50c 100644 --- a/save-frontend/src/main/resources/scss/_buttons.scss +++ b/save-frontend/src/main/resources/scss/_buttons.scss @@ -2,12 +2,6 @@ z-index: 0 } -.btn-primary { - color: #fff; - background-color: #53669c; - border-color: #53669c; -} - .btn-circle { border-radius: 100%; height: 2.5rem; From 6c66b1b254a070833f87c47aa8263ece5243ca39 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 22 Aug 2022 14:19:39 +0000 Subject: [PATCH 7/7] Update backend-api-docs.json --- save-backend/backend-api-docs.json | 266 ++++++++++++++++++++++++++++- 1 file changed, 265 insertions(+), 1 deletion(-) diff --git a/save-backend/backend-api-docs.json b/save-backend/backend-api-docs.json index a130082b8b..14f0560af1 100644 --- a/save-backend/backend-api-docs.json +++ b/save-backend/backend-api-docs.json @@ -5389,6 +5389,246 @@ ] } }, + "/api/v1/contests/{contestName}/my-results": { + "get": { + "tags": [ + "contests" + ], + "summary": "Get your best results in contest.", + "description": "Get list of best results of your projects in a given contest.", + "operationId": "getBestResultsInUserProjects", + "parameters": [ + { + "name": "contestName", + "in": "path", + "description": "name of a contest", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "X-Authorization-Source", + "in": "header", + "required": true, + "example": "basic" + } + ], + "responses": { + "200": { + "description": "Successfully fetched your best results.", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContestResult" + } + } + } + } + }, + "404": { + "description": "Either given project or given contest was not found.", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContestResult" + } + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContestResult" + } + } + } + } + } + }, + "security": [ + { + "basic": [] + } + ] + } + }, + "/api/v1/contests/{contestName}/executions/{organizationName}/{projectName}": { + "get": { + "tags": [ + "contests" + ], + "summary": "Get project executions in contest.", + "description": "Get list of execution of a project with given name in contest with given name.", + "operationId": "getContestExecutionsForProject", + "parameters": [ + { + "name": "contestName", + "in": "path", + "description": "name of an organization", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "organizationName", + "in": "path", + "description": "name of an organization", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "projectName", + "in": "path", + "description": "name of a project", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "404": { + "description": "Either contest is not found or project is not found or execution is not found.", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExecutionDto" + } + } + } + } + }, + "200": { + "description": "Successfully fetched latest project execution in contest.", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExecutionDto" + } + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExecutionDto" + } + } + } + } + } + }, + "security": [ + { + "basic": [] + } + ] + } + }, + "/api/v1/contests/{contestName}/executions/{organizationName}/{projectName}/latest": { + "get": { + "tags": [ + "contests" + ], + "summary": "Get latest project execution in contest.", + "description": "Get latest execution of a project with given name in contest with given name.", + "operationId": "getLatestExecutionOfProjectInContest", + "parameters": [ + { + "name": "organizationName", + "in": "path", + "description": "name of an organization", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "projectName", + "in": "path", + "description": "name of a project", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "contestName", + "in": "path", + "description": "name of an organization", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "X-Authorization-Source", + "in": "header", + "required": true, + "example": "basic" + } + ], + "responses": { + "404": { + "description": "Either contest is not found or project is not found or execution is not found.", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ExecutionDto" + } + } + } + }, + "200": { + "description": "Successfully fetched latest project execution in contest.", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ExecutionDto" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ExecutionDto" + } + } + } + } + }, + "security": [ + { + "basic": [] + } + ] + } + }, "/api/v1/contests/{contestName}/enroll": { "get": { "tags": [ @@ -7219,6 +7459,7 @@ "type": { "type": "string", "enum": [ + "CONTEST", "GIT", "STANDARD" ] @@ -7396,6 +7637,7 @@ "type": { "type": "string", "enum": [ + "CONTEST", "GIT", "STANDARD" ] @@ -7452,8 +7694,11 @@ "ContestResult": { "required": [ "contestName", + "hasFailedTest", "organizationName", - "projectName" + "projectName", + "sdk", + "submissionStatus" ], "type": "object", "properties": { @@ -7469,6 +7714,25 @@ "score": { "type": "number", "format": "double" + }, + "submissionTime": { + "type": "string", + "format": "date-time" + }, + "submissionStatus": { + "type": "string", + "enum": [ + "ERROR", + "FINISHED", + "PENDING", + "RUNNING" + ] + }, + "sdk": { + "type": "string" + }, + "hasFailedTest": { + "type": "boolean" } } },