diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bac8fa46..c645fbb5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,7 @@ ktor = "3.2.1" logback = "1.5.18" modelix-core = "15.4.2" modelix-mps-plugins = "0.11.1" +modelix-openapi = "1.3.0" [libraries] auth0-jwt = { group = "com.auth0", name = "java-jwt", version = "4.5.0" } @@ -36,9 +37,11 @@ ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-conte ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" } ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" } ktor-server-auth = { group = "io.ktor", name = "ktor-server-auth", version.ref = "ktor" } +ktor-server-call-logging = { group = "io.ktor", name = "ktor-server-call-logging", version.ref = "ktor" } ktor-server-content-negotiation = { group = "io.ktor", name = "ktor-server-content-negotiation", version.ref = "ktor" } ktor-server-core = { group = "io.ktor", name = "ktor-server-core", version.ref = "ktor" } ktor-server-cors = { group = "io.ktor", name = "ktor-server-cors", version.ref = "ktor" } +ktor-server-data-conversion = { group = "io.ktor", name = "ktor-server-data-conversion", version.ref = "ktor" } ktor-server-html-builder = { group = "io.ktor", name = "ktor-server-html-builder", version.ref = "ktor" } ktor-server-netty = { group = "io.ktor", name = "ktor-server-netty", version.ref = "ktor" } ktor-server-status-pages = { group = "io.ktor", name = "ktor-server-status-pages", version.ref = "ktor" } @@ -54,6 +57,7 @@ zt-zip = { group = "org.zeroturnaround", name = "zt-zip", version = "1.17" } modelix-syncPlugin3 = { group = "org.modelix.mps", name = "mps-sync-plugin3", version.ref = "modelix-core" } modelix-mpsPlugins-generator = { group = "org.modelix.mps", name = "generator-execution-plugin", version.ref = "modelix-mps-plugins" } modelix-mpsPlugins-diff = { group = "org.modelix.mps", name = "diff-plugin", version.ref = "modelix-mps-plugins" } +modelix-api-server-stubs = { group = "org.modelix", name = "api-server-stubs-ktor", version.ref = "modelix-openapi" } [bundles] ktor-client = [ @@ -85,3 +89,4 @@ kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } shadow = { id = "com.github.johnrengelman.shadow", version = "8.1.1" } jib = { id = "com.google.cloud.tools.jib", version = "3.4.5" } +openapi-generator = {id = "org.openapi.generator", version = "7.12.0"} diff --git a/workspace-client-plugin/build.gradle.kts b/workspace-client-plugin/build.gradle.kts index b86a7738..f8a19c2d 100644 --- a/workspace-client-plugin/build.gradle.kts +++ b/workspace-client-plugin/build.gradle.kts @@ -20,7 +20,7 @@ plugins { group = "org.modelix.mps" kotlin { - jvmToolchain(17) + jvmToolchain(11) } dependencies { diff --git a/workspace-client-plugin/src/main/kotlin/org/modelix/workspace/client/WorkspaceClientStartupActivity.kt b/workspace-client-plugin/src/main/kotlin/org/modelix/workspace/client/WorkspaceClientStartupActivity.kt index 8e5cc74a..0ffc1e46 100644 --- a/workspace-client-plugin/src/main/kotlin/org/modelix/workspace/client/WorkspaceClientStartupActivity.kt +++ b/workspace-client-plugin/src/main/kotlin/org/modelix/workspace/client/WorkspaceClientStartupActivity.kt @@ -3,12 +3,9 @@ package org.modelix.workspace.client import com.intellij.openapi.project.DumbService import com.intellij.openapi.project.Project import com.intellij.openapi.startup.StartupActivity -import io.github.oshai.kotlinlogging.KotlinLogging import org.modelix.model.lazy.RepositoryId import org.modelix.mps.sync3.IModelSyncService -private val LOG = KotlinLogging.logger { } - class WorkspaceClientStartupActivity : StartupActivity { override fun runActivity(project: Project) { println("### Workspace client loaded for project: ${project.name}") @@ -18,16 +15,21 @@ class WorkspaceClientStartupActivity : StartupActivity { val syncEnabled: Boolean = getEnvOrLog("WORKSPACE_MODEL_SYNC_ENABLED") == "true" if (syncEnabled) { + println("model sync is enabled") val modelUri: String? = getEnvOrLog("MODEL_URI") - val repoId: String? = getEnvOrLog("REPOSITORY_ID") + val repoId: RepositoryId? = getEnvOrLog("REPOSITORY_ID")?.let { RepositoryId(it) } val branchName: String? = getEnvOrLog("REPOSITORY_BRANCH") val jwt: String? = getEnvOrLog("INITIAL_JWT_TOKEN") + println("model server: $modelUri") + println("repository: $repoId") + println("branch: $branchName") + println("JWT: $jwt") if (modelUri != null && repoId != null) { - val connection = IModelSyncService.getInstance(project).addServer(modelUri) + val connection = IModelSyncService.getInstance(project).addServer(modelUri, repositoryId = repoId) if (jwt != null) { connection.setTokenProvider { jwt } } - connection.bind(RepositoryId(repoId).getBranchReference(branchName)) + connection.bind(repoId.getBranchReference(branchName)) } } } @@ -36,7 +38,7 @@ class WorkspaceClientStartupActivity : StartupActivity { private fun getEnvOrLog(name: String): String? { val value = System.getenv(name) if (value == null) { - LOG.warn { "Environment variable $name is not set." } + println("Environment variable $name is not set.") } return value } diff --git a/workspace-job/src/main/kotlin/org/modelix/workspace/job/Main.kt b/workspace-job/src/main/kotlin/org/modelix/workspace/job/Main.kt index 30fa78bf..ea51d4a6 100644 --- a/workspace-job/src/main/kotlin/org/modelix/workspace/job/Main.kt +++ b/workspace-job/src/main/kotlin/org/modelix/workspace/job/Main.kt @@ -27,20 +27,17 @@ import io.ktor.http.appendPathSegments import io.ktor.http.takeFrom import io.ktor.serialization.kotlinx.json.json import kotlinx.coroutines.runBlocking -import org.modelix.workspaces.Workspace import org.modelix.workspaces.WorkspaceBuildStatus -import org.modelix.workspaces.WorkspaceHash -import org.modelix.workspaces.withHash +import org.modelix.workspaces.WorkspaceConfigForBuild +import java.util.UUID import kotlin.time.Duration.Companion.minutes private val LOG = mu.KotlinLogging.logger("main") fun main(args: Array) { try { - val workspaceId = propertyOrEnv("modelix.workspace.id") - ?: throw RuntimeException("modelix.workspace.id not specified") - val workspaceHash = propertyOrEnv("modelix.workspace.hash")?.let { WorkspaceHash(it) } - ?: throw RuntimeException("modelix.workspace.id not specified") + val buildTaskId = propertyOrEnv("modelix.workspace.task.id")?.let { UUID.fromString(it) } + ?: throw RuntimeException("modelix.workspace.task.id not specified") var serverUrl = propertyOrEnv("modelix.workspace.server") ?: "http://workspace-manager:28104/" serverUrl = serverUrl.trimEnd('/') @@ -61,14 +58,14 @@ fun main(args: Array) { runBlocking { printNewJobStatus(WorkspaceBuildStatus.Running) - val workspace: Workspace = httpClient.get { + val workspace: WorkspaceConfigForBuild = httpClient.get { url { takeFrom(serverUrl) - appendPathSegments(workspaceHash.hash) + appendPathSegments("modelix", "workspaces", "tasks", buildTaskId.toString(), "config") parameter("decryptCredentials", "true") } }.body() - val job = WorkspaceBuildJob(workspace.withHash(workspaceHash), httpClient, serverUrl) + val job = WorkspaceBuildJob(workspace, httpClient, serverUrl) job.buildWorkspace() // job.status = if (job.status == WorkspaceBuildStatus.FailedBuild) WorkspaceBuildStatus.ZipSuccessful else WorkspaceBuildStatus.AllSuccessful } diff --git a/workspace-job/src/main/kotlin/org/modelix/workspace/job/MavenDownloader.kt b/workspace-job/src/main/kotlin/org/modelix/workspace/job/MavenDownloader.kt index 433720f0..f8c04a35 100644 --- a/workspace-job/src/main/kotlin/org/modelix/workspace/job/MavenDownloader.kt +++ b/workspace-job/src/main/kotlin/org/modelix/workspace/job/MavenDownloader.kt @@ -17,12 +17,12 @@ import org.apache.commons.io.FileUtils import org.apache.maven.shared.invoker.DefaultInvocationRequest import org.apache.maven.shared.invoker.DefaultInvoker import org.apache.maven.shared.invoker.InvocationOutputHandler -import org.modelix.workspaces.Workspace +import org.modelix.workspaces.WorkspaceConfigForBuild import org.zeroturnaround.zip.ZipUtil import java.io.File -import java.util.* +import java.util.Properties -class MavenDownloader(val workspace: Workspace, val workspaceDir: File) { +class MavenDownloader(val workspace: WorkspaceConfigForBuild, val workspaceDir: File) { fun downloadAndCopyFromMaven(coordinates: String, outputHandler: ((String) -> Unit)? = null): File { if (workspace.mavenRepositories.isNotEmpty()) { diff --git a/workspace-job/src/main/kotlin/org/modelix/workspace/job/WorkspaceBuildJob.kt b/workspace-job/src/main/kotlin/org/modelix/workspace/job/WorkspaceBuildJob.kt index 54311a2d..436903c8 100644 --- a/workspace-job/src/main/kotlin/org/modelix/workspace/job/WorkspaceBuildJob.kt +++ b/workspace-job/src/main/kotlin/org/modelix/workspace/job/WorkspaceBuildJob.kt @@ -15,16 +15,12 @@ package org.modelix.workspace.job import io.ktor.client.HttpClient -import io.ktor.client.plugins.timeout import io.ktor.client.request.get import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.bodyAsChannel import io.ktor.http.HttpStatusCode -import io.ktor.http.appendPathSegments -import io.ktor.http.takeFrom import io.ktor.util.cio.writeChannel import io.ktor.utils.io.copyTo -import io.ktor.utils.io.jvm.javaio.toInputStream import org.modelix.buildtools.BuildScriptGenerator import org.modelix.buildtools.DependencyGraph import org.modelix.buildtools.FoundModule @@ -39,13 +35,11 @@ import org.modelix.buildtools.PublicationDependencyGraph import org.modelix.buildtools.SourceModuleOwner import org.modelix.buildtools.newChild import org.modelix.buildtools.xmlToString -import org.modelix.workspaces.UploadId -import org.modelix.workspaces.Workspace -import org.modelix.workspaces.WorkspaceAndHash +import org.modelix.workspaces.InternalWorkspaceConfig import org.modelix.workspaces.WorkspaceBuildStatus +import org.modelix.workspaces.WorkspaceConfigForBuild import org.modelix.workspaces.WorkspaceProgressItems import org.w3c.dom.Document -import org.zeroturnaround.zip.ZipUtil import java.io.File import java.nio.file.Path import javax.xml.parsers.DocumentBuilderFactory @@ -54,9 +48,8 @@ import kotlin.io.path.deleteExisting import kotlin.io.path.isRegularFile import kotlin.io.path.name import kotlin.io.path.walk -import kotlin.time.Duration.Companion.minutes -class WorkspaceBuildJob(val workspace: WorkspaceAndHash, val httpClient: HttpClient, val serverUrl: String) { +class WorkspaceBuildJob(val workspace: WorkspaceConfigForBuild, val httpClient: HttpClient, val serverUrl: String) { private val workspaceDir = File(".").canonicalFile val progressItems = WorkspaceProgressItems() @@ -71,21 +64,22 @@ class WorkspaceBuildJob(val workspace: WorkspaceAndHash, val httpClient: HttpCli } private suspend fun copyUploads(): List { - return workspace.uploads.map { UploadId(it) }.map { uploadId -> - LOG.info { "Copying upload $uploadId" } - val uploadFolder = workspaceDir.resolve("uploads/${uploadId.id}") - val data = httpClient.get { - url { - takeFrom(serverUrl) - appendPathSegments("uploads", uploadId.id) - } - timeout { - requestTimeoutMillis = 2.minutes.inWholeMilliseconds - } - }.bodyAsChannel() - ZipUtil.unpack(data.toInputStream(), uploadFolder) - uploadFolder - } + return emptyList() +// return workspace.uploads.map { UploadId(it) }.map { uploadId -> +// LOG.info { "Copying upload $uploadId" } +// val uploadFolder = workspaceDir.resolve("uploads/${uploadId.id}") +// val data = httpClient.get { +// url { +// takeFrom(serverUrl) +// appendPathSegments("uploads", uploadId.id) +// } +// timeout { +// requestTimeoutMillis = 2.minutes.inWholeMilliseconds +// } +// }.bodyAsChannel() +// ZipUtil.unpack(data.toInputStream(), uploadFolder) +// uploadFolder +// } } private fun cloneGitRepositories(): List { @@ -98,9 +92,9 @@ class WorkspaceBuildJob(val workspace: WorkspaceAndHash, val httpClient: HttpCli } private fun copyMavenDependencies(): List { - return workspace.mavenDependencies.map { mavenDep -> + return workspace.mavenArtifacts.map { mavenDep -> LOG.info { "Resolving $mavenDep" } - MavenDownloader(workspace.workspace, workspaceDir).downloadAndCopyFromMaven(mavenDep) { println(it) } + MavenDownloader(workspace, workspaceDir).downloadAndCopyFromMaven(mavenDep) { println(it) } } } @@ -126,7 +120,7 @@ class WorkspaceBuildJob(val workspace: WorkspaceAndHash, val httpClient: HttpCli buildScriptGenerator = BuildScriptGenerator( modulesMiner, ignoredModules = workspace.ignoredModules.map { ModuleId(it) }.toSet(), - additionalGenerationDependencies = workspace.workspace.additionalGenerationDependenciesAsMap(), + additionalGenerationDependencies = workspace.additionalGenerationDependenciesAsMap(), ) runSafely { modulesXml = xmlToString(buildModulesXml(buildScriptGenerator.modulesMiner.getModules())) @@ -141,7 +135,7 @@ class WorkspaceBuildJob(val workspace: WorkspaceAndHash, val httpClient: HttpCli progressItems.build.deleteUnusedModules.execute { // to reduce the required memory include only those modules in the zip that are actually used val resolver = ModuleResolver(modulesMiner.getModules(), workspace.ignoredModules.map { ModuleId(it) }.toSet(), true) - val graph = PublicationDependencyGraph(resolver, workspace.workspace.additionalGenerationDependenciesAsMap()) + val graph = PublicationDependencyGraph(resolver, workspace.additionalGenerationDependenciesAsMap()) graph.load(modulesMiner.getModules().getModules().values) val sourceModules: Set = modulesMiner.getModules().getModules() .filter { it.value.owner is SourceModuleOwner }.keys - @@ -239,7 +233,7 @@ class WorkspaceBuildJob(val workspace: WorkspaceAndHash, val httpClient: HttpCli } } - private fun Workspace.additionalGenerationDependenciesAsMap(): Map> { + private fun InternalWorkspaceConfig.additionalGenerationDependenciesAsMap(): Map> { return additionalGenerationDependencies .groupBy { ModuleId(it.from) } .mapValues { it.value.map { ModuleId(it.to) }.toSet() } @@ -259,3 +253,9 @@ suspend fun HttpClient.downloadFile(file: File, url: String) { data.copyTo(file.writeChannel()) } } + +fun WorkspaceConfigForBuild.additionalGenerationDependenciesAsMap() = additionalGenerationDependencies + .map { ModuleId(it.first) to ModuleId(it.second) } + .groupBy { it.first } + .mapValues { it.value.map { it.second }.toSet() } + .toMap() diff --git a/workspace-manager/build.gradle.kts b/workspace-manager/build.gradle.kts index e94f47ac..90b9826a 100644 --- a/workspace-manager/build.gradle.kts +++ b/workspace-manager/build.gradle.kts @@ -42,6 +42,7 @@ dependencies { implementation(libs.logback.classic) implementation(libs.maven.invoker) implementation(libs.modelix.authorization) + implementation(libs.ktor.server.call.logging) implementation(libs.modelix.model.client) implementation(libs.modelix.model.server) { isTransitive = false @@ -49,6 +50,7 @@ dependencies { implementation(libs.zt.zip) implementation(project(":gitui")) implementation(project(":workspaces")) + implementation(libs.modelix.api.server.stubs) mpsPlugins(libs.bundles.modelix.mpsPlugins.all) mpsPlugins(project(":workspace-client-plugin", configuration = "archives")) runtimeOnly(libs.slf4j.simple) diff --git a/workspace-manager/src/main/kotlin/org/modelix/instancesmanager/AdminModule.kt b/workspace-manager/src/main/kotlin/org/modelix/instancesmanager/AdminModule.kt deleted file mode 100644 index f353024d..00000000 --- a/workspace-manager/src/main/kotlin/org/modelix/instancesmanager/AdminModule.kt +++ /dev/null @@ -1,262 +0,0 @@ -/* - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.modelix.instancesmanager - -import io.ktor.http.ContentType -import io.ktor.http.HttpStatusCode -import io.ktor.server.html.respondHtml -import io.ktor.server.http.content.resources -import io.ktor.server.request.receiveParameters -import io.ktor.server.response.respondRedirect -import io.ktor.server.response.respondText -import io.ktor.server.routing.Route -import io.ktor.server.routing.get -import io.ktor.server.routing.post -import io.kubernetes.client.openapi.models.CoreV1Event -import kotlinx.html.TR -import kotlinx.html.a -import kotlinx.html.body -import kotlinx.html.br -import kotlinx.html.div -import kotlinx.html.h1 -import kotlinx.html.head -import kotlinx.html.hiddenInput -import kotlinx.html.img -import kotlinx.html.link -import kotlinx.html.postForm -import kotlinx.html.span -import kotlinx.html.style -import kotlinx.html.submitInput -import kotlinx.html.table -import kotlinx.html.td -import kotlinx.html.th -import kotlinx.html.thead -import kotlinx.html.title -import kotlinx.html.tr -import kotlinx.html.unsafe -import org.json.JSONArray -import org.modelix.workspaces.WorkspaceHash -import java.time.OffsetDateTime -import java.time.format.DateTimeFormatter - -fun Route.adminModule(manager: DeploymentManager) { - get("/") { - call.respondHtml(HttpStatusCode.OK) { - head { - title("Manage Workspace Instances") - link("../../public/modelix-base.css", rel = "stylesheet") - style { - unsafe { - +""" - tbody tr { - border: 1px solid #dddddd; - } - tbody tr:nth-of-type(even) { - background: none; - } - """.trimIndent() - } - } - } - body { - style = "display: flex; flex-direction: column; align-items: center;" - div { - style = "display: flex; justify-content: center;" - a("../../") { - style = "background-color: #343434; border-radius: 15px; padding: 10px;" - img("Modelix Logo") { - src = "../../public/logo-dark.svg" - width = "70px" - height = "70px" - } - } - } - div { - style = "display: flex; flex-direction: column; justify-content: center;" - h1 { +"Workspace Instances" } - table { - thead { - tr { - th { - +"Workspace Name" - br { } - +"Workspace ID" - br { } - +"Workspace Hash" - } - th { - +"Max. Unassigned" - br {} - +"Instances" - } - th { +"Instance ID" } - th { +"User" } - } - } - for (assignment in manager.getAssignments().sortedBy { it.workspace.hash().hash }.sortedBy { it.workspace.id }) { - val assignmentCells: TR.() -> Unit = { - td { - rowSpan = assignment.instances.size.coerceAtLeast(1).toString() - if (!assignment.isLatest) style = "color: #aaa" - span { - style = "font-weight: bold;" - +(assignment.workspace.name ?: "") - } - br {} - span { - +assignment.workspace.id - } - br {} - span { - style = "color: #888;" - +assignment.workspace.hash().hash - } - } - td { - rowSpan = assignment.instances.size.coerceAtLeast(1).toString() - postForm("change-unassigned") { - hiddenInput { - name = "workspaceHash" - value = assignment.workspace.hash().hash - } - for (newValue in 0..5) { - submitInput(classes = "btn") { - style = "margin: 1px; padding: 4px 8px;" - disabled = newValue == assignment.unassignedInstances - name = "numberOfUnassigned" - value = "$newValue" - } - } - } - } - } - - if (assignment.instances.isEmpty()) { - tr { - assignmentCells() - } - } else { - for (instanceAndIndex in assignment.instances.withIndex()) { - val instance = instanceAndIndex.value - tr { - if (instanceAndIndex.index == 0) assignmentCells() - td { - if (instance.disabled) style = "color: #aaa" - a("log/${instance.id}/", "_blank") { - +instance.id.name - } - } - td { - if (instance.disabled) style = "color: #aaa" - +when (val owner = instance.owner) { - is SharedInstanceOwner -> "[${owner.name}]" - is UnassignedInstanceOwner -> "" - is UserInstanceOwner -> owner.userId - } - } - td { - if (instance.disabled) { - postForm("enable-instance") { - hiddenInput { - name = "instanceId" - value = instance.id.name - } - submitInput(classes = "btn") { - value = "Enable" - } - } - } else { - postForm("disable-instance") { - hiddenInput { - name = "instanceId" - value = instance.id.name - } - submitInput(classes = "btn") { - value = "Disable" - } - } - } - } - } - } - } - } - } - } - } - } - } - - post("disable-instance") { - val instanceId = InstanceName(call.receiveParameters()["instanceId"]!!) - manager.disableInstance(instanceId) - call.respondRedirect(".") - } - - post("enable-instance") { - val instanceId = InstanceName(call.receiveParameters()["instanceId"]!!) - manager.enableInstance(instanceId) - call.respondRedirect(".") - } - - post("change-unassigned") { - val parameters = call.receiveParameters() - val workspaceHash = parameters["workspaceHash"]!! - val numberOfUnassigned = parameters["numberOfUnassigned"]!! - manager.changeNumberOfAssigned(WorkspaceHash(workspaceHash), numberOfUnassigned.toInt().coerceIn(0..100)) - call.respondRedirect(".") - } - - get("log/{instanceId}/content") { - val instanceId = InstanceName(call.parameters["instanceId"]!!) - val log = manager.getPodLogs(instanceId) ?: "Instance $instanceId not found" - call.respondText(log, ContentType.Text.Plain, HttpStatusCode.OK) - } - - get("log/{instanceId}/events") { - val instanceId = call.parameters["instanceId"]!! - val events = manager.getEvents(instanceId) - val eventTime: (CoreV1Event) -> OffsetDateTime? = { - listOfNotNull( - it.eventTime, - it.lastTimestamp, - it.firstTimestamp, - ).firstOrNull() - } - - val json = JSONArray() - for (event in events) { - val row = JSONArray() - // Format time as HH:mm:ss - row.put(eventTime(event)?.format(DateTimeFormatter.ISO_LOCAL_TIME) ?: "---") - row.put(event.type) - row.put(event.reason) - row.put(event.message) - json.put(row) - } - call.respondText(json.toString(), ContentType.Application.Json, HttpStatusCode.OK) - } - - get("log/{instanceId}/") { - val resourceName = "/static/log/xxx/log.html" - val resource = this.javaClass.getResource(resourceName) - if (resource == null) { - call.respondText("$resourceName not found", ContentType.Text.Plain, HttpStatusCode.NotFound) - return@get - } - call.respondText(resource.readText(), ContentType.Text.Html) - } - - resources("/static") -} diff --git a/workspace-manager/src/main/kotlin/org/modelix/instancesmanager/AssignmentData.kt b/workspace-manager/src/main/kotlin/org/modelix/instancesmanager/AssignmentData.kt deleted file mode 100644 index afc156a6..00000000 --- a/workspace-manager/src/main/kotlin/org/modelix/instancesmanager/AssignmentData.kt +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.modelix.instancesmanager - -import org.modelix.workspaces.WorkspaceAndHash - -class AssignmentData(val workspace: WorkspaceAndHash, val unassignedInstances: Int, val instances: List, val isLatest: Boolean) diff --git a/workspace-manager/src/main/kotlin/org/modelix/instancesmanager/DeploymentManager.kt b/workspace-manager/src/main/kotlin/org/modelix/instancesmanager/DeploymentManager.kt deleted file mode 100644 index e8150039..00000000 --- a/workspace-manager/src/main/kotlin/org/modelix/instancesmanager/DeploymentManager.kt +++ /dev/null @@ -1,633 +0,0 @@ -/* - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.modelix.instancesmanager - -import com.google.common.cache.CacheBuilder -import io.ktor.server.auth.jwt.JWTPrincipal -import io.kubernetes.client.custom.Quantity -import io.kubernetes.client.openapi.ApiException -import io.kubernetes.client.openapi.Configuration -import io.kubernetes.client.openapi.apis.AppsV1Api -import io.kubernetes.client.openapi.apis.CoreV1Api -import io.kubernetes.client.openapi.models.CoreV1Event -import io.kubernetes.client.openapi.models.CoreV1EventList -import io.kubernetes.client.openapi.models.V1Deployment -import io.kubernetes.client.openapi.models.V1EnvVar -import io.kubernetes.client.openapi.models.V1Pod -import io.kubernetes.client.openapi.models.V1Service -import io.kubernetes.client.openapi.models.V1ServicePort -import io.kubernetes.client.util.ClientBuilder -import io.kubernetes.client.util.Yaml -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import org.apache.commons.collections4.map.LRUMap -import org.eclipse.jetty.server.Request -import org.modelix.authorization.ModelixJWTUtil -import org.modelix.authorization.getUserName -import org.modelix.authorization.permissions.AccessControlData -import org.modelix.authorization.permissions.PermissionParts -import org.modelix.model.server.ModelServerPermissionSchema -import org.modelix.workspace.manager.WorkspaceManager -import org.modelix.workspaces.WorkspaceAndHash -import org.modelix.workspaces.WorkspaceBuildStatus -import org.modelix.workspaces.WorkspaceHash -import org.modelix.workspaces.WorkspacesPermissionSchema -import org.modelix.workspaces.withHash -import java.io.File -import java.util.Collections -import java.util.Locale -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicLong -import java.util.function.Consumer -import java.util.regex.Pattern -import javax.servlet.http.HttpServletRequest -import kotlin.time.Duration.Companion.seconds - -const val TIMEOUT_SECONDS = 10 - -class DeploymentManager(val workspaceManager: WorkspaceManager) { - private val coroutineScope = CoroutineScope(Dispatchers.IO) - private val cleanupJob: Job - private val managerId = java.lang.Long.toHexString(System.currentTimeMillis() / 1000) - private val deploymentSuffixSequence = AtomicLong(0xf) - private val assignments = Collections.synchronizedMap(HashMap()) - private val disabledInstances = HashSet() - private val dirty = AtomicBoolean(true) - private val jwtUtil = ModelixJWTUtil().also { it.loadKeysFromEnvironment() } - private val userTokens: MutableMap = Collections.synchronizedMap(HashMap()) - private val reconcileLock = Any() - private val indexWasReady: MutableSet = Collections.synchronizedSet(HashSet()) - - init { - Configuration.setDefaultApiClient(ClientBuilder.cluster().build()) - reconcileDeployments() - cleanupJob = coroutineScope.launch { - while (true) { - try { - createAssignmentsForAllWorkspaces() - reconcileDeployments() - } catch (ex: Exception) { - LOG.error("", ex) - } - delay(10.seconds) - } - } - } - - private fun getAssignments(workspace: WorkspaceAndHash): Assignments { - return assignments.getOrPut(workspace.hash()) { Assignments(workspace) } - } - - private fun getWorkspaceByHash(hash: WorkspaceHash): WorkspaceAndHash { - return requireNotNull(workspaceManager.getWorkspaceForHash(hash)) { - "Workspace not found: $hash" - } - } - - private fun getAccessControlData(): AccessControlData { - return workspaceManager.accessControlPersistence.read() - } - - private val statusCache = CacheBuilder.newBuilder() - .expireAfterWrite(1, TimeUnit.SECONDS) - .build() - fun getWorkspaceStatus(workspaceHash: WorkspaceHash): WorkspaceBuildStatus { - return workspaceManager.buildWorkspaceDownloadFileAsync(workspaceHash).status - } - - fun getWorkspaceBuildLog(workspaceHash: WorkspaceHash): String { - return workspaceManager.buildWorkspaceDownloadFileAsync(workspaceHash).getLog() - } - - fun getAssignments(): List { - val latestWorkspaces = workspaceManager.getAllWorkspaces() - var hash2workspace: Map = - latestWorkspaces.map { it.withHash() }.associateBy { it.hash() } - val latestWorkspaceHashes = hash2workspace.keys.toSet() - - var assignmentsCopy: HashMap - synchronized(assignments) { - assignmentsCopy = HashMap(assignments) - } - - hash2workspace += assignmentsCopy.map { it.key to it.value.workspace } - - return hash2workspace.map { - val assignment = assignmentsCopy[it.key] - val workspace = it.value - AssignmentData( - workspace = workspace, - unassignedInstances = assignment?.getNumberOfUnassigned() ?: 0, - (assignment?.listDeployments() ?: emptyList()).map { deployment -> - InstanceStatus( - workspace, - deployment.first, - deployment.second, - disabledInstances.contains(deployment.second), - ) - }, - isLatest = latestWorkspaceHashes.contains(it.key), - ) - } - } - - fun listDeployments(): List { - return assignments.entries.flatMap { assignment -> - assignment.value.listDeployments().map { deployment -> - InstanceStatus( - assignment.value.workspace, - deployment.first, - deployment.second, - disabledInstances.contains(deployment.second), - ) - } - } - } - - fun disableInstance(instanceId: InstanceName) { - disabledInstances += instanceId - dirty.set(true) - reconcileDeployments() - } - - fun enableInstance(instanceId: InstanceName) { - disabledInstances -= instanceId - dirty.set(true) - reconcileDeployments() - } - - fun changeNumberOfAssigned(workspaceHash: WorkspaceHash, newNumber: Int) { - getAssignments(getWorkspaceByHash(workspaceHash)).setNumberOfUnassigned(newNumber, true) - } - - fun isInstanceDisabled(instanceId: InstanceName): Boolean = disabledInstances.contains(instanceId) - - private fun generateInstanceName(workspace: WorkspaceAndHash): InstanceName { - val cleanName = - (workspace.id + "-" + workspace.hash()).lowercase(Locale.getDefault()).replace("[^a-z0-9-]".toRegex(), "") - var deploymentName = INSTANCE_PREFIX + cleanName - val suffix = "-" + java.lang.Long.toHexString(deploymentSuffixSequence.incrementAndGet()) + "-" + managerId - val charsToRemove = deploymentName.length + suffix.length - (63 - 16) - if (charsToRemove > 0) deploymentName = deploymentName.substring(0, deploymentName.length - charsToRemove) - return InstanceName(deploymentName + suffix) - } - - @Synchronized - fun redirect(baseRequest: Request?, request: HttpServletRequest): RedirectedURL? { - val redirected: RedirectedURL = RedirectedURL.redirect(baseRequest, request) - ?: return null - return redirect(redirected) - } - - @Synchronized - fun redirect(redirected: RedirectedURL): RedirectedURL? { - val userToken = redirected.userToken - if (userToken == null) return redirected - val userId = userToken.getUserName() - if (userId != null) { - userTokens[UserInstanceOwner(userId)] = userToken - } - val workspaceReference = redirected.workspaceReference - if (!WORKSPACE_PATTERN.matcher(workspaceReference).matches()) return null - val workspace = getWorkspaceForPath(workspaceReference) ?: return null - - val permissionEvaluator = ModelixJWTUtil().createPermissionEvaluator(userToken.payload, WorkspacesPermissionSchema.SCHEMA) - if (userId != null) { - getAccessControlData().load(userToken.payload, permissionEvaluator) - } - - val isSharedInstance = redirected.sharedInstanceName != "own" - if (isSharedInstance) { - if (!permissionEvaluator.hasPermission(WorkspacesPermissionSchema.workspaces.workspace(workspace.id).sharedInstance.access)) return null - } else { - if (!permissionEvaluator.hasPermission(WorkspacesPermissionSchema.workspaces.workspace(workspace.id).instance.run)) return null - } - - val assignments = getAssignments(workspace) - redirected.instanceName = if (redirected.sharedInstanceName == "own") { - assignments.getOrCreate(userToken) - } else { - assignments.getSharedInstance(redirected.sharedInstanceName) ?: return null - } - assignments.reconcile() - reconcileIfDirty() - return redirected - } - - private fun createAssignmentsForAllWorkspaces() { - val latestVersions = workspaceManager.getAllWorkspaces().map { it.withHash() }.associateBy { it.id } - val allExistingVersions = assignments.entries.groupBy { it.value.workspace.id } - - for (latestVersion in latestVersions) { - val existingVersions: List>? = - allExistingVersions[latestVersion.key] - if (existingVersions != null && existingVersions.any { it.key == latestVersion.value.hash() }) continue - val assignment = getAssignments(latestVersion.value) - val unassigned = existingVersions?.maxOfOrNull { it.value.getNumberOfUnassigned() } ?: 0 - assignment.setNumberOfUnassigned(unassigned, false) - existingVersions?.forEach { it.value.resetNumberOfUnassigned() } - } - - val deletedIds = allExistingVersions.keys - latestVersions.keys - for (deleted in deletedIds.flatMap { allExistingVersions[it]!! }) { - deleted.value.resetNumberOfUnassigned() - } - } - - private fun reconcileDeployments() { - // TODO doesn't work with multiple instances of this proxy - synchronized(reconcileLock) { - try { - val expectedDeployments: MutableMap> = HashMap() - val existingDeployments: MutableSet = HashSet() - synchronized(assignments) { - for (assignment in assignments.values) { - assignment.removeTimedOut() - for (deployment in assignment.getAllDeploymentNamesAndUserIds()) { - if (disabledInstances.contains(deployment.first)) continue - if (!getWorkspaceStatus(assignment.workspace.hash()).canStartInstance()) continue - expectedDeployments[deployment.first] = assignment.workspace to deployment.second - } - } - } - val appsApi = AppsV1Api() - val coreApi = CoreV1Api() - val deployments = appsApi - .listNamespacedDeployment(KUBERNETES_NAMESPACE) - .timeoutSeconds(TIMEOUT_SECONDS) - .execute() - for (deployment in deployments.items) { - val name = deployment.metadata!!.name!! - if (name.startsWith(INSTANCE_PREFIX)) { - existingDeployments.add(InstanceName(name)) - } - } - val toAdd = expectedDeployments.keys - existingDeployments - val toRemove = existingDeployments - expectedDeployments.keys - for (d in toRemove) { - try { - appsApi.deleteNamespacedDeployment(d.name, KUBERNETES_NAMESPACE).execute() - } catch (e: Exception) { - LOG.error("Failed to delete deployment $d", e) - } - try { - coreApi.deleteNamespacedService(d.name, KUBERNETES_NAMESPACE).execute() - } catch (e: Exception) { - LOG.error("Failed to delete service $d", e) - } - } - for (d in toAdd) { - val (workspace, owner) = expectedDeployments[d]!! - try { - createDeployment(workspace, owner, d, userTokens[owner]) - } catch (e: Exception) { - LOG.error("Failed to create deployment for workspace ${workspace.id} / $d", e) - } - } - - synchronized(indexWasReady) { - indexWasReady.removeAll(indexWasReady - expectedDeployments.keys) - } - } catch (e: ApiException) { - LOG.error("Deployment cleanup failed", e) - } - } - } - - private fun reconcileIfDirty() { - if (dirty.getAndSet(false)) reconcileDeployments() - } - - fun getDeployment(name: InstanceName, attempts: Int): V1Deployment? { - val appsApi = AppsV1Api() - var deployment: V1Deployment? = null - for (i in 0 until attempts) { - try { - deployment = appsApi.readNamespacedDeployment(name.name, KUBERNETES_NAMESPACE).execute() - } catch (ex: ApiException) { - LOG.error("Failed to read deployment: $name", ex) - } - if (deployment != null) break - try { - Thread.sleep(1000L) - } catch (e: InterruptedException) { - return null - } - } - return deployment - } - - fun getPod(deploymentName: InstanceName): V1Pod? { - try { - val coreApi = CoreV1Api() - val pods = coreApi.listNamespacedPod(KUBERNETES_NAMESPACE).timeoutSeconds(TIMEOUT_SECONDS).execute() - for (pod in pods.items) { - if (!pod.metadata!!.name!!.startsWith(deploymentName.name)) continue - return pod - } - } catch (e: Exception) { - LOG.error("", e) - return null - } - return null - } - - fun getPodLogs(deploymentName: InstanceName): String? { - try { - val coreApi = CoreV1Api() - val pods = coreApi.listNamespacedPod(KUBERNETES_NAMESPACE).timeoutSeconds(TIMEOUT_SECONDS).execute() - for (pod in pods.items) { - if (!pod.metadata!!.name!!.startsWith(deploymentName.name)) continue - return coreApi - .readNamespacedPodLog(pod.metadata!!.name, KUBERNETES_NAMESPACE) - .container(pod.spec!!.containers[0].name) - .pretty("true") - .tailLines(10_000) - .execute() - } - } catch (e: Exception) { - LOG.error("", e) - return null - } - return null - } - - fun isIndexerReady(deploymentName: InstanceName): Boolean { - // avoid doing the expensive check again - // also the relevant line may be truncated from the log if there is too much output - if (indexWasReady.contains(deploymentName)) return true - - val log = getPodLogs(deploymentName) ?: return false - val isReady = log.contains("### Index is ready") - if (isReady) { - indexWasReady.add(deploymentName) - } - return isReady - } - - fun getEvents(deploymentName: String?): List { - if (deploymentName == null) return emptyList() - val events: CoreV1EventList = CoreV1Api() - .listNamespacedEvent(KUBERNETES_NAMESPACE) - .timeoutSeconds(TIMEOUT_SECONDS) - .execute() - return events.items - .filter { (it.involvedObject.name ?: "").contains(deploymentName) } - } - - private val workspaceCache = LRUMap(100) - fun getWorkspaceForPath(path: String): WorkspaceAndHash? { - val matcher = WORKSPACE_PATTERN.matcher(path) - if (!matcher.matches()) return null - var workspaceId = matcher.group(1) - var workspaceHash = matcher.group(2) ?: return null - if (!workspaceHash.contains("*")) { - workspaceHash = - workspaceHash.substring(0, 5) + "*" + workspaceHash.substring(5) - } - return workspaceCache.computeIfAbsent(WorkspaceHash(workspaceHash)) { - getWorkspaceByHash(it) - } - } - - @Synchronized - fun getWorkspaceForInstance(instanceId: InstanceName): WorkspaceAndHash? { - return assignments.values.filter { it.getAllDeploymentNames().any { it == instanceId } } - .map { it.workspace }.firstOrNull() - } - - fun createDeployment( - workspace: WorkspaceAndHash, - owner: InstanceOwner, - instanceName: InstanceName, - userToken: JWTPrincipal?, - ): Boolean { - val originalDeploymentName = WORKSPACE_CLIENT_DEPLOYMENT_NAME - val appsApi = AppsV1Api() - val deployments = appsApi.listNamespacedDeployment(KUBERNETES_NAMESPACE).timeoutSeconds(5).execute() - val deploymentExists = - deployments.items.stream().anyMatch { d: V1Deployment -> instanceName.name == d.metadata!!.name } - if (!deploymentExists) { - val deployment = Yaml.loadAs(File("/workspace-client-templates/deployment"), V1Deployment::class.java) - deployment.metadata!!.creationTimestamp(null) - deployment.metadata!!.managedFields = null - deployment.metadata!!.uid = null - deployment.metadata!!.resourceVersion(null) - deployment.status = null - deployment.metadata!!.putAnnotationsItem("kubectl.kubernetes.io/last-applied-configuration", null) - // deployment.metadata!!.putAnnotationsItem(INSTANCE_PER_USER_ANNOTATION_KEY, null) - // deployment.metadata!!.putAnnotationsItem(MAX_UNASSIGNED_INSTANCES_ANNOTATION_KEY, null) - deployment.metadata!!.name(instanceName.name) - deployment.metadata!!.putLabelsItem("component", instanceName.name) - deployment.spec!!.selector.putMatchLabelsItem("component", instanceName.name) - deployment.spec!!.template.metadata!!.putLabelsItem("component", instanceName.name) - deployment.spec!!.replicas(1) - deployment.spec!!.template.spec!!.containers[0].apply { - addEnvItem(V1EnvVar().name("modelix_workspace_id").value(workspace.id)) - addEnvItem(V1EnvVar().name("REPOSITORY_ID").value("workspace_${workspace.id}")) - addEnvItem(V1EnvVar().name("modelix_workspace_hash").value(workspace.hash().hash)) - addEnvItem(V1EnvVar().name("WORKSPACE_MODEL_SYNC_ENABLED").value(workspace.workspace.modelSyncEnabled.toString())) - } - - val originalJwt = userToken?.payload - var userId: String? = null - var hasWritePermission = false - val newPermissions = ArrayList() - newPermissions += WorkspacesPermissionSchema.workspaces.workspace(workspace.id).config.read - - if (originalJwt == null) { - if (owner is SharedInstanceOwner && workspace.sharedInstances.find { it.name == owner.name }?.allowWrite == true) { - hasWritePermission = true - } - } else { - userId = ModelixJWTUtil().extractUserId(originalJwt) - val permissionEvaluator = jwtUtil.createPermissionEvaluator(originalJwt, WorkspacesPermissionSchema.SCHEMA) - getAccessControlData().load(originalJwt, permissionEvaluator) - if (permissionEvaluator.hasPermission(WorkspacesPermissionSchema.workspaces.workspace(workspace.id).modelRepository.write)) { - hasWritePermission = true - } - } - newPermissions += ModelServerPermissionSchema.repository("workspace_" + workspace.id).let { if (hasWritePermission) it.write else it.read } - - val newToken = jwtUtil.createAccessToken(userId ?: "workspace-user@modelix.org", newPermissions.map { it.fullId }) - deployment.spec!!.template.spec!!.containers[0].addEnvItem(V1EnvVar().name("INITIAL_JWT_TOKEN").value(newToken)) - loadWorkspaceSpecificValues(workspace, deployment) - println("Creating deployment: ") - println(Yaml.dump(deployment)) - appsApi.createNamespacedDeployment(KUBERNETES_NAMESPACE, deployment).execute() - } - val coreApi = CoreV1Api() - val services = coreApi.listNamespacedService(KUBERNETES_NAMESPACE).timeoutSeconds(TIMEOUT_SECONDS).execute() - val serviceExists = services.items.stream().anyMatch { s: V1Service -> instanceName.name == s.metadata!!.name } - if (!serviceExists) { - val service = Yaml.loadAs(File("/workspace-client-templates/service"), V1Service::class.java) - service.metadata!!.putAnnotationsItem("kubectl.kubernetes.io/last-applied-configuration", null) - service.metadata!!.managedFields = null - service.metadata!!.uid = null - service.metadata!!.resourceVersion(null) - // The "template" service got assigned cluster IPs. - // We do not want to use them for the service we are going to create. - // Therefore, we are resetting them. - // Leaving them would result in Kubernetes refusing to create the new service. - service.spec!!.clusterIPs = null - service.spec!!.clusterIP = null - service.spec!!.ports!!.forEach(Consumer { p: V1ServicePort -> p.nodePort(null) }) - service.status = null - service.metadata!!.name(instanceName.name) - service.metadata!!.putLabelsItem("component", instanceName.name) - service.metadata!!.name(instanceName.name) - service.spec!!.putSelectorItem("component", instanceName.name) - println("Creating service: ") - println(Yaml.dump(service)) - coreApi.createNamespacedService(KUBERNETES_NAMESPACE, service).execute() - } - return true - } - - private fun loadWorkspaceSpecificValues(workspace: WorkspaceAndHash, deployment: V1Deployment) { - try { - val container = deployment.spec!!.template.spec!!.containers[0] - - // The image registry is made available to the container runtime via a NodePort - // localhost in this case is the kubernetes node, not the instances-manager - container.image = "${INTERNAL_DOCKER_REGISTRY_AUTHORITY}/modelix-workspaces/ws${workspace.id}:${workspace.hash().toValidImageTag()}" - - val resources = container.resources ?: return - val memoryLimit = Quantity.fromString(workspace.memoryLimit) - val limits = resources.limits - if (limits != null) limits["memory"] = memoryLimit - val requests = resources.requests - if (requests != null) requests["memory"] = memoryLimit - } catch (ex: Exception) { - LOG.error("Failed to configure the deployment for the workspace ${workspace.id}", ex) - } - } - - private inner class Assignments(val workspace: WorkspaceAndHash) { - private val owner2deployment: MutableMap = HashMap() - private var numberOfUnassignedAuto: Int? = null - private var numberOfUnassignedSetByUser: Int? = null - - fun listDeployments(): List> { - return owner2deployment.map { it.key to it.value } - } - - @Synchronized - fun getSharedInstance(name: String): InstanceName? { - return owner2deployment.entries.find { (it.key as? SharedInstanceOwner)?.name == name }?.value - } - - @Synchronized - fun getOrCreate(userToken: JWTPrincipal): InstanceName { - val userId: String = userToken.getUserName() ?: throw RuntimeException("Token doesn't contain a user ID") - var workspaceInstanceId = owner2deployment[UserInstanceOwner(userId)] - if (workspaceInstanceId == null) { - val unassignedInstanceKey = - owner2deployment.keys.filterIsInstance().firstOrNull() - if (unassignedInstanceKey == null) { - workspaceInstanceId = generateInstanceName(workspace) - } else { - workspaceInstanceId = owner2deployment.remove(unassignedInstanceKey)!! - owner2deployment[unassignedInstanceKey] = generateInstanceName(workspace) - } - owner2deployment[UserInstanceOwner(userId)] = workspaceInstanceId - dirty.set(true) - } - DeploymentTimeouts.update(workspaceInstanceId) - return workspaceInstanceId - } - - @Synchronized - fun setNumberOfUnassigned(targetNumber: Int, setByUser: Boolean) { - if (setByUser) { - numberOfUnassignedSetByUser = targetNumber - } else { - numberOfUnassignedAuto = targetNumber - } - reconcile() - } - - @Synchronized - fun resetNumberOfUnassigned() { - numberOfUnassignedSetByUser = null - numberOfUnassignedAuto = null - reconcile() - } - - fun getNumberOfUnassigned() = numberOfUnassignedSetByUser ?: numberOfUnassignedAuto ?: 0 - - @Synchronized - fun reconcile() { - val expectedNumUnassigned = getNumberOfUnassigned() - - val expectedInstances = ( - workspace.sharedInstances.map { SharedInstanceOwner(it.name) } + - (0 until expectedNumUnassigned).map { UnassignedInstanceOwner(it) } - ).toSet() - val existingInstances = owner2deployment.keys.filterNot { it is UserInstanceOwner }.toSet() - for (toRemove in (existingInstances - expectedInstances)) { - owner2deployment.remove(toRemove) - dirty.set(true) - } - for (toAdd in (expectedInstances - existingInstances)) { - owner2deployment[toAdd] = generateInstanceName(workspace) - dirty.set(true) - } - } - - @Synchronized - fun getAllDeploymentNames(): List = owner2deployment.values.toList() - - @Synchronized - fun getAllDeploymentNamesAndUserIds(): List> = - owner2deployment.map { it.value to it.key } - - @Synchronized - fun removeTimedOut() { - val entries = owner2deployment.entries.toList() - for ((owner, instanceName) in entries) { - if (owner is UserInstanceOwner && DeploymentTimeouts.isTimedOut(instanceName)) { - owner2deployment.remove(owner, instanceName) - dirty.set(true) - } - } - } - } - - companion object { - private val LOG = mu.KotlinLogging.logger {} - val KUBERNETES_NAMESPACE = System.getenv("WORKSPACE_CLIENT_NAMESPACE") ?: "default" - val INSTANCE_PREFIX = System.getenv("WORKSPACE_CLIENT_PREFIX") ?: "wsclt-" - val WORKSPACE_CLIENT_DEPLOYMENT_NAME = System.getenv("WORKSPACE_CLIENT_DEPLOYMENT_NAME") ?: "workspace-client" - val WORKSPACE_PATTERN = Pattern.compile("workspace-([a-f0-9]+)-([a-zA-Z0-9\\-_\\*]+)") - val INTERNAL_DOCKER_REGISTRY_AUTHORITY = requireNotNull(System.getenv("INTERNAL_DOCKER_REGISTRY_AUTHORITY")) - } -} - -@JvmInline -value class InstanceName(val name: String) - -fun WorkspaceBuildStatus.canStartInstance(): Boolean = when (this) { - WorkspaceBuildStatus.New -> false - WorkspaceBuildStatus.Queued -> false - WorkspaceBuildStatus.Running -> false - WorkspaceBuildStatus.FailedBuild -> false - WorkspaceBuildStatus.FailedZip -> false - WorkspaceBuildStatus.AllSuccessful -> true - WorkspaceBuildStatus.ZipSuccessful -> true -} diff --git a/workspace-manager/src/main/kotlin/org/modelix/instancesmanager/DeploymentManagingHandler.kt b/workspace-manager/src/main/kotlin/org/modelix/instancesmanager/DeploymentManagingHandler.kt deleted file mode 100644 index 915fba8c..00000000 --- a/workspace-manager/src/main/kotlin/org/modelix/instancesmanager/DeploymentManagingHandler.kt +++ /dev/null @@ -1,149 +0,0 @@ -/* - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.modelix.instancesmanager - -import org.eclipse.jetty.server.Request -import org.eclipse.jetty.server.handler.AbstractHandler -import org.modelix.authorization.getUserName -import org.modelix.workspaces.WorkspaceBuildStatus -import org.modelix.workspaces.WorkspaceProgressItems -import javax.servlet.http.HttpServletRequest -import javax.servlet.http.HttpServletResponse - -class DeploymentManagingHandler(val manager: DeploymentManager) : AbstractHandler() { - override fun handle(target: String, baseRequest: Request, request: HttpServletRequest, response: HttpServletResponse) { - val redirectedURL = manager.redirect(baseRequest, request) ?: return - val personalDeploymentName = redirectedURL.instanceName ?: return - - // instance disabled on the management page - if (manager.isInstanceDisabled(personalDeploymentName)) { - baseRequest.isHandled = true - response.contentType = "text/html" - response.status = HttpServletResponse.SC_OK - response.writer.append("""Instance is disabled. (Manage Instances)""") - return - } - - val workspace = manager.getWorkspaceForPath(redirectedURL.workspaceReference) ?: return - var progress: Pair = 0 to "Waiting for start of workspace build job" - var statusLink = "/workspace-manager/instances/log/${personalDeploymentName.name}/" - var readyForForwarding = false - - val progressItems = WorkspaceProgressItems() - val status = manager.getWorkspaceStatus(workspace.hash()) - - fun loadBuildStatus() { - progress = when (status) { - WorkspaceBuildStatus.New -> { - progressItems.build.enqueue.started = true - 10 to "Waiting for start of workspace build job" - } - WorkspaceBuildStatus.Queued -> { - progressItems.build.enqueue.done = true - 20 to "Workspace is queued for building" - } - WorkspaceBuildStatus.Running -> { - progressItems.build.startKubernetesJob.started = true - 30 to "Workspace build is running" - } - WorkspaceBuildStatus.FailedBuild, WorkspaceBuildStatus.FailedZip -> { - 0 to "Workspace build failed" - } - WorkspaceBuildStatus.AllSuccessful -> 40 to "Workspace build is done" - WorkspaceBuildStatus.ZipSuccessful -> 40 to "Workspace build is done" - } - progressItems.build.enqueue.done = status != WorkspaceBuildStatus.New - progressItems.parseLog(manager.getWorkspaceBuildLog(workspace.hash())) - } - - if (status.canStartInstance()) { - DeploymentTimeouts.update(personalDeploymentName) - val deployment = manager.getDeployment(personalDeploymentName, 10) - ?: throw RuntimeException("Failed creating deployment " + personalDeploymentName + " for user " + redirectedURL.userToken?.getUserName()) - progressItems.container.createDeployment.done = true - val readyReplicas = deployment.status?.readyReplicas ?: 0 - val waitForIndexer = request.getParameter("waitForIndexer") != "false" - val waitingForIndexer = waitForIndexer && !manager.isIndexerReady(personalDeploymentName) - readyForForwarding = readyReplicas > 0 - if (readyForForwarding && !waitingForIndexer) { - progress = 100 to "Workspace instance is ready" - } else { - loadBuildStatus() - progress = 50 to "Workspace deployment created. Waiting for startup of the container." - if (manager.getPod(personalDeploymentName)?.status?.phase == "Running") { - progressItems.container.startContainer.started = true - progress = 50 to "Workspace container is running" - val log = manager.getPodLogs(personalDeploymentName) ?: "" - val string2progress: List>> = listOf( - "] container is starting..." to (60 to "Workspace container is running"), - "] starting service 'app'..." to (70 to "Preparing MPS project"), - "] + /mps/bin/mps.sh" to (80 to "MPS is starting"), - "### Workspace client loaded" to (90 to "Project is loaded. Waiting for indexer."), - "### Index is ready" to (100 to "Indexing is done. Project is ready."), - ) - string2progress.lastOrNull { log.contains(it.first) }?.second?.let { - progress = it - } - - if (log.contains("] container is starting...")) { - progressItems.container.startContainer.done = true - } - if (log.contains("] starting service 'app'...")) { - progressItems.container.prepareMPS.started = true - } - if (log.contains("] + /mps/bin/mps.sh")) { - progressItems.container.prepareMPS.done = true - progressItems.container.startMPS.started = true - } - if (log.contains("### Workspace client loaded")) { - progressItems.container.startMPS.done = true - progressItems.container.runIndexer.started = true - } - if (log.contains("### Index is ready")) { - progressItems.container.runIndexer.done = true - } - } - } - } else { - statusLink = "/workspace-manager/${workspace.hash()}/buildlog" - loadBuildStatus() - } - - if (progress.first < 100) { - baseRequest.isHandled = true - response.contentType = "text/html" - response.status = HttpServletResponse.SC_OK - var html = this.javaClass.getResource("/static/status-screen.html")?.readText() ?: "" - html = html.replace("{{workspaceName}}", workspace.name ?: workspace.id) - html = html.replace("{{progressPercent}}", progress.first.toString()) - html = html.replace("{{instanceId}}", personalDeploymentName.name) - html = html.replace("{{statusSummary}}", progress.second) - html = html.replace("{{statusLink}}", statusLink) - - val progressItemsAsHtml = progressItems.getItems().entries.joinToString("  ") { group -> - group.value.joinToString("") { - "${it.description}${it.statusText()}" - } - } - html = html.replace("{{progressItems}}", progressItemsAsHtml) - html = html.replace("{{skipIndexerLinkVisibility}}", if (readyForForwarding) "visible" else "hidden") - - response.writer.append(html) - } - } - - companion object { - private val LOG = mu.KotlinLogging.logger {} - } -} diff --git a/workspace-manager/src/main/kotlin/org/modelix/instancesmanager/DeploymentTimeouts.kt b/workspace-manager/src/main/kotlin/org/modelix/instancesmanager/DeploymentTimeouts.kt deleted file mode 100644 index 223c1e4c..00000000 --- a/workspace-manager/src/main/kotlin/org/modelix/instancesmanager/DeploymentTimeouts.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.modelix.instancesmanager - -import org.apache.commons.collections4.map.HashedMap -import java.util.* - -object DeploymentTimeouts { - private val timeouts = Collections.synchronizedMap(HashedMap()) - fun update(deploymentName: InstanceName) { - var timeoutStr = System.getProperty("DEPLOYMENT_USER_COPY_TIMEOUT") - if (timeoutStr == null) timeoutStr = System.getenv("DEPLOYMENT_USER_COPY_TIMEOUT") - var timeout: Long = 60 - if (timeoutStr != null && timeoutStr.length > 0) { - try { - timeout = timeoutStr.toLong() - } catch (ex: NumberFormatException) { - } - } - timeouts[deploymentName] = System.currentTimeMillis() + timeout * 60L * 1000L - } - - fun isTimedOut(deploymentName: InstanceName): Boolean { - synchronized(timeouts) { - val timeout = timeouts[deploymentName] ?: return true - if (timeout > System.currentTimeMillis()) return false - timeouts.remove(deploymentName) - return true - } - } -} diff --git a/workspace-manager/src/main/kotlin/org/modelix/instancesmanager/DeploymentsProxy.kt b/workspace-manager/src/main/kotlin/org/modelix/instancesmanager/DeploymentsProxy.kt index f06274f3..fdfa663c 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/instancesmanager/DeploymentsProxy.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/instancesmanager/DeploymentsProxy.kt @@ -13,99 +13,49 @@ */ package org.modelix.instancesmanager -import io.kubernetes.client.openapi.ApiException -import org.eclipse.jetty.server.Request import org.eclipse.jetty.server.Server import org.eclipse.jetty.server.handler.DefaultHandler import org.eclipse.jetty.server.handler.HandlerList -import org.eclipse.jetty.server.handler.HandlerWrapper import org.eclipse.jetty.servlet.ServletContextHandler import org.eclipse.jetty.servlet.ServletHolder import org.eclipse.jetty.websocket.api.Session import org.eclipse.jetty.websocket.servlet.ServletUpgradeRequest +import org.modelix.workspace.manager.WorkspaceInstancesManager import java.net.URI -import java.net.URISyntaxException import javax.servlet.http.HttpServletRequest -import javax.servlet.http.HttpServletResponse -class DeploymentsProxy(val manager: DeploymentManager) { +class DeploymentsProxy(val manager: WorkspaceInstancesManager) { private val LOG = mu.KotlinLogging.logger {} - fun main(args: Array) { - try { - startServer() - io.ktor.server.netty.EngineMain.main(args) - } catch (ex: ApiException) { - LOG.error("", ex) - LOG.error("code: " + ex.code) - LOG.error("body: " + ex.responseBody) - } catch (ex: Exception) { - LOG.error("", ex) - } - } - fun startServer() { val server = Server(33332) val handlerList = HandlerList() server.handler = handlerList - val deploymentManagingHandler = DeploymentManagingHandler(manager) - handlerList.addHandler(deploymentManagingHandler) val proxyServlet: ProxyServletWithWebsocketSupport = object : ProxyServletWithWebsocketSupport() { override fun dataTransferred(clientSession: Session?, proxySession: Session?) { - val deploymentName = InstanceName(proxySession!!.upgradeRequest.host) - DeploymentTimeouts.update(deploymentName) +// val deploymentName = InstanceName(proxySession!!.upgradeRequest.host) +// DeploymentTimeouts.update(deploymentName) } override fun redirect(request: ServletUpgradeRequest): URI? { - val redirectedURL = manager.redirect(null, request.httpServletRequest) - val urlToRedirectTo = redirectedURL?.getURLToRedirectTo(true) - return try { - urlToRedirectTo?.let { URI(it) } - } catch (e: URISyntaxException) { - throw RuntimeException(e) - } + val redirectedURL = RedirectedURL.redirect(request.httpServletRequest) + if (redirectedURL == null) return null + redirectedURL.targetHost = manager.getTargetHost(redirectedURL.instanceId) + return redirectedURL.getURLToRedirectTo(true)?.let { URI(it) } } override fun rewriteTarget(clientRequest: HttpServletRequest): String? { - val redirectedURL = manager.redirect(null, clientRequest) - val urlToRedirectTo = redirectedURL?.getURLToRedirectTo(false) - return urlToRedirectTo - } - } - val proxyHandlerCondition: HandlerWrapper = object : HandlerWrapper() { - override fun handle(target: String, baseRequest: Request, request: HttpServletRequest, response: HttpServletResponse) { - val redirect: RedirectedURL = manager.redirect(baseRequest, request) - ?: return - if (redirect.userToken == null) { - baseRequest.isHandled = true - response.status = HttpServletResponse.SC_UNAUTHORIZED - response.contentType = "text/plain" - response.writer.write("Cookie with deployment ID missing. Refresh this page to send a new valid request.") - return - } - // if (!baseRequest.getRequestURI().contains("/ws/")) return; - super.handle(target, baseRequest, request, response) + val redirectedURL = RedirectedURL.redirect(clientRequest) + if (redirectedURL == null) return null + redirectedURL.targetHost = manager.getTargetHost(redirectedURL.instanceId) + return redirectedURL.getURLToRedirectTo(false) } } + val proxyHandler = ServletContextHandler() proxyHandler.addServlet(ServletHolder(proxyServlet), "/*") - proxyHandlerCondition.handler = proxyHandler - handlerList.addHandler(proxyHandlerCondition) - -// ProxyServlet proxyServlet = new ProxyServlet() { -// @Override -// protected String rewriteTarget(HttpServletRequest clientRequest) { -// RedirectedURL redirectedURL = RedirectedURL.redirect(null, clientRequest); -// if (redirectedURL == null) return null; -// return redirectedURL.getRedirectedUrl(false); -// } -// }; -// proxyServlet.setTimeout(60_000); -// -// ServletContextHandler proxyHandler = new ServletContextHandler(); -// proxyHandler.addServlet(new ServletHolder(proxyServlet), "/*"); -// handlerList.addHandler(proxyHandler); + handlerList.addHandler(proxyHandler) handlerList.addHandler(DefaultHandler()) server.start() Runtime.getRuntime().addShutdownHook(object : Thread() { @@ -118,8 +68,5 @@ class DeploymentsProxy(val manager: DeploymentManager) { } } }) - - // Trigger creation of the instance so that it starts the first MPS instance - manager.toString() } } diff --git a/workspace-manager/src/main/kotlin/org/modelix/instancesmanager/InstanceStatus.kt b/workspace-manager/src/main/kotlin/org/modelix/instancesmanager/InstanceStatus.kt deleted file mode 100644 index ea746635..00000000 --- a/workspace-manager/src/main/kotlin/org/modelix/instancesmanager/InstanceStatus.kt +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright 2003-2022 JetBrains s.r.o. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.modelix.instancesmanager - -import org.modelix.workspaces.WorkspaceAndHash - -class InstanceStatus(val workspace: WorkspaceAndHash, val owner: InstanceOwner, val id: InstanceName, val disabled: Boolean) diff --git a/workspace-manager/src/main/kotlin/org/modelix/instancesmanager/RedirectedURL.kt b/workspace-manager/src/main/kotlin/org/modelix/instancesmanager/RedirectedURL.kt index 9656b208..a12ce6fa 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/instancesmanager/RedirectedURL.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/instancesmanager/RedirectedURL.kt @@ -15,62 +15,48 @@ package org.modelix.instancesmanager import com.auth0.jwt.JWT import io.ktor.server.auth.jwt.JWTPrincipal -import org.eclipse.jetty.server.Request import org.modelix.authorization.nullIfInvalid +import java.util.UUID import javax.servlet.http.HttpServletRequest class RedirectedURL( - val remainingPath: String, - val workspaceReference: String, - val sharedInstanceName: String, - var instanceName: InstanceName?, + val targetPath: String, + val targetPort: Int, + val instanceId: UUID, val userToken: JWTPrincipal?, + var targetHost: String? = null, ) { fun getURLToRedirectTo(websocket: Boolean): String? { var url = (if (websocket) "ws" else "http") + "://" - url += if (instanceName != null) instanceName?.name else workspaceReference - url += if (remainingPath.startsWith("/ide")) { - ":5800" + remainingPath.substring("/ide".length) - } else if (remainingPath.startsWith("/generator")) { - // see https://github.com/modelix/modelix.mps-plugins/blob/bb70966087e2f41c263a7fe4d292e4722d50b9d1/mps-generator-execution-plugin/src/main/kotlin/org/modelix/mps/generator/web/GeneratorExecutionServer.kt#L78 - ":33335" + remainingPath.substring("/generator".length) - } else if (remainingPath.startsWith("/diff")) { - // see https://github.com/modelix/modelix.mps-plugins/blob/bb70966087e2f41c263a7fe4d292e4722d50b9d1/mps-diff-plugin/src/main/kotlin/org/modelix/ui/diff/DiffServer.kt#L82 - ":33334" + remainingPath.substring("/diff".length) - } else if (remainingPath.startsWith("/port/")) { - val matchResults = PORT_MATCHER.matchEntire(remainingPath) ?: return null - val portString = matchResults.groupValues[1] - val portNumber = portString.toInt() - if (portNumber > HIGHEST_VALID_PORT_NUMBER) { - return null - } - val pathAfterPort = matchResults.groupValues[2] - ":$portString$pathAfterPort" - } else { - ":33333$remainingPath" - } + url += "$targetHost:$targetPort$targetPath" return url } companion object { - private const val HIGHEST_VALID_PORT_NUMBER = 65535 - private val PORT_MATCHER = Regex("/port/(\\d{1,5})(/.*)?") - fun redirect(baseRequest: Request?, request: HttpServletRequest): RedirectedURL? { + fun redirect(request: HttpServletRequest): RedirectedURL? { var remainingPath = request.requestURI - if (!remainingPath.startsWith("/")) return null - remainingPath = remainingPath.substring(1) - val workspaceReference = remainingPath.substringBefore('/') - remainingPath = remainingPath.substringAfter('/') - val sharedInstanceName = remainingPath.substringBefore('/') - remainingPath = remainingPath.substringAfter('/') + remainingPath = remainingPath.trimStart('/') + + val instanceId = remainingPath.substringBefore('/').let { UUID.fromString(it) } + remainingPath = remainingPath.substringAfter('/', "") + + if (!remainingPath.startsWith("port/")) return null + remainingPath = remainingPath.substringAfter("port/") + val port = remainingPath.substringBefore('/').toIntOrNull()?.takeIf { (0..65535).contains(it) } ?: return null + remainingPath = remainingPath.substringAfter('/', "") + if (request.queryString != null) remainingPath += "?" + request.queryString - val userId = getUserIdFromAuthHeader(request) - return RedirectedURL("/" + remainingPath, workspaceReference, sharedInstanceName, null, userId) + return RedirectedURL( + targetPath = "/$remainingPath", + targetPort = port, + instanceId = instanceId, + userToken = getUserIdFromAuthHeader(request), + ) } - fun getUserIdFromAuthHeader(request: HttpServletRequest): JWTPrincipal? { + private fun getUserIdFromAuthHeader(request: HttpServletRequest): JWTPrincipal? { val tokenString = run { val headerValue: String? = request.getHeader("Authorization") val prefix = "Bearer " diff --git a/workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/DraftPreparationTask.kt b/workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/DraftPreparationTask.kt new file mode 100644 index 00000000..c1f36ddf --- /dev/null +++ b/workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/DraftPreparationTask.kt @@ -0,0 +1,33 @@ +package org.modelix.services.gitconnector + +import kotlinx.coroutines.CoroutineScope +import org.modelix.model.client2.IModelClientV2 +import org.modelix.model.lazy.BranchReference +import org.modelix.workspace.manager.TaskInstance + +class DraftPreparationTask( + scope: CoroutineScope, + val key: Key, + val gitManager: GitConnectorManager, + val modelClient: IModelClientV2, +) : TaskInstance(scope) { + override suspend fun process(): BranchReference { + val draft = requireNotNull(gitManager.getDraft(key.draftId)) { "Draft not found: ${key.draftId}" } + val gitRepoConfig = requireNotNull(gitManager.getRepository(draft.gitRepositoryId)) { + "Git repository config not found: ${draft.gitRepositoryId}" + } + val modelixBranch = gitRepoConfig.getModelixRepositoryId().getBranchReference(draft.modelixBranchName) + if (modelClient.listBranches(modelixBranch.repositoryId).contains(modelixBranch)) { + return modelixBranch + } + + val importTask = gitManager.getOrCreateImportTask(draft.gitRepositoryId, draft.gitBranchName) + val importedVersion = importTask.waitForOutput() + modelClient.push(modelixBranch, importedVersion, importedVersion) + return modelixBranch + } + + data class Key( + val draftId: String, + ) +} diff --git a/workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/DraftRebaseTask.kt b/workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/DraftRebaseTask.kt new file mode 100644 index 00000000..acfea4ee --- /dev/null +++ b/workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/DraftRebaseTask.kt @@ -0,0 +1,38 @@ +package org.modelix.services.gitconnector + +import kotlinx.coroutines.CoroutineScope +import org.modelix.model.IVersion +import org.modelix.model.VersionMerger +import org.modelix.model.client2.IModelClientV2 +import org.modelix.model.lazy.BranchReference +import org.modelix.workspace.manager.TaskInstance + +class DraftRebaseTask( + val key: Key, + scope: CoroutineScope, + val gitManager: GitConnectorManager, + val modelClient: IModelClientV2, +) : TaskInstance(scope) { + + override suspend fun process(): IVersion { + val importTask = gitManager.getOrCreateImportTask(key.importTaskKey) + val draftBranch = key.draftBranch + val currentDraftHead = modelClient.lazyLoadVersion(draftBranch) + val newBaseVersion = importTask.waitForOutput() + val mergedVersion = VersionMerger().mergeChange(newBaseVersion, currentDraftHead) + val newVersion = modelClient.push(draftBranch, mergedVersion, listOf(currentDraftHead, newBaseVersion)) + gitManager.updateDraftConfig(key.draftId) { + it.copy( + baseGitCommit = key.importTaskKey.gitRevision, + gitBranchName = key.importTaskKey.gitBranchName, + ) + } + return newVersion + } + + data class Key( + val importTaskKey: GitImportTask.Key, + val draftId: String, + val draftBranch: BranchReference, + ) +} diff --git a/workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/GitConnectorController.kt b/workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/GitConnectorController.kt new file mode 100644 index 00000000..c8168336 --- /dev/null +++ b/workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/GitConnectorController.kt @@ -0,0 +1,354 @@ +package org.modelix.services.gitconnector + +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.ApplicationCall +import io.ktor.server.response.respond +import io.ktor.server.response.respondText +import io.ktor.server.routing.Route +import kotlinx.serialization.Serializable +import org.modelix.services.gitconnector.stubs.controllers.ModelixGitConnectorDraftsController +import org.modelix.services.gitconnector.stubs.controllers.ModelixGitConnectorDraftsController.Companion.modelixGitConnectorDraftsRoutes +import org.modelix.services.gitconnector.stubs.controllers.ModelixGitConnectorDraftsPreparationJobController +import org.modelix.services.gitconnector.stubs.controllers.ModelixGitConnectorDraftsPreparationJobController.Companion.modelixGitConnectorDraftsPreparationJobRoutes +import org.modelix.services.gitconnector.stubs.controllers.ModelixGitConnectorDraftsRebaseJobController +import org.modelix.services.gitconnector.stubs.controllers.ModelixGitConnectorDraftsRebaseJobController.Companion.modelixGitConnectorDraftsRebaseJobRoutes +import org.modelix.services.gitconnector.stubs.controllers.ModelixGitConnectorRepositoriesBranchesController +import org.modelix.services.gitconnector.stubs.controllers.ModelixGitConnectorRepositoriesBranchesController.Companion.modelixGitConnectorRepositoriesBranchesRoutes +import org.modelix.services.gitconnector.stubs.controllers.ModelixGitConnectorRepositoriesBranchesUpdateController +import org.modelix.services.gitconnector.stubs.controllers.ModelixGitConnectorRepositoriesBranchesUpdateController.Companion.modelixGitConnectorRepositoriesBranchesUpdateRoutes +import org.modelix.services.gitconnector.stubs.controllers.ModelixGitConnectorRepositoriesController +import org.modelix.services.gitconnector.stubs.controllers.ModelixGitConnectorRepositoriesController.Companion.modelixGitConnectorRepositoriesRoutes +import org.modelix.services.gitconnector.stubs.controllers.ModelixGitConnectorRepositoriesDraftsController +import org.modelix.services.gitconnector.stubs.controllers.ModelixGitConnectorRepositoriesDraftsController.Companion.modelixGitConnectorRepositoriesDraftsRoutes +import org.modelix.services.gitconnector.stubs.controllers.ModelixGitConnectorRepositoriesStatusController +import org.modelix.services.gitconnector.stubs.controllers.ModelixGitConnectorRepositoriesStatusController.Companion.modelixGitConnectorRepositoriesStatusRoutes +import org.modelix.services.gitconnector.stubs.controllers.TypedApplicationCall +import org.modelix.services.gitconnector.stubs.models.DraftConfig +import org.modelix.services.gitconnector.stubs.models.DraftConfigList +import org.modelix.services.gitconnector.stubs.models.DraftPreparationJob +import org.modelix.services.gitconnector.stubs.models.DraftRebaseJob +import org.modelix.services.gitconnector.stubs.models.GitBranchList +import org.modelix.services.gitconnector.stubs.models.GitRemoteConfig +import org.modelix.services.gitconnector.stubs.models.GitRepositoryConfig +import org.modelix.services.gitconnector.stubs.models.GitRepositoryConfigList +import org.modelix.services.gitconnector.stubs.models.GitRepositoryStatusData +import org.modelix.workspace.manager.SharedMutableState +import org.modelix.workspace.manager.TaskState +import java.util.UUID + +@Serializable +data class GitConnectorData( + val repositories: Map = emptyMap(), + val drafts: Map = emptyMap(), +) + +class GitConnectorController(val manager: GitConnectorManager) { + + private val data: SharedMutableState get() = manager.connectorData + + fun install(route: Route) { + route.install_() + } + + private fun Route.install_() { + modelixGitConnectorRepositoriesRoutes(object : ModelixGitConnectorRepositoriesController { + override suspend fun listGitRepositories( + includeStatus: Boolean?, + call: TypedApplicationCall, + ) { + call.respondTyped( + GitRepositoryConfigList(data.getValue().repositories.values.toList()) + .maskCredentials() + .maskStatus(includeStatus), + ) + } + + override suspend fun createGitRepository( + gitRepositoryConfig: GitRepositoryConfig, + call: TypedApplicationCall, + ) { + val newId = UUID.randomUUID().toString() + val newRepository = gitRepositoryConfig.copy( + id = newId, + status = null, + modelixRepository = newId, + ) + + data.update { + it.copy( + repositories = it.repositories + (newRepository.id to newRepository), + ) + } + + call.respondTyped(newRepository.maskCredentials()) + } + + override suspend fun getGitRepository( + repositoryId: String, + includeStatus: Boolean?, + call: TypedApplicationCall, + ) { + val repo = data.getValue().repositories[repositoryId] + if (repo == null) { + call.respond(HttpStatusCode.NotFound) + } else { + call.respondTyped(repo.maskCredentials().copy(status = if (includeStatus == true) repo.status else null)) + } + } + + override suspend fun updateGitRepository( + repositoryId: String, + gitRepositoryConfig: GitRepositoryConfig, + call: ApplicationCall, + ) { + data.update { + val mergedConfig = it.repositories[repositoryId].merge(gitRepositoryConfig) + it.copy( + repositories = it.repositories + (repositoryId to mergedConfig), + ) + } + + call.respond(HttpStatusCode.OK) + } + + override suspend fun deleteGitRepository( + repositoryId: String, + call: ApplicationCall, + ) { + data.update { + it.copy( + repositories = it.repositories - repositoryId, + ) + } + + call.respond(HttpStatusCode.OK) + } + }) + + modelixGitConnectorRepositoriesDraftsRoutes(object : ModelixGitConnectorRepositoriesDraftsController { + override suspend fun listDraftsInRepository( + repositoryId: String, + call: TypedApplicationCall, + ) { + call.respondTyped( + DraftConfigList( + data.getValue().drafts.values.filter { it.gitRepositoryId == repositoryId }, + ), + ) + } + + override suspend fun createDraftInRepository( + repositoryId: String, + draftConfig: DraftConfig, + call: TypedApplicationCall, + ) { + val draftId = UUID.randomUUID().toString() + val branch = manager.getRepository(repositoryId)?.status?.branches?.find { it.name == draftConfig.gitBranchName } + val newDraft = draftConfig.copy( + id = draftId, + gitRepositoryId = repositoryId, + baseGitCommit = draftConfig.baseGitCommit.takeIf { it.isNotEmpty() } ?: branch?.gitCommitHash ?: "", + modelixBranchName = "drafts/$draftId", + ) + data.update { + it.copy( + drafts = it.drafts + (draftId to newDraft), + ) + } + call.respondTyped(newDraft) + } + }) + + modelixGitConnectorDraftsRoutes(object : ModelixGitConnectorDraftsController { + override suspend fun deleteDraft(draftId: String, call: ApplicationCall) { + data.update { + it.copy( + drafts = it.drafts - draftId, + ) + } + call.respond(HttpStatusCode.OK) + } + + override suspend fun getDraft( + draftId: String, + call: TypedApplicationCall, + ) { + val draft = data.getValue().drafts[draftId] + if (draft == null) { + call.respond(HttpStatusCode.NotFound) + } else { + call.respondTyped(draft) + } + } + }) + + modelixGitConnectorRepositoriesBranchesRoutes(object : ModelixGitConnectorRepositoriesBranchesController { + override suspend fun listBranches( + repositoryId: String, + call: TypedApplicationCall, + ) { + val repository = data.getValue().repositories[repositoryId] + if (repository == null) { + call.respond(HttpStatusCode.NotFound) + return + } + call.respondTyped(GitBranchList(repository.status?.branches ?: emptyList())) + } + }) + + modelixGitConnectorRepositoriesBranchesUpdateRoutes(object : ModelixGitConnectorRepositoriesBranchesUpdateController { + override suspend fun updateBranches( + repositoryId: String, + call: TypedApplicationCall, + ) { + val repository = data.getValue().repositories[repositoryId] + if (repository == null) { + call.respond(HttpStatusCode.NotFound) + return + } + val newBranches = manager.updateRemoteBranches(repository) + call.respondTyped(GitBranchList(newBranches)) + } + }) + + modelixGitConnectorRepositoriesStatusRoutes(object : ModelixGitConnectorRepositoriesStatusController { + override suspend fun getGitRepositoryStatus( + repositoryId: String, + call: TypedApplicationCall, + ) { + call.respondTyped(data.getValue().repositories[repositoryId]?.status ?: GitRepositoryStatusData()) + } + }) + + modelixGitConnectorDraftsRebaseJobRoutes(object : ModelixGitConnectorDraftsRebaseJobController { + override suspend fun getDraftRebaseJob( + draftId: String, + call: TypedApplicationCall, + ) { + val draft = data.getValue().drafts[draftId] + if (draft == null) { + call.respond(HttpStatusCode.NotFound) + } else { + val task = manager.getRebaseTask(draftId) + if (task == null) { + call.respond(HttpStatusCode.NotFound) + } else { + call.respondTyped( + DraftRebaseJob( + baseGitCommit = task.key.importTaskKey.gitRevision, + gitBranchName = task.key.importTaskKey.gitBranchName, + active = when (task.getState()) { + TaskState.CREATED, TaskState.ACTIVE -> true + TaskState.CANCELLED -> false + TaskState.COMPLETED -> false + TaskState.UNKNOWN -> false + }, + errorMessage = task.getOutput()?.exceptionOrNull()?.stackTraceToString(), + ), + ) + } + } + } + + override suspend fun rebaseDraft( + draftId: String, + draftRebaseJob: DraftRebaseJob, + call: ApplicationCall, + ) { + val draft = data.getValue().drafts[draftId] + if (draft == null) { + call.respondText("Draft not found: $draftId", status = HttpStatusCode.NotFound) + } else { + manager.rebaseDraft( + draftId = draftId, + newGitCommitId = draftRebaseJob.baseGitCommit, + gitBranchName = draftRebaseJob.gitBranchName, + ) + call.respond(HttpStatusCode.OK) + } + } + }) + + modelixGitConnectorDraftsPreparationJobRoutes(object : ModelixGitConnectorDraftsPreparationJobController { + + suspend fun TypedApplicationCall.respondJob(task: DraftPreparationTask) { + respondTyped( + DraftPreparationJob( + active = when (task.getState()) { + TaskState.CREATED, TaskState.ACTIVE -> true + TaskState.CANCELLED, TaskState.COMPLETED, TaskState.UNKNOWN -> false + }, + errorMessage = task.getOutput()?.exceptionOrNull()?.stackTraceToString(), + ), + ) + } + + override suspend fun getDraftBranchPreparationJob( + draftId: String, + call: TypedApplicationCall, + ) { + val task = manager.draftPreparationTasks.getAll().lastOrNull { it.key.draftId == draftId } + if (task == null) { + call.respondText("Draft not found: $draftId", status = HttpStatusCode.NotFound) + } else { + call.respondJob(task) + } + } + + override suspend fun prepareDraftBranch( + draftId: String, + draftPreparationJob: DraftPreparationJob, + call: TypedApplicationCall, + ) { + val task = manager.getOrCreateDraftPreparationTask(draftId).also { it.launch() } + call.respondJob(task) + } + }) + } +} + +fun GitRepositoryConfigList.maskCredentials() = copy( + repositories = repositories.map { it.maskCredentials() }, +) +fun GitConnectorData.maskCredentials() = copy( + repositories = repositories.mapValues { it.value.maskCredentials() }, +) +fun GitRepositoryConfig.maskCredentials() = copy( + remotes = remotes?.map { it.maskCredentials() }, +) +fun GitRemoteConfig.maskCredentials() = copy( + credentials = null, +) + +fun GitRepositoryConfig?.merge(newData: GitRepositoryConfig): GitRepositoryConfig { + if (this == null) return newData + return copy( + name = newData.name ?: name, + remotes = (remotes ?: emptyList()).merge(newData.remotes ?: emptyList()), + ) +} + +fun List.merge(newData: List): List { + return newData.map { newConfig -> + val oldConfig = this.find { it.url == newConfig.url } + val mergedCredentials = (newConfig.credentials ?: oldConfig?.credentials).takeIf { newConfig.hasCredentials } + GitRemoteConfig( + name = newConfig.name, + url = newConfig.url, + credentials = mergedCredentials, + hasCredentials = newConfig.hasCredentials, + ) + } +} + +fun GitRepositoryConfigList.maskStatus(includeStatus: Boolean?): GitRepositoryConfigList { + if (includeStatus == true) return this + return copy( + repositories = repositories.map { it.maskStatus(includeStatus) }, + ) +} + +fun GitRepositoryConfig.maskStatus(includeStatus: Boolean?): GitRepositoryConfig { + if (includeStatus == true) return this + return copy(status = null) +} diff --git a/workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/GitConnectorManager.kt b/workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/GitConnectorManager.kt new file mode 100644 index 00000000..98539575 --- /dev/null +++ b/workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/GitConnectorManager.kt @@ -0,0 +1,138 @@ +package org.modelix.services.gitconnector + +import kotlinx.coroutines.CoroutineScope +import org.modelix.model.client2.IModelClientV2 +import org.modelix.model.lazy.RepositoryId +import org.modelix.services.gitconnector.stubs.models.DraftConfig +import org.modelix.services.gitconnector.stubs.models.GitBranchStatusData +import org.modelix.services.gitconnector.stubs.models.GitRepositoryConfig +import org.modelix.services.workspaces.FileSystemPersistence +import org.modelix.services.workspaces.PersistedState +import org.modelix.workspace.manager.KestraClient +import org.modelix.workspace.manager.ReusableTasks +import org.modelix.workspace.manager.SharedMutableState +import java.io.File + +class GitConnectorManager( + val scope: CoroutineScope, + val connectorData: SharedMutableState = PersistedState( + persistence = FileSystemPersistence( + file = File("/workspace-manager/config/git-connector.json"), + serializer = GitConnectorData.serializer(), + ), + defaultState = { GitConnectorData() }, + ).state, + val modelClient: IModelClientV2, + val kestraClient: KestraClient, +) { + + private val importTasks = ReusableTasks() + val draftPreparationTasks = ReusableTasks() + private val draftRebaseTasks = ReusableTasks() + + fun getOrCreateImportTask(gitRepoId: String, gitBranchName: String): GitImportTask { + val data = connectorData.getValue() + val repo = requireNotNull(data.repositories[gitRepoId]) { "Repository not found: $gitRepoId" } + val branch = requireNotNull(repo.status?.branches?.find { it.name == gitBranchName }) { + "Branch not found: $gitBranchName" + } + val gitRevision = requireNotNull(branch.gitCommitHash) { + "Git commit hash for branch unknown: $gitBranchName" + } + return getOrCreateImportTask(repo, gitBranchName, gitRevision) + } + + fun getOrCreateImportTask(gitRepo: GitRepositoryConfig, gitBranchName: String, gitRevision: String): GitImportTask { + val key = GitImportTask.Key( + repo = gitRepo.copy(status = null), + gitBranchName = gitBranchName, + gitRevision = gitRevision, + modelixBranchName = "git-import/$gitBranchName", + ) + return getOrCreateImportTask(key) + } + + fun getOrCreateImportTask(taskKey: GitImportTask.Key): GitImportTask { + return importTasks.getOrCreateTask(taskKey) { + GitImportTaskUsingKubernetesJob( + key = taskKey, + scope = scope, + modelClient = modelClient, + jwtUtil = kestraClient.jwtUtil, + ) + } + } + + fun getOrCreateDraftPreparationTask(draftId: String): DraftPreparationTask { + val key = DraftPreparationTask.Key( + draftId = draftId, + ) + + return draftPreparationTasks.getOrCreateTask(key) { + DraftPreparationTask( + scope = scope, + key = key, + gitManager = this, + modelClient = modelClient, + ) + } + } + + suspend fun updateRemoteBranches(repository: GitRepositoryConfig): List { + val gitRepositoryId = repository.id + val fetchTask = GitFetchTask(scope, repository) + val resultResult = fetchTask.waitForOutput() + return connectorData.update { + val oldRepositoryData = it.repositories[gitRepositoryId] ?: return@update it + val newRepositoryData = oldRepositoryData.merge(resultResult.remoteRefs) + it.copy(repositories = it.repositories + (gitRepositoryId to newRepositoryData)) + }.repositories[gitRepositoryId]?.status?.branches.orEmpty() + } + + fun getRepository(id: String): GitRepositoryConfig? { + return connectorData.getValue().repositories[id] + } + + fun getDraft(id: String) = connectorData.getValue().drafts[id] + + fun updateDraftConfig(draftId: String, updater: (DraftConfig) -> DraftConfig) { + connectorData.update { connectorData -> + connectorData.copy( + drafts = connectorData.drafts + (draftId to updater(connectorData.drafts.getValue(draftId))), + ) + } + } + + fun rebaseDraft(draftId: String, newGitCommitId: String, gitBranchName: String?): DraftRebaseTask { + val draft = requireNotNull(getDraft(draftId)) { "Draft not found: $draftId" } + val gitRepoConfig = requireNotNull(getRepository(draft.gitRepositoryId)) { + "Git repository config not found: ${draft.gitRepositoryId}" + } + val draftBranch = gitRepoConfig.getModelixRepositoryId().getBranchReference(draft.modelixBranchName) + val gitBranchName = gitBranchName ?: draft.gitBranchName + val key = DraftRebaseTask.Key( + importTaskKey = GitImportTask.Key( + repo = gitRepoConfig, + gitBranchName = gitBranchName, + gitRevision = newGitCommitId, + modelixBranchName = "git-import/$gitBranchName", + ), + draftId = draftId, + draftBranch = draftBranch, + ) + return draftRebaseTasks.getOrCreateTask(key) { + DraftRebaseTask( + key = key, + scope = scope, + gitManager = this, + modelClient = modelClient, + ) + }.also { it.launch() } + } + + fun getRebaseTask(draftId: String): DraftRebaseTask? { + return draftRebaseTasks.getAll().lastOrNull { it.key.draftId == draftId } + } +} + +fun GitRepositoryConfig.getModelixRepositoryId() = RepositoryId((modelixRepository ?: id)) diff --git a/workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/GitFetchTask.kt b/workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/GitFetchTask.kt new file mode 100644 index 00000000..58a04a8f --- /dev/null +++ b/workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/GitFetchTask.kt @@ -0,0 +1,75 @@ +package org.modelix.services.gitconnector + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.eclipse.jgit.api.Git +import org.modelix.services.gitconnector.stubs.models.GitBranchStatusData +import org.modelix.services.gitconnector.stubs.models.GitRepositoryConfig +import org.modelix.services.gitconnector.stubs.models.GitRepositoryStatusData +import org.modelix.workspace.manager.TaskInstance + +class GitFetchTask(scope: CoroutineScope, val repo: GitRepositoryConfig) : TaskInstance(scope) { + override suspend fun process(): FetchResult { + val fetchedBranches = ArrayList() + + for (remoteConfig in (repo.remotes ?: emptyList())) { + val cmd = Git.lsRemoteRepository() + cmd.setRemote(remoteConfig.url) + + val username = remoteConfig.credentials?.username.orEmpty() + val password = remoteConfig.credentials?.password.orEmpty() + if (password.isNotEmpty()) { + cmd.applyCredentials(username, password) + } + + val refs = withContext(Dispatchers.IO) { + cmd.call() + } + + for (ref in refs) { + if (!ref.name.startsWith("refs/heads/")) continue + val branchName = ref.name.removePrefix("refs/heads/") + fetchedBranches.add( + FetchedBranch( + remoteName = remoteConfig.name, + branchName = branchName, + commitHash = ref.objectId.name, + ), + ) + } + } + + return FetchResult(remoteRefs = fetchedBranches) + } +} + +data class FetchResult( + val remoteRefs: List, +) + +data class FetchedBranch( + val remoteName: String, + val branchName: String, + val commitHash: String, +) + +private fun GitBranchStatusData.key() = remoteRepositoryName to name + +fun GitRepositoryConfig.merge(newRefs: List): GitRepositoryConfig { + val oldBranchStatusMap = (status?.branches ?: emptyList()).associateBy { it.key() } + val newBranchStatusMap = mutableMapOf, GitBranchStatusData>() + for (ref in newRefs) { + val branchKey = ref.remoteName to ref.branchName + val branchStatus = oldBranchStatusMap[branchKey] + ?: GitBranchStatusData(remoteRepositoryName = ref.remoteName, name = ref.branchName) + newBranchStatusMap[branchKey] = branchStatus.copy( + gitCommitHash = ref.commitHash, + ) + } + return copy( + status = (status ?: GitRepositoryStatusData()).copy( + branches = newBranchStatusMap.values.toList().sortedWith { a, b -> (a.name).compareTo(b.name, ignoreCase = true) }, + ), + ) +} diff --git a/workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/GitImportTask.kt b/workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/GitImportTask.kt new file mode 100644 index 00000000..af95040b --- /dev/null +++ b/workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/GitImportTask.kt @@ -0,0 +1,145 @@ +package org.modelix.services.gitconnector + +import io.kubernetes.client.openapi.models.V1Container +import io.kubernetes.client.openapi.models.V1Job +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import org.modelix.authorization.ModelixJWTUtil +import org.modelix.model.IVersion +import org.modelix.model.client2.IModelClientV2 +import org.modelix.model.lazy.RepositoryId +import org.modelix.model.server.ModelServerPermissionSchema +import org.modelix.services.gitconnector.stubs.models.GitRepositoryConfig +import org.modelix.services.workspaces.metadata +import org.modelix.services.workspaces.spec +import org.modelix.services.workspaces.template +import org.modelix.workspace.manager.ITaskInstance +import org.modelix.workspace.manager.KestraClient +import org.modelix.workspace.manager.KubernetesJobTask +import org.modelix.workspace.manager.TaskInstance +import kotlin.time.Duration.Companion.seconds + +interface GitImportTask : ITaskInstance { + data class Key( + val repo: GitRepositoryConfig, + val gitBranchName: String, + val gitRevision: String, + val modelixBranchName: String, + ) +} + +class GitImportTaskUsingKubernetesJob( + val key: GitImportTask.Key, + scope: CoroutineScope, + val modelClient: IModelClientV2, + val jwtUtil: ModelixJWTUtil, +) : GitImportTask, KubernetesJobTask(scope) { + + private val repoId = requireNotNull(key.repo.modelixRepository?.let { RepositoryId(it) }) { "Repository ID missing" } + private val branchRef = repoId.getBranchReference(key.modelixBranchName) + private suspend fun modelixBranchExists() = modelClient.listBranches(repoId).contains(branchRef) + + override suspend fun tryGetResult(): IVersion? { + return if (modelixBranchExists()) { + return modelClient.lazyLoadVersion(branchRef).takeIf { it.gitCommit == key.gitRevision } + } else { + return null + } + } + + @Suppress("ktlint") + override fun generateJobYaml(): V1Job { + val remote = requireNotNull(key.repo.remotes?.firstOrNull()) { "No remotes specified" } + val modelixBranch = + RepositoryId((key.repo.modelixRepository ?: key.repo.id)).getBranchReference("git-import/${key.gitBranchName}") + val token = jwtUtil.createAccessToken( + "git-import@modelix.org", + listOf( + ModelServerPermissionSchema.repository(modelixBranch.repositoryId).create.fullId, + ModelServerPermissionSchema.repository(modelixBranch.repositoryId).read.fullId, + ModelServerPermissionSchema.branch(modelixBranch).rewrite.fullId, + ), + ) + + return V1Job().apply { + metadata { + name = "gitimportjob-$id" + } + spec { + template { + spec { + addContainersItem(V1Container().apply { + name = "importer" + image = System.getenv("GIT_IMPORT_IMAGE") + args = listOf( + "git-import-remote", + remote.url, + "--git-user", + remote.credentials?.username, + "--git-password", + remote.credentials?.password, + "--limit", + "50", + "--model-server", + System.getenv("model_server_url"), + "--token", + token, + "--repository", + modelixBranch.repositoryId.id, + "--branch", + modelixBranch.branchName, + "--rev", + key.gitRevision, + ) + }) + } + } + } + } + } +} + +class GitImportTaskUsingKestra( + val key: GitImportTask.Key, + scope: CoroutineScope, + val kestraClient: KestraClient, + val modelClient: IModelClientV2, +) : GitImportTask, TaskInstance(scope) { + + private val repoId = requireNotNull(key.repo.modelixRepository?.let { RepositoryId(it) }) { "Repository ID missing" } + private val branchRef = repoId.getBranchReference(key.modelixBranchName) + private val jobLabels = mapOf("taskId" to id.toString()) + private suspend fun modelixBranchExists() = modelClient.listBranches(repoId).contains(branchRef) + private suspend fun jobIsRunning() = kestraClient.getRunningImportJobIds(jobLabels).isNotEmpty() + + override suspend fun process(): IVersion { + if (modelixBranchExists()) { + return modelClient.lazyLoadVersion(branchRef) + } + + val remote = requireNotNull(key.repo.remotes?.firstOrNull()) { "No remotes specified" } + val modelixBranch = + RepositoryId((key.repo.modelixRepository ?: key.repo.id)).getBranchReference("git-import/${key.gitBranchName}") + kestraClient.enqueueGitImport( + gitRepoUrl = remote.url, + gitUser = remote.credentials?.username, + gitPassword = remote.credentials?.password, + gitRevision = key.gitRevision, + modelixBranch = modelixBranch, + labels = jobLabels, + ) + + while (true) { + if (modelixBranchExists()) { + val version = modelClient.lazyLoadVersion(modelixBranch) + if (version.gitCommit == key.gitRevision) { + return version + } + } + check(jobIsRunning()) { "Import failed" } + delay(3.seconds) + } + } +} + +val IVersion.gitCommit: String? get() = getAttributes()["git-commit"] diff --git a/workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/JGitExtensions.kt b/workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/JGitExtensions.kt new file mode 100644 index 00000000..39a302eb --- /dev/null +++ b/workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/JGitExtensions.kt @@ -0,0 +1,65 @@ +package org.modelix.services.gitconnector + +import io.ktor.util.encodeBase64 +import org.eclipse.jgit.api.GitCommand +import org.eclipse.jgit.api.TransportCommand +import org.eclipse.jgit.transport.Transport +import org.eclipse.jgit.transport.TransportHttp +import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider +import org.eclipse.jgit.transport.http.HttpConnection +import org.eclipse.jgit.transport.http.HttpConnectionFactory +import org.eclipse.jgit.transport.http.JDKHttpConnection +import org.eclipse.jgit.transport.http.JDKHttpConnectionFactory +import java.net.Authenticator +import java.net.HttpURLConnection +import java.net.PasswordAuthentication +import java.net.Proxy +import java.net.URL + +fun , T, E : TransportCommand> E.applyCredentials(username: String, password: String): E { + val cmd = this + cmd.setCredentialsProvider(UsernamePasswordCredentialsProvider(username, password)) + cmd.setTransportConfigCallback { transport -> + if (transport is TransportHttp) { + // https://learn.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=Linux#use-a-pat + transport.setAdditionalHeaders( + mapOf( + "Authorization" to "Basic ${(username.orEmpty() + ":" + password).encodeBase64()}", + ), + ) + } + transport?.setAuthenticator(object : Authenticator() { + override fun getPasswordAuthentication(): PasswordAuthentication { + return PasswordAuthentication(username, password.toCharArray()) + } + }) + } + return cmd +} + +/** + * The credentialsProvider only works with WWW-Authenticate: Basic, but not with WWW-Authenticate: Negotiate. + * This is handled by the JDK. + */ +private fun Transport.setAuthenticator(authenticator: Authenticator) { + val transport = this as TransportHttp + val originalFactory = transport.httpConnectionFactory as JDKHttpConnectionFactory + transport.httpConnectionFactory = object : HttpConnectionFactory { + override fun create(url: URL?): HttpConnection { + return modify(originalFactory.create(url)) + } + + override fun create(url: URL?, proxy: Proxy?): HttpConnection { + return modify(originalFactory.create(url, proxy)) + } + + fun modify(conn: HttpConnection): HttpConnection { + val jdkConn = conn as JDKHttpConnection + val field = jdkConn.javaClass.getDeclaredField("wrappedUrlConnection") + field.isAccessible = true + val wrapped = field.get(jdkConn) as HttpURLConnection + wrapped.setAuthenticator(authenticator) + return conn + } + } +} diff --git a/workspace-manager/src/main/kotlin/org/modelix/services/workspaces/IPersistedState.kt b/workspace-manager/src/main/kotlin/org/modelix/services/workspaces/IPersistedState.kt new file mode 100644 index 00000000..05550671 --- /dev/null +++ b/workspace-manager/src/main/kotlin/org/modelix/services/workspaces/IPersistedState.kt @@ -0,0 +1,44 @@ +package org.modelix.services.workspaces + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.Json +import org.modelix.workspace.manager.SharedMutableState + +interface IStatePersistence { + fun load(): E? + fun save(value: E) +} + +class FileSystemPersistence(val file: java.io.File, val serializer: KSerializer) : IStatePersistence { + override fun load(): E? { + if (!file.exists()) return null + return try { + val json = file.readText() + if (json.isEmpty()) return null + Json.decodeFromString(serializer, json) + } catch (e: Exception) { + LOG.warn("Failed to load state from ${file.absolutePath}", e) + null + } + } + + override fun save(value: E) { + try { + file.writeText(Json.encodeToString(serializer, value)) + } catch (e: Exception) { + LOG.warn("Failed to save state to ${file.absolutePath}", e) + } + } + + companion object { + private val LOG = mu.KotlinLogging.logger {} + } +} + +class PersistedState(persistence: IStatePersistence, defaultState: () -> E) { + val state: SharedMutableState = SharedMutableState(persistence.load() ?: defaultState()).also { + it.addListener { + persistence.save(it) + } + } +} diff --git a/workspace-manager/src/main/kotlin/org/modelix/services/workspaces/InternalWorkspaceInstanceConfig.kt b/workspace-manager/src/main/kotlin/org/modelix/services/workspaces/InternalWorkspaceInstanceConfig.kt new file mode 100644 index 00000000..81806765 --- /dev/null +++ b/workspace-manager/src/main/kotlin/org/modelix/services/workspaces/InternalWorkspaceInstanceConfig.kt @@ -0,0 +1,12 @@ +package org.modelix.services.workspaces + +import org.modelix.services.workspaces.stubs.models.WorkspaceInstance +import org.modelix.workspaces.InternalWorkspaceConfig + +data class InternalWorkspaceInstanceConfig( + val instanceConfig: WorkspaceInstance, + val workspaceConfig: InternalWorkspaceConfig, +) { + val instanceId: String get() = instanceConfig.id + val workspaceId: String get() = workspaceConfig.id +} diff --git a/workspace-manager/src/main/kotlin/org/modelix/services/workspaces/KubernetesApiExtensions.kt b/workspace-manager/src/main/kotlin/org/modelix/services/workspaces/KubernetesApiExtensions.kt new file mode 100644 index 00000000..a848a088 --- /dev/null +++ b/workspace-manager/src/main/kotlin/org/modelix/services/workspaces/KubernetesApiExtensions.kt @@ -0,0 +1,161 @@ +package org.modelix.services.workspaces + +import io.kubernetes.client.common.KubernetesObject +import io.kubernetes.client.openapi.ApiCallback +import io.kubernetes.client.openapi.ApiException +import io.kubernetes.client.openapi.apis.AppsV1Api +import io.kubernetes.client.openapi.apis.CoreV1Api +import io.kubernetes.client.openapi.models.V1Deployment +import io.kubernetes.client.openapi.models.V1DeploymentList +import io.kubernetes.client.openapi.models.V1DeploymentSpec +import io.kubernetes.client.openapi.models.V1Job +import io.kubernetes.client.openapi.models.V1JobSpec +import io.kubernetes.client.openapi.models.V1ObjectMeta +import io.kubernetes.client.openapi.models.V1PodSpec +import io.kubernetes.client.openapi.models.V1PodTemplateSpec +import io.kubernetes.client.openapi.models.V1ServiceList +import kotlin.coroutines.Continuation +import kotlin.coroutines.suspendCoroutine + +fun KubernetesObject.metadata(body: V1ObjectMeta.() -> Unit): V1ObjectMeta { + return (metadata ?: V1ObjectMeta().also { setMetadata(it) }).apply(body) +} +fun V1PodTemplateSpec.metadata(body: V1ObjectMeta.() -> Unit): V1ObjectMeta { + return (metadata ?: V1ObjectMeta().also { setMetadata(it) }).apply(body) +} + +fun KubernetesObject.setMetadata(data: V1ObjectMeta) { + when (this) { + is io.kubernetes.client.openapi.models.AuthenticationV1TokenRequest -> metadata = data + is io.kubernetes.client.openapi.models.CoreV1Event -> metadata = data + is io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject -> metadata = data + is io.kubernetes.client.openapi.models.EventsV1Event -> metadata = data + is io.kubernetes.client.custom.NodeMetrics -> metadata = data + is io.kubernetes.client.custom.PodMetrics -> metadata = data + is io.kubernetes.client.openapi.models.V1alpha1ClusterTrustBundle -> metadata = data + is io.kubernetes.client.openapi.models.V1alpha1MutatingAdmissionPolicy -> metadata = data + is io.kubernetes.client.openapi.models.V1alpha1MutatingAdmissionPolicyBinding -> metadata = data + is io.kubernetes.client.openapi.models.V1alpha1StorageVersion -> metadata = data + is io.kubernetes.client.openapi.models.V1alpha1StorageVersionMigration -> metadata = data + is io.kubernetes.client.openapi.models.V1alpha1VolumeAttributesClass -> metadata = data + is io.kubernetes.client.openapi.models.V1alpha2LeaseCandidate -> metadata = data + is io.kubernetes.client.openapi.models.V1alpha3DeviceClass -> metadata = data + is io.kubernetes.client.openapi.models.V1alpha3ResourceClaim -> metadata = data + is io.kubernetes.client.openapi.models.V1alpha3ResourceClaimTemplate -> metadata = data + is io.kubernetes.client.openapi.models.V1alpha3ResourceSlice -> metadata = data + is io.kubernetes.client.openapi.models.V1APIService -> metadata = data + is io.kubernetes.client.openapi.models.V1beta1DeviceClass -> metadata = data + is io.kubernetes.client.openapi.models.V1beta1IPAddress -> metadata = data + is io.kubernetes.client.openapi.models.V1beta1ResourceClaim -> metadata = data + is io.kubernetes.client.openapi.models.V1beta1ResourceClaimTemplate -> metadata = data + is io.kubernetes.client.openapi.models.V1beta1ResourceSlice -> metadata = data + is io.kubernetes.client.openapi.models.V1beta1SelfSubjectReview -> metadata = data + is io.kubernetes.client.openapi.models.V1beta1ServiceCIDR -> metadata = data + is io.kubernetes.client.openapi.models.V1beta1ValidatingAdmissionPolicy -> metadata = data + is io.kubernetes.client.openapi.models.V1beta1ValidatingAdmissionPolicyBinding -> metadata = data + is io.kubernetes.client.openapi.models.V1beta1VolumeAttributesClass -> metadata = data + is io.kubernetes.client.openapi.models.V1Binding -> metadata = data + is io.kubernetes.client.openapi.models.V1CertificateSigningRequest -> metadata = data + is io.kubernetes.client.openapi.models.V1ClusterRole -> metadata = data + is io.kubernetes.client.openapi.models.V1ClusterRoleBinding -> metadata = data + is io.kubernetes.client.openapi.models.V1ComponentStatus -> metadata = data + is io.kubernetes.client.openapi.models.V1ConfigMap -> metadata = data + is io.kubernetes.client.openapi.models.V1ControllerRevision -> metadata = data + is io.kubernetes.client.openapi.models.V1CronJob -> metadata = data + is io.kubernetes.client.openapi.models.V1CSIDriver -> metadata = data + is io.kubernetes.client.openapi.models.V1CSINode -> metadata = data + is io.kubernetes.client.openapi.models.V1CSIStorageCapacity -> metadata = data + is io.kubernetes.client.openapi.models.V1CustomResourceDefinition -> metadata = data + is io.kubernetes.client.openapi.models.V1DaemonSet -> metadata = data + is io.kubernetes.client.openapi.models.V1Deployment -> metadata = data + is io.kubernetes.client.openapi.models.V1Endpoints -> metadata = data + is io.kubernetes.client.openapi.models.V1EndpointSlice -> metadata = data + is io.kubernetes.client.openapi.models.V1Eviction -> metadata = data + is io.kubernetes.client.openapi.models.V1FlowSchema -> metadata = data + is io.kubernetes.client.openapi.models.V1HorizontalPodAutoscaler -> metadata = data + is io.kubernetes.client.openapi.models.V1Ingress -> metadata = data + is io.kubernetes.client.openapi.models.V1IngressClass -> metadata = data + is io.kubernetes.client.openapi.models.V1Job -> metadata = data + is io.kubernetes.client.openapi.models.V1Lease -> metadata = data + is io.kubernetes.client.openapi.models.V1LimitRange -> metadata = data + is io.kubernetes.client.openapi.models.V1LocalSubjectAccessReview -> metadata = data + is io.kubernetes.client.openapi.models.V1MutatingWebhookConfiguration -> metadata = data + is io.kubernetes.client.openapi.models.V1Namespace -> metadata = data + is io.kubernetes.client.openapi.models.V1NetworkPolicy -> metadata = data + is io.kubernetes.client.openapi.models.V1Node -> metadata = data + is io.kubernetes.client.openapi.models.V1PersistentVolume -> metadata = data + is io.kubernetes.client.openapi.models.V1PersistentVolumeClaim -> metadata = data + is io.kubernetes.client.openapi.models.V1Pod -> metadata = data + is io.kubernetes.client.openapi.models.V1PodDisruptionBudget -> metadata = data + is io.kubernetes.client.openapi.models.V1PodTemplate -> metadata = data + is io.kubernetes.client.openapi.models.V1PriorityClass -> metadata = data + is io.kubernetes.client.openapi.models.V1PriorityLevelConfiguration -> metadata = data + is io.kubernetes.client.openapi.models.V1ReplicaSet -> metadata = data + is io.kubernetes.client.openapi.models.V1ReplicationController -> metadata = data + is io.kubernetes.client.openapi.models.V1ResourceQuota -> metadata = data + is io.kubernetes.client.openapi.models.V1Role -> metadata = data + is io.kubernetes.client.openapi.models.V1RoleBinding -> metadata = data + is io.kubernetes.client.openapi.models.V1RuntimeClass -> metadata = data + is io.kubernetes.client.openapi.models.V1Scale -> metadata = data + is io.kubernetes.client.openapi.models.V1Secret -> metadata = data + is io.kubernetes.client.openapi.models.V1SelfSubjectAccessReview -> metadata = data + is io.kubernetes.client.openapi.models.V1SelfSubjectReview -> metadata = data + is io.kubernetes.client.openapi.models.V1SelfSubjectRulesReview -> metadata = data + is io.kubernetes.client.openapi.models.V1Service -> metadata = data + is io.kubernetes.client.openapi.models.V1ServiceAccount -> metadata = data + is io.kubernetes.client.openapi.models.V1StatefulSet -> metadata = data + is io.kubernetes.client.openapi.models.V1StorageClass -> metadata = data + is io.kubernetes.client.openapi.models.V1SubjectAccessReview -> metadata = data + is io.kubernetes.client.openapi.models.V1TokenReview -> metadata = data + is io.kubernetes.client.openapi.models.V1ValidatingAdmissionPolicy -> metadata = data + is io.kubernetes.client.openapi.models.V1ValidatingAdmissionPolicyBinding -> metadata = data + is io.kubernetes.client.openapi.models.V1ValidatingWebhookConfiguration -> metadata = data + is io.kubernetes.client.openapi.models.V1VolumeAttachment -> metadata = data + is io.kubernetes.client.openapi.models.V2HorizontalPodAutoscaler -> metadata = data + else -> throw UnsupportedOperationException("Unknown object type: $this") + } +} + +fun V1Deployment.spec(body: V1DeploymentSpec.() -> Unit): V1DeploymentSpec { + return (spec ?: V1DeploymentSpec().also { spec = it }).apply(body) +} + +fun V1Job.spec(body: V1JobSpec.() -> Unit): V1JobSpec { + return (spec ?: V1JobSpec().also { spec = it }).apply(body) +} + +fun V1JobSpec.template(body: V1PodTemplateSpec.() -> Unit): V1PodTemplateSpec { + return (template ?: V1PodTemplateSpec().also { template = it }).apply(body) +} + +fun V1PodTemplateSpec.spec(body: V1PodSpec.() -> Unit): V1PodSpec { + return (spec ?: V1PodSpec().also { spec = it }).apply(body) +} + +class ContinuingCallback(val continuation: Continuation) : ApiCallback { + override fun onDownloadProgress(p0: Long, p1: Long, p2: Boolean) {} + override fun onUploadProgress(p0: Long, p1: Long, p2: Boolean) {} + + override fun onFailure( + ex: ApiException, + p1: Int, + p2: Map?>?, + ) { + continuation.resumeWith(Result.failure(ex)) + } + + override fun onSuccess( + returnedValue: T, + p1: Int, + p2: Map?>?, + ) { + continuation.resumeWith(Result.success(returnedValue)) + } +} + +suspend fun AppsV1Api.APIcreateNamespacedDeploymentRequest.executeSuspending(): V1Deployment = + suspendCoroutine { executeAsync(ContinuingCallback(it)) } +suspend fun CoreV1Api.APIlistNamespacedServiceRequest.executeSuspending(): V1ServiceList = + suspendCoroutine { executeAsync(ContinuingCallback(it)) } +suspend fun AppsV1Api.APIlistNamespacedDeploymentRequest.executeSuspending(): V1DeploymentList = + suspendCoroutine { executeAsync(ContinuingCallback(it)) } diff --git a/workspace-manager/src/main/kotlin/org/modelix/services/workspaces/WorkspaceConfigExtensions.kt b/workspace-manager/src/main/kotlin/org/modelix/services/workspaces/WorkspaceConfigExtensions.kt new file mode 100644 index 00000000..33dcbba4 --- /dev/null +++ b/workspace-manager/src/main/kotlin/org/modelix/services/workspaces/WorkspaceConfigExtensions.kt @@ -0,0 +1,32 @@ +package org.modelix.services.workspaces + +import io.kubernetes.client.custom.Quantity +import kotlinx.serialization.json.Json +import org.modelix.model.persistent.HashUtil +import org.modelix.services.workspaces.stubs.models.WorkspaceConfig + +fun WorkspaceConfig.hash(): String = HashUtil.sha256(Json.encodeToString(this)) + +fun WorkspaceConfig.normalizeForBuild() = copy( + name = "", + memoryLimit = "", + runConfig = null, +) + +fun WorkspaceConfig.hashForBuild(): String = normalizeForBuild().hash() + +fun String.toValidImageTag() = replace("*", "") + +fun WorkspaceConfig.merge(other: WorkspaceConfig) = copy( + name = other.name.takeIf { it.isNotEmpty() } ?: name, + mpsVersion = other.validMPSVersion() ?: mpsVersion, + memoryLimit = other.validMemoryLimit() ?: memoryLimit, + gitRepositoryIds = other.gitRepositoryIds ?: gitRepositoryIds, + mavenRepositories = other.mavenRepositories ?: mavenRepositories, + mavenArtifacts = other.mavenArtifacts ?: mavenArtifacts, + buildConfig = other.buildConfig ?: buildConfig, + runConfig = other.runConfig ?: runConfig, +) + +fun WorkspaceConfig.validMPSVersion() = mpsVersion.takeIf { it.isNotEmpty() } +fun WorkspaceConfig.validMemoryLimit() = runCatching { Quantity.fromString(memoryLimit).toSuffixedString() }.getOrNull() diff --git a/workspace-manager/src/main/kotlin/org/modelix/services/workspaces/WorkspaceConfigForBuildExtensions.kt b/workspace-manager/src/main/kotlin/org/modelix/services/workspaces/WorkspaceConfigForBuildExtensions.kt new file mode 100644 index 00000000..8972c5dd --- /dev/null +++ b/workspace-manager/src/main/kotlin/org/modelix/services/workspaces/WorkspaceConfigForBuildExtensions.kt @@ -0,0 +1,37 @@ +package org.modelix.services.workspaces + +import io.kubernetes.client.custom.Quantity +import org.modelix.services.gitconnector.GitConnectorManager +import org.modelix.services.workspaces.stubs.models.WorkspaceInstance +import org.modelix.workspaces.DEFAULT_MPS_VERSION +import org.modelix.workspaces.GitConfigForBuild +import org.modelix.workspaces.MavenRepositoryForBuild +import org.modelix.workspaces.WorkspaceConfigForBuild + +fun WorkspaceInstance.configForBuild(gitManager: GitConnectorManager) = WorkspaceConfigForBuild( + id = config.id, + mpsVersion = config.validMPSVersion() ?: DEFAULT_MPS_VERSION, + gitRepositories = drafts.orEmpty().mapNotNull { gitManager.getDraft(it) }.mapNotNull { draft -> + val repo = gitManager.getRepository(draft.gitRepositoryId) ?: return@mapNotNull null + val remote = repo.remotes?.firstOrNull() ?: return@mapNotNull null + GitConfigForBuild( + url = remote.url, + username = remote.credentials?.username, + password = remote.credentials?.password, + branch = draft.gitBranchName, + commitHash = draft.baseGitCommit, + ) + }.toSet(), + memoryLimit = Quantity.fromString(config.validMemoryLimit() ?: "2G").number.toLong(), + mavenRepositories = config.mavenRepositories.orEmpty().map { + MavenRepositoryForBuild( + url = it.url, + username = null, + password = null, + ) + }.toSet(), + mavenArtifacts = config.mavenArtifacts.orEmpty().map { "${it.groupId}:${it.artifactId}:${it.version ?: "*"}" }.toSet(), + ignoredModules = config.buildConfig?.ignoredModules.orEmpty().toSet(), + additionalGenerationDependencies = config.buildConfig?.additionalGenerationDependencies.orEmpty().map { it.from to it.to }.toSet(), + loadUsedModulesOnly = config.runConfig?.loadUsedModulesOnly ?: false, +) diff --git a/workspace-manager/src/main/kotlin/org/modelix/services/workspaces/WorkspacesController.kt b/workspace-manager/src/main/kotlin/org/modelix/services/workspaces/WorkspacesController.kt new file mode 100644 index 00000000..c4dde043 --- /dev/null +++ b/workspace-manager/src/main/kotlin/org/modelix/services/workspaces/WorkspacesController.kt @@ -0,0 +1,358 @@ +package org.modelix.services.workspaces + +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.ApplicationCall +import io.ktor.server.auth.jwt.JWTPrincipal +import io.ktor.server.auth.principal +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.util.encodeBase64 +import io.kubernetes.client.custom.Quantity +import org.modelix.authorization.getUserName +import org.modelix.services.gitconnector.GitConnectorManager +import org.modelix.services.workspaces.stubs.controllers.ModelixWorkspacesInstancesController +import org.modelix.services.workspaces.stubs.controllers.ModelixWorkspacesInstancesController.Companion.modelixWorkspacesInstancesRoutes +import org.modelix.services.workspaces.stubs.controllers.ModelixWorkspacesInstancesEnabledController +import org.modelix.services.workspaces.stubs.controllers.ModelixWorkspacesInstancesEnabledController.Companion.modelixWorkspacesInstancesEnabledRoutes +import org.modelix.services.workspaces.stubs.controllers.ModelixWorkspacesInstancesStateController +import org.modelix.services.workspaces.stubs.controllers.ModelixWorkspacesInstancesStateController.Companion.modelixWorkspacesInstancesStateRoutes +import org.modelix.services.workspaces.stubs.controllers.ModelixWorkspacesTasksConfigController +import org.modelix.services.workspaces.stubs.controllers.ModelixWorkspacesTasksConfigController.Companion.modelixWorkspacesTasksConfigRoutes +import org.modelix.services.workspaces.stubs.controllers.ModelixWorkspacesTasksContextTarGzController +import org.modelix.services.workspaces.stubs.controllers.ModelixWorkspacesTasksContextTarGzController.Companion.modelixWorkspacesTasksContextTarGzRoutes +import org.modelix.services.workspaces.stubs.controllers.ModelixWorkspacesWorkspacesController +import org.modelix.services.workspaces.stubs.controllers.ModelixWorkspacesWorkspacesController.Companion.modelixWorkspacesWorkspacesRoutes +import org.modelix.services.workspaces.stubs.controllers.TypedApplicationCall +import org.modelix.services.workspaces.stubs.models.WorkspaceConfig +import org.modelix.services.workspaces.stubs.models.WorkspaceInstance +import org.modelix.services.workspaces.stubs.models.WorkspaceInstanceEnabled +import org.modelix.services.workspaces.stubs.models.WorkspaceInstanceList +import org.modelix.services.workspaces.stubs.models.WorkspaceInstanceState +import org.modelix.services.workspaces.stubs.models.WorkspaceInstanceStateObject +import org.modelix.services.workspaces.stubs.models.WorkspaceList +import org.modelix.workspace.manager.WorkspaceBuildManager +import org.modelix.workspace.manager.WorkspaceInstancesManager +import org.modelix.workspace.manager.WorkspaceJobQueue +import org.modelix.workspace.manager.WorkspaceManager +import org.modelix.workspace.manager.heapSizeFromContainerLimit +import org.modelix.workspace.manager.putFile +import org.modelix.workspace.manager.respondTarGz +import org.modelix.workspaces.DEFAULT_MPS_VERSION +import org.modelix.workspaces.WorkspaceConfigForBuild +import org.modelix.workspaces.WorkspaceProgressItems +import org.modelix.workspaces.WorkspacesPermissionSchema +import java.util.UUID + +class WorkspacesController( + val manager: WorkspaceManager, + val instancesManager: WorkspaceInstancesManager, + val buildManager: WorkspaceBuildManager, + val gitConnectorManager: GitConnectorManager, +) { + + fun install(route: Route) { + route.install_() + } + + private fun Route.install_() { + modelixWorkspacesWorkspacesRoutes(object : ModelixWorkspacesWorkspacesController { + override suspend fun getWorkspace( + workspaceId: String, + call: TypedApplicationCall, + ) { + val workspace = manager.getWorkspace(workspaceId) + if (workspace == null) { + call.respond(HttpStatusCode.NotFound) + } else { + call.respondTyped(workspace) + } + } + + override suspend fun listWorkspaces(call: TypedApplicationCall) { + call.respondTyped(WorkspaceList(workspaces = manager.getAllWorkspaces())) + } + + override suspend fun deleteWorkspace( + workspaceId: String, + call: ApplicationCall, + ) { + manager.removeWorkspace(workspaceId) + call.respond(HttpStatusCode.OK) + } + + override suspend fun updateWorkspace( + workspaceId: String, + workspaceConfig: WorkspaceConfig, + call: ApplicationCall, + ) { + manager.updateWorkspace(workspaceId) { oldConfig -> + workspaceConfig.copy(id = workspaceId) + } + call.respond(HttpStatusCode.OK) + } + + override suspend fun createWorkspace( + workspaceConfig: WorkspaceConfig, + call: TypedApplicationCall, + ) { + val newWorkspace = workspaceConfig.copy( + id = UUID.randomUUID().toString(), + mpsVersion = workspaceConfig.mpsVersion.takeIf { it.isNotEmpty() } ?: DEFAULT_MPS_VERSION, + memoryLimit = workspaceConfig.memoryLimit?.takeIf { it.isNotEmpty() }?.let { + runCatching { + Quantity( + it, + ).toSuffixedString() + }.getOrNull() + } ?: "2Gi", + ) + manager.putWorkspace(newWorkspace) + call.getUserName()?.let { manager.assignOwner(newWorkspace.id, it) } + call.respondTyped(newWorkspace) + } + }) + + modelixWorkspacesInstancesRoutes(object : ModelixWorkspacesInstancesController { + override suspend fun getInstance( + instanceId: String, + call: TypedApplicationCall, + ) { + val instance = instancesManager.getInstancesMap()[instanceId] + if (instance == null) { + call.respond(HttpStatusCode.NotFound) + } else { + call.respondTyped(instance) + } + } + + override suspend fun listInstances(workspaceId: String?, call: TypedApplicationCall) { + val allInstances = instancesManager.getInstancesMap().values + val filteredInstances = if (workspaceId != null) { + allInstances.filter { it.config.id == workspaceId } + } else { + allInstances + } + val states = instancesManager.getInstanceStates() + call.respondTyped( + WorkspaceInstanceList( + instances = filteredInstances.map { + it.copy( + state = states[it.id]?.deriveState() ?: WorkspaceInstanceState.UNKNOWN, + statusText = states[it.id]?.statusText(), + ) + }, + ), + ) + } + + override suspend fun createInstance( + workspaceInstance: WorkspaceInstance, + call: TypedApplicationCall, + ) { + var readonly = workspaceInstance.readonly ?: false + if (readonly == false) { + val token = call.principal()?.payload + if (token == null) { + readonly = true + } else { + val permissionEvaluator = manager.jwtUtil.createPermissionEvaluator(token, WorkspacesPermissionSchema.SCHEMA) + manager.accessControlPersistence.read().load(token, permissionEvaluator) + if (!permissionEvaluator.hasPermission(WorkspacesPermissionSchema.workspaces.workspace(workspaceInstance.config.id).modelRepository.write)) { + readonly = true + } + } + } + + val id = UUID.randomUUID().toString() + instancesManager.updateInstancesMap { instances -> + instances.plus( + id to workspaceInstance.copy( + id = id, + owner = call.getUserName(), + state = WorkspaceInstanceState.CREATED, + readonly = readonly, + ), + ) + } + call.respondTyped(instancesManager.getInstancesMap().getValue(id)) + } + + override suspend fun deleteInstance( + instanceId: String, + call: ApplicationCall, + ) { + instancesManager.updateInstancesMap { it - instanceId } + call.respond(HttpStatusCode.OK) + } + }) + + modelixWorkspacesInstancesEnabledRoutes(object : ModelixWorkspacesInstancesEnabledController { + override suspend fun enableInstance( + instanceId: String, + workspaceInstanceEnabled: WorkspaceInstanceEnabled, + call: ApplicationCall, + ) { + instancesManager.updateInstancesMap { instances -> + instances.plus(instanceId to instances.getValue(instanceId).copy(enabled = workspaceInstanceEnabled.enabled)) + } + call.respond(HttpStatusCode.OK) + } + }) + + modelixWorkspacesTasksConfigRoutes(object : ModelixWorkspacesTasksConfigController { + override suspend fun getWorkspaceByTaskId( + taskId: String, + call: TypedApplicationCall, + ) { + val config = buildManager.getWorkspaceConfigByTaskId(UUID.fromString(taskId)) + if (config == null) { + call.respond(HttpStatusCode.NotFound) + } else { + call.respond(config) + } + } + }) + + modelixWorkspacesTasksContextTarGzRoutes(object : ModelixWorkspacesTasksContextTarGzController { + override suspend fun getContextForTaskId( + taskId: String, + call: TypedApplicationCall, + ) { + val taskUUID = UUID.fromString(taskId) + val config = buildManager.getWorkspaceConfigByTaskId(taskUUID) + if (config == null) { + call.respond(HttpStatusCode.NotFound) + } else { + respondBuildContext(call, config, taskUUID) + } + } + }) + + modelixWorkspacesInstancesStateRoutes(object : ModelixWorkspacesInstancesStateController { + override suspend fun changeInstanceState( + instanceId: String, + workspaceInstanceStateObject: WorkspaceInstanceStateObject, + call: TypedApplicationCall, + ) { + TODO("Not yet implemented") + } + + override suspend fun getInstanceState( + instanceId: String, + call: TypedApplicationCall, + ) { + val state = instancesManager.getInstanceStates()[instanceId]?.deriveState() ?: WorkspaceInstanceState.UNKNOWN + call.respondTyped(WorkspaceInstanceStateObject(state)) + } + }) + } + + private suspend fun respondBuildContext(call: ApplicationCall, workspace: WorkspaceConfigForBuild, taskId: UUID) { + val httpProxy: String? = System.getenv("MODELIX_HTTP_PROXY")?.takeIf { it.isNotEmpty() } + +// call.checkPermission(WorkspacesPermissionSchema.workspaces.workspace(workspace.id).config.readCredentials) +// +// // more extensive check to ensure only the build job has access +// if (!run { +// val token = call.principal()?.payload ?: return@run false +// if (!manager.jwtUtil.isAccessToken(token)) return@run false +// if (call.getUnverifiedJwt()?.keyId != manager.jwtUtil.getPrivateKey()?.keyID) return@run false +// true +// } +// ) { +// throw NoPermissionException("Only permitted to the workspace-job") +// } + + val mpsVersion = workspace.mpsVersion + val jwtToken = manager.workspaceJobTokenGenerator(workspace) + + val containerMemoryBytes = workspace.memoryLimit.toBigDecimal() + var maxHeapSizeBytes = heapSizeFromContainerLimit(containerMemoryBytes) + val maxHeapSizeMega = (maxHeapSizeBytes / 1024.toBigDecimal() / 1024.toBigDecimal()).toBigInteger() + + call.respondTarGz { tar -> + @Suppress("ktlint") + tar.putFile("Dockerfile", """ + FROM ${WorkspaceJobQueue.HELM_PREFIX}docker-registry:5000/modelix/workspace-client-baseimage:${System.getenv("MPS_BASEIMAGE_VERSION")}-mps$mpsVersion + + ENV modelix_workspace_id=${workspace.id} + ENV modelix_workspace_task_id=${taskId} + ENV modelix_workspace_server=http://${WorkspaceJobQueue.HELM_PREFIX}workspace-manager:28104/ + ENV INITIAL_JWT_TOKEN=$jwtToken + + RUN /etc/cont-init.d/10-init-users.sh && /etc/cont-init.d/99-set-user-home.sh + + RUN sed -i.bak '/-Xmx/d' /mps/bin/mps64.vmoptions \ + && sed -i.bak '/-XX:MaxRAMPercentage/d' /mps/bin/mps64.vmoptions \ + && echo "-Xmx${maxHeapSizeMega}m" >> /mps/bin/mps64.vmoptions \ + && cat /mps/bin/mps64.vmoptions > /mps/bin/mps.vmoptions + + COPY clone.sh /clone.sh + RUN chmod +x /clone.sh && chown app:app /clone.sh + USER app + RUN /clone.sh + USER root + RUN rm /clone.sh + USER app + + RUN rm -rf /mps-projects/default-mps-project + + RUN mkdir /config/home/job \ + && cd /config/home/job \ + && wget -q "http://${WorkspaceJobQueue.HELM_PREFIX}workspace-manager:28104/static/workspace-job.tar" \ + && tar -xf workspace-job.tar \ + && cd /mps-projects/workspace-${workspace.id} \ + && /config/home/job/workspace-job/bin/workspace-job \ + && rm -rf /config/home/job + + RUN /update-recent-projects.sh \ + && echo "${WorkspaceProgressItems().build.runIndexer.logMessageStart}" \ + && ( /run-indexer.sh || echo "${WorkspaceProgressItems().build.runIndexer.logMessageFailed}" ) \ + && echo "${WorkspaceProgressItems().build.runIndexer.logMessageDone}" + + USER root + """.trimIndent().toByteArray()) + + // Separate file for git command because they may contain the credentials + // and the commands shouldn't appear in the log + @Suppress("ktlint") + tar.putFile("clone.sh", """ + #!/bin/sh + + echo "### START build-gitClone ###" + + ${if (httpProxy == null) "" else """ + export http_proxy="$httpProxy" + export https_proxy="$httpProxy" + export HTTP_PROXY="$httpProxy" + export HTTPS_PROXY="$httpProxy" + """} + + if ${ + workspace.gitRepositories.flatMapIndexed { index, git -> + val dir = "/mps-projects/workspace-${workspace.id}/git/$index/" + + // https://learn.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=Linux#use-a-pat + val authHeader = git.password?.let { + """ -c http.extraheader="Authorization: Basic ${(git.username.orEmpty() + ":" + git.password).encodeBase64()}"""" + } ?: "" + + listOf( + "mkdir -p $dir", + "cd $dir", + "git$authHeader clone \"${git.url}\"", + "cd *", + "git checkout -b \"${git.branch}\" \"${git.commitHash}\"", + "git branch --set-upstream-to=\"origin/${git.branch}\"", + ) + }.ifEmpty { listOf("true") }.joinToString(" && ") + } + then + echo "### DONE build-gitClone ###" + else + echo "### FAILED build-gitClone ###" + fi + """.lines().joinToString("\n") { it.trim() }.toByteArray()) + } + } +} diff --git a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/CredentialsEncryption.kt b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/CredentialsEncryption.kt index 94aef86e..b74655f3 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/CredentialsEncryption.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/CredentialsEncryption.kt @@ -5,7 +5,7 @@ import io.ktor.server.util.url import org.jasypt.util.text.AES256TextEncryptor import org.modelix.workspaces.Credentials import org.modelix.workspaces.GitRepository -import org.modelix.workspaces.Workspace +import org.modelix.workspaces.InternalWorkspaceConfig /** * @@ -46,10 +46,10 @@ class CredentialsEncryption(key: String) { } } -fun CredentialsEncryption.copyWithEncryptedCredentials(workspace: Workspace): Workspace = +fun CredentialsEncryption.copyWithEncryptedCredentials(workspace: InternalWorkspaceConfig): InternalWorkspaceConfig = workspace.copy(gitRepositories = workspace.gitRepositories.map(::copyWithEncryptedCredentials)) -fun CredentialsEncryption.copyWithDecryptedCredentials(workspace: Workspace): Workspace = +fun CredentialsEncryption.copyWithDecryptedCredentials(workspace: InternalWorkspaceConfig): InternalWorkspaceConfig = workspace.copy(gitRepositories = workspace.gitRepositories.map(::copyWithDecryptedCredentials)) fun CredentialsEncryption.copyWithEncryptedCredentials(gitRepository: GitRepository): GitRepository = diff --git a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/FileSystemWorkspacePersistence.kt b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/FileSystemWorkspacePersistence.kt index 376be6ea..f90bed69 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/FileSystemWorkspacePersistence.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/FileSystemWorkspacePersistence.kt @@ -1,11 +1,10 @@ package org.modelix.workspace.manager import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.modelix.model.persistent.SerializationUtil +import org.modelix.workspaces.InternalWorkspaceConfig import org.modelix.workspaces.ModelRepository -import org.modelix.workspaces.Workspace import org.modelix.workspaces.WorkspaceAndHash import org.modelix.workspaces.WorkspaceHash import org.modelix.workspaces.WorkspacePersistence @@ -16,8 +15,8 @@ import kotlin.math.max @Serializable private data class WorkspacesDB( val lastUsedWorkspaceId: Long = 0L, // for preventing reuse after delete - val workspaces: Map = emptyMap(), - val workspacesByHash: Map = emptyMap(), + val workspaces: Map = emptyMap(), + val workspacesByHash: Map = emptyMap(), ) class FileSystemWorkspacePersistence(val file: File) : WorkspacePersistence { @@ -41,13 +40,13 @@ class FileSystemWorkspacePersistence(val file: File) : WorkspacePersistence { return db.workspaces.keys } - override fun getAllWorkspaces(): List { + override fun getAllWorkspaces(): List { return db.workspaces.values.toList() } @Synchronized - override fun newWorkspace(): Workspace { - val workspace = Workspace( + override fun newWorkspace(): InternalWorkspaceConfig { + val workspace = InternalWorkspaceConfig( id = newWorkspaceId(), modelRepositories = listOf(ModelRepository(id = "default")), ) @@ -61,7 +60,7 @@ class FileSystemWorkspacePersistence(val file: File) : WorkspacePersistence { writeDBFile() } - override fun getWorkspaceForId(id: String): Workspace? { + override fun getWorkspaceForId(id: String): InternalWorkspaceConfig? { return db.workspaces[id] } @@ -70,7 +69,7 @@ class FileSystemWorkspacePersistence(val file: File) : WorkspacePersistence { } @Synchronized - override fun update(workspace: Workspace): WorkspaceHash { + override fun update(workspace: InternalWorkspaceConfig): WorkspaceHash { val hash = workspace.withHash().hash() db = db.copy( lastUsedWorkspaceId = max(db.lastUsedWorkspaceId, workspace.id.toLong(16)), diff --git a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/KestraClient.kt b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/KestraClient.kt new file mode 100644 index 00000000..59226721 --- /dev/null +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/KestraClient.kt @@ -0,0 +1,218 @@ +package org.modelix.workspace.manager + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.expectSuccess +import io.ktor.client.request.forms.MultiPartFormDataContent +import io.ktor.client.request.forms.formData +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.put +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.appendPathSegments +import io.ktor.http.content.TextContent +import io.ktor.http.takeFrom +import io.ktor.serialization.kotlinx.json.json +import io.ktor.server.util.url +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.modelix.authorization.ModelixJWTUtil +import org.modelix.model.lazy.BranchReference +import org.modelix.model.lazy.RepositoryId +import org.modelix.model.server.ModelServerPermissionSchema +import org.modelix.workspaces.InternalWorkspaceConfig + +class KestraClient(val jwtUtil: ModelixJWTUtil) { + private val kestraApiEndpoint = url { + takeFrom(System.getenv("KESTRA_URL")) + appendPathSegments("api", "v1") + } + + private val httpClient = HttpClient(CIO) { + expectSuccess = true + install(ContentNegotiation) { + json() + } + } + + suspend fun getRunningImportJobIds(workspaceId: String): List { + return getRunningImportJobIds(mapOf("workspace" to workspaceId)) + } + + suspend fun getRunningImportJobIds(labels: Map): List { + val responseObject: JsonObject = httpClient.get { + url { + takeFrom(kestraApiEndpoint) + appendPathSegments("executions", "search") + parameters.append("namespace", "modelix") + parameters.append("flowId", "git_import") + parameters.appendAll("labels", labels.map { "${it.key}:${it.value}" }) + parameters.append("state", "CREATED") + parameters.append("state", "QUEUED") + parameters.append("state", "RUNNING") + parameters.append("state", "RETRYING") + parameters.append("state", "PAUSED") + parameters.append("state", "RESTARTED") + parameters.append("state", "KILLING") + } + }.body() + + return responseObject["results"]!!.jsonArray.map { it.jsonObject["id"]!!.jsonPrimitive.content } + } + + suspend fun enqueueGitImport(workspace: InternalWorkspaceConfig): JsonObject { + val gitRepo = workspace.gitRepositories.first() + return enqueueGitImport( + gitRepoUrl = gitRepo.url, + gitUser = gitRepo.credentials?.user, + gitPassword = gitRepo.credentials?.password, + gitRevision = "origin/${gitRepo.branch}", + modelixBranch = RepositoryId("workspace_${workspace.id}").getBranchReference("git-import"), + labels = mapOf("workspace" to workspace.id), + ) + } + + suspend fun enqueueGitImport( + gitRepoUrl: String, + gitUser: String?, + gitPassword: String?, + gitRevision: String, + modelixBranch: BranchReference, + labels: Map, + ): JsonObject { + updateGitImportFlow() + + val token = jwtUtil.createAccessToken( + "git-import@modelix.org", + listOf( + ModelServerPermissionSchema.repository(modelixBranch.repositoryId).create.fullId, + ModelServerPermissionSchema.branch(modelixBranch).rewrite.fullId, + ), + ) + + val response = httpClient.post { + url { + takeFrom(kestraApiEndpoint) + appendPathSegments("executions", "modelix", "git_import") + if (labels.isNotEmpty()) { + parameters.appendAll("labels", labels.map { "${it.key}:${it.value}" }) + } + } + setBody( + MultiPartFormDataContent( + formData { + append("git_url", gitRepoUrl) + append("git_revision", gitRevision) + append("git_limit", "50") + append("modelix_repo_name", modelixBranch.repositoryId.id) + append("modelix_target_branch", modelixBranch.branchName) + append("token", token) + gitUser?.let { append("git_user", it) } + gitPassword?.let { append("git_pw", it) } + }, + ), + ) + } + + return response.body() + } + + suspend fun updateGitImportFlow() { + // language=yaml + val content = TextContent( + """ + id: git_import + namespace: modelix + + inputs: + - id: git_url + type: URI + required: true + defaults: https://github.com/coolya/Durchblick.git + - id: git_revision + type: STRING + defaults: HEAD + - id: modelix_repo_name + type: STRING + required: true + - id: modelix_target_branch + type: STRING + required: true + defaults: git-import + - id: token + type: SECRET + required: true + - type: SECRET + id: git_pw + required: false + - type: SECRET + id: git_user + required: false + - id: git_limit + type: INT + defaults: 200 + + tasks: + - id: clone_and_import + type: io.kestra.plugin.kubernetes.PodCreate + namespace: ${System.getenv("KUBERNETES_NAMESPACE")} + spec: + containers: + - name: importer + image: ${System.getenv("GIT_IMPORT_IMAGE")} + args: + - git-import-remote + - "{{ inputs.git_url }}" + - --git-user + - "{{ inputs.git_user }}" + - --git-password + - "{{ inputs.git_pw }}" + - --limit + - "{{ inputs.git_limit }}" + - --model-server + - "${System.getenv("model_server_url")}" + - --token + - "{{ inputs.token }}" + - --repository + - "{{ inputs.modelix_repo_name }}" + - --branch + - "{{ inputs.modelix_target_branch }}" + - --rev + - "{{ inputs.git_revision }}" + restartPolicy: Never + """.trimIndent(), + ContentType("application", "x-yaml"), + ) + + val response = httpClient.put { + expectSuccess = false + url { + takeFrom(kestraApiEndpoint) + appendPathSegments("flows", "modelix", "git_import") + } + setBody(content) + } + when (response.status) { + HttpStatusCode.Companion.OK -> {} + HttpStatusCode.Companion.NotFound -> { + httpClient.post { + url { + takeFrom(kestraApiEndpoint) + appendPathSegments("flows") + } + setBody(content) + } + } + else -> { + throw RuntimeException("${response.status}\n\n${response.bodyAsText()}\n\n${content.text}") + } + } + } +} diff --git a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/KubernetesJobTask.kt b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/KubernetesJobTask.kt new file mode 100644 index 00000000..c9394c9e --- /dev/null +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/KubernetesJobTask.kt @@ -0,0 +1,129 @@ +package org.modelix.workspace.manager + +import io.kubernetes.client.openapi.apis.BatchV1Api +import io.kubernetes.client.openapi.apis.CoreV1Api +import io.kubernetes.client.openapi.models.V1Job +import io.kubernetes.client.openapi.models.V1Toleration +import io.kubernetes.client.util.Yaml +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeout +import org.modelix.services.workspaces.ContinuingCallback +import org.modelix.services.workspaces.metadata +import org.modelix.services.workspaces.spec +import org.modelix.services.workspaces.template +import org.modelix.workspace.manager.WorkspaceJobQueue.Companion.KUBERNETES_NAMESPACE +import kotlin.coroutines.suspendCoroutine +import kotlin.time.Duration.Companion.minutes + +abstract class KubernetesJobTask(scope: CoroutineScope) : TaskInstance(scope) { + companion object { + const val JOB_ID_LABEL = "modelix.workspace.job.id" + private val LOG = mu.KotlinLogging.logger {} + } + + abstract suspend fun tryGetResult(): Out? + abstract fun generateJobYaml(): V1Job + + override suspend fun process() = withTimeout(30.minutes) { + tryGetResult()?.let { return@withTimeout it } + + findJob()?.let { deleteJob(it) } + createJob() + + var jobCreationFailedConfirmations = 0 + while (true) { + delay(1000) + + tryGetResult()?.let { return@withTimeout it } + + val job = findJob() + + // https://kubernetes.io/docs/concepts/workloads/controllers/job/#terminal-job-conditions + val jobFailed = job?.status?.conditions.orEmpty().any { it.type == "Failed" } + val jobSucceeded = job?.status?.conditions.orEmpty().any { it.type == "Complete" } + if (jobFailed || jobSucceeded) break + + if (job == null) { + jobCreationFailedConfirmations++ + } else { + jobCreationFailedConfirmations = 0 + } + if (jobCreationFailedConfirmations > 10) { + break + } + } + checkNotNull(tryGetResult()) { + "Job finished without producing the expected result. \nStatus: ${findJob()?.status?.let { Yaml.dump(it) }}\nPod logs:\n ${getPodLogs()}" + } + } + + fun getPodLogs(): String? { + try { + val coreApi = CoreV1Api() + val pods = coreApi.listNamespacedPod(KUBERNETES_NAMESPACE).timeoutSeconds(10).execute() + for (pod in pods.items) { + if (pod.metadata!!.labels?.get(JOB_ID_LABEL) != id.toString()) continue + return coreApi + .readNamespacedPodLog(pod.metadata!!.name, KUBERNETES_NAMESPACE) + .container(pod.spec!!.containers[0].name) + .pretty("true") + .tailLines(10_000) + .execute() + } + } catch (e: Exception) { + LOG.error("", e) + return null + } + return null + } + + private suspend fun createJob() { + suspendCoroutine { + val job = generateJobYaml().apply { + apiVersion = "batch/v1" + kind = "Job" + metadata { + putLabelsItem(JOB_ID_LABEL, id.toString()) + } + spec { + ttlSecondsAfterFinished = 300 + activeDeadlineSeconds = 3600 + backoffLimit = 0 + template { + spec { + addTolerationsItem( + V1Toleration().apply { + key = "workspace-client" + operator = "Exists" + effect = "NoExecute" + }, + ) + activeDeadlineSeconds = 3600 + restartPolicy = "Never" + } + metadata { + putLabelsItem(JOB_ID_LABEL, id.toString()) + } + } + } + } + BatchV1Api().createNamespacedJob(KUBERNETES_NAMESPACE, job).executeAsync(ContinuingCallback(it)) + } + } + + private suspend fun findJob(): V1Job? { + val jobs = suspendCoroutine { + BatchV1Api().listNamespacedJob(KUBERNETES_NAMESPACE) + .executeAsync(ContinuingCallback(it)) + } + return jobs.items.firstOrNull { it.metadata.labels?.get(JOB_ID_LABEL) == id.toString() } + } + + private suspend fun deleteJob(job: V1Job) { + suspendCoroutine { + BatchV1Api().deleteNamespacedJob(job.metadata!!.name, job.metadata!!.namespace) + .executeAsync(ContinuingCallback(it)) + } + } +} diff --git a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/MavenControllerImpl.kt b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/MavenControllerImpl.kt new file mode 100644 index 00000000..7fbefa9b --- /dev/null +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/MavenControllerImpl.kt @@ -0,0 +1,110 @@ +package org.modelix.workspace.manager + +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.ApplicationCall +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import org.modelix.services.mavenconnector.stubs.controllers.ModelixMavenConnectorArtifactsController +import org.modelix.services.mavenconnector.stubs.controllers.ModelixMavenConnectorArtifactsController.Companion.modelixMavenConnectorArtifactsRoutes +import org.modelix.services.mavenconnector.stubs.controllers.ModelixMavenConnectorController +import org.modelix.services.mavenconnector.stubs.controllers.ModelixMavenConnectorController.Companion.modelixMavenConnectorRoutes +import org.modelix.services.mavenconnector.stubs.controllers.ModelixMavenConnectorRepositoriesController +import org.modelix.services.mavenconnector.stubs.controllers.ModelixMavenConnectorRepositoriesController.Companion.modelixMavenConnectorRepositoriesRoutes +import org.modelix.services.mavenconnector.stubs.controllers.TypedApplicationCall +import org.modelix.services.mavenconnector.stubs.models.MavenArtifact +import org.modelix.services.mavenconnector.stubs.models.MavenArtifactList +import org.modelix.services.mavenconnector.stubs.models.MavenConnectorConfig +import org.modelix.services.mavenconnector.stubs.models.MavenRepository +import org.modelix.services.mavenconnector.stubs.models.MavenRepositoryList + +class MavenControllerImpl : + ModelixMavenConnectorController, + ModelixMavenConnectorArtifactsController, + ModelixMavenConnectorRepositoriesController { + var data = MavenConnectorConfig( + repositories = listOf( + MavenRepository(id = "itemis", "https://artifacts.itemis.cloud/repository/maven-mps/"), + ), + artifacts = listOf( + MavenArtifact(groupId = "com.jetbrains", "mps", "2024.1.1"), + ), + ) + + override suspend fun getMavenConnectorConfig(call: TypedApplicationCall) { + call.respondTyped(data) + } + + fun install(route: Route) { + route.modelixMavenConnectorRoutes(this) + route.modelixMavenConnectorRepositoriesRoutes(this) + route.modelixMavenConnectorArtifactsRoutes(this) + } + + override suspend fun getMavenRepository( + repositoryId: String, + call: TypedApplicationCall, + ) { + val repository = data.repositories.find { it.id == repositoryId } + if (repository == null) { + call.respond(HttpStatusCode.NotFound) + } else { + call.respondTyped(repository) + } + } + + override suspend fun updateMavenRepository( + repositoryId: String, + mavenRepository: MavenRepository, + call: ApplicationCall, + ) { + data = data.copy( + repositories = data.repositories.associateBy { it.id } + .plus(repositoryId to mavenRepository.copy(id = repositoryId)) + .values.toList(), + ) + call.respond(HttpStatusCode.OK) + } + + override suspend fun deleteMavenRepository( + repositoryId: String, + call: ApplicationCall, + ) { + data = data.copy( + repositories = data.repositories.filter { it.id != repositoryId }, + ) + call.respond(HttpStatusCode.OK) + } + + override suspend fun listMavenRepositories(call: TypedApplicationCall) { + call.respondTyped(MavenRepositoryList(data.repositories)) + } + + override suspend fun listMavenArtifacts(call: TypedApplicationCall) { + call.respondTyped(MavenArtifactList(data.artifacts)) + } + + override suspend fun deleteMavenArtifact( + groupId: String, + artifactId: String, + call: ApplicationCall, + ) { + TODO("Not yet implemented") + } + + override suspend fun getMavenArtifact( + groupId: String, + artifactId: String, + call: TypedApplicationCall, + ) { + TODO("Not yet implemented") + } + + override suspend fun updateMavenArtifact( + groupId: String, + artifactId: String, + mavenArtifact: MavenArtifact, + call: ApplicationCall, + ) { + TODO("Not yet implemented") + } +} diff --git a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/Reconciler.kt b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/Reconciler.kt new file mode 100644 index 00000000..a454afc7 --- /dev/null +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/Reconciler.kt @@ -0,0 +1,52 @@ +package org.modelix.workspace.manager + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +private val LOG = mu.KotlinLogging.logger {} + +class Reconciler(val coroutinesScope: CoroutineScope, initialState: E, reconcile: suspend (E) -> Unit) { + private val stateChanges = Channel(capacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + private val desiredState = SharedMutableState(initialState) + .also { it.addListener { stateChanges.trySend(it) } } + private val reconciliationJob = coroutinesScope.launch { + try { + while (isActive) { + try { + reconcile(stateChanges.receive()) + } catch (ex: CancellationException) { + break + } catch (ex: Throwable) { + LOG.error("Exception during reconciliation", ex) + } + } + } finally { + LOG.info("Reconciliation job stopped") + } + } + + init { + stateChanges.trySend(initialState) + } + + fun dispose() { + reconciliationJob.cancel() + } + + fun updateDesiredState(updater: (E) -> E) { + desiredState.update(updater) + } + + fun getDesiredState(): E = desiredState.getValue() + + fun trigger() { + desiredState.update { + stateChanges.trySend(it) + it + } + } +} diff --git a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/SharedMutableState.kt b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/SharedMutableState.kt new file mode 100644 index 00000000..58d27d82 --- /dev/null +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/SharedMutableState.kt @@ -0,0 +1,39 @@ +package org.modelix.workspace.manager + +private val LOG = mu.KotlinLogging.logger { } + +class SharedMutableState(initialValue: E) { + private var value: E = initialValue + private val listeners = mutableListOf<(E) -> Unit>() + + @Synchronized + fun update(updater: (E) -> E): E { + val newValue = updater(value) + if (newValue == value) return value + value = newValue + notifyListeners() + return newValue + } + + fun getValue() = value + + @Synchronized + fun addListener(listener: (E) -> Unit) { + listeners.add(listener) + } + + @Synchronized + fun removeListener(listener: (E) -> Unit) { + listeners.remove(listener) + } + + private fun notifyListeners() { + for (it in listeners) { + try { + it(value) + } catch (ex: Exception) { + LOG.error("Exception in listener", ex) + } + } + } +} diff --git a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/TaskInstance.kt b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/TaskInstance.kt new file mode 100644 index 00000000..365a74d9 --- /dev/null +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/TaskInstance.kt @@ -0,0 +1,61 @@ +package org.modelix.workspace.manager + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import java.util.UUID + +interface ITaskInstance { + fun getOutput(): Result? + suspend fun waitForOutput(): R + fun getState(): TaskState + fun launch(): Deferred +} + +abstract class TaskInstance(val scope: CoroutineScope) : ITaskInstance { + val id: UUID = UUID.randomUUID() + private var job: Deferred? = null + private var result: Result? = null + protected abstract suspend fun process(): R + + @Synchronized + override fun launch(): Deferred { + return job ?: scope.async { + runCatching { process() }.also { result = it }.getOrThrow() + }.also { job = it } + } + + override fun getOutput(): Result? = result + + override suspend fun waitForOutput(): R { + return launch().await() + } + + override fun getState(): TaskState = job.let { + when { + it == null -> TaskState.CREATED + it.isCompleted -> TaskState.COMPLETED + it.isCancelled -> TaskState.CANCELLED + it.isActive -> TaskState.ACTIVE + else -> TaskState.UNKNOWN + } + } +} + +class ReusableTasks> { + private val tasks = LinkedHashMap() + + fun getOrCreateTask(key: K, factory: (K) -> V): V { + return synchronized(tasks) { + val existing = tasks[key] + if (existing != null && existing.getState() != TaskState.CANCELLED) return@synchronized existing + val newTask = factory(key) + tasks[key] = newTask + newTask + } + } + + fun getEntries(): Map = synchronized(tasks) { tasks.toMap() } + + fun getAll(): List = synchronized(tasks) { tasks.values.toList() } +} diff --git a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceBuildManager.kt b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceBuildManager.kt new file mode 100644 index 00000000..40b21817 --- /dev/null +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceBuildManager.kt @@ -0,0 +1,33 @@ +package org.modelix.workspace.manager + +import kotlinx.coroutines.CoroutineScope +import org.modelix.workspaces.WorkspaceConfigForBuild +import java.util.UUID + +private val LOG = mu.KotlinLogging.logger { } + +class WorkspaceBuildManager( + val coroutinesScope: CoroutineScope, + val tokenGenerator: (WorkspaceConfigForBuild) -> String, +) { + + private val workspaceImageTasks = ReusableTasks() + + fun getOrCreateWorkspaceImageTask(workspaceConfig: WorkspaceConfigForBuild): WorkspaceImageTask { + return workspaceImageTasks.getOrCreateTask(workspaceConfig) { + WorkspaceImageTask(workspaceConfig, tokenGenerator, coroutinesScope) + } + } + + fun getWorkspaceConfigByTaskId(taskId: UUID): WorkspaceConfigForBuild? { + return workspaceImageTasks.getAll().find { it.id == taskId }?.workspaceConfig + } +} + +enum class TaskState { + CREATED, + ACTIVE, + CANCELLED, + COMPLETED, + UNKNOWN, +} diff --git a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceImageTask.kt b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceImageTask.kt new file mode 100644 index 00000000..492f81fb --- /dev/null +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceImageTask.kt @@ -0,0 +1,161 @@ +package org.modelix.workspace.manager + +import io.ktor.client.HttpClient +import io.ktor.client.engine.cio.CIO +import io.ktor.client.request.basicAuth +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpStatusCode +import io.kubernetes.client.custom.Quantity +import io.kubernetes.client.openapi.models.V1Job +import io.kubernetes.client.util.Yaml +import kotlinx.coroutines.CoroutineScope +import org.modelix.services.workspaces.toValidImageTag +import org.modelix.workspace.manager.WorkspaceJobQueue.Companion.HELM_PREFIX +import org.modelix.workspaces.WorkspaceConfigForBuild +import org.modelix.workspaces.hash + +class WorkspaceImageTask( + val workspaceConfig: WorkspaceConfigForBuild, + val tokenGenerator: (WorkspaceConfigForBuild) -> String, + scope: CoroutineScope, +) : KubernetesJobTask(scope) { + companion object { + const val JOB_ID_LABEL = "modelix.workspace.job.id" + } + + private val resultImage = ImageNameAndTag( + "modelix-workspaces/ws${workspaceConfig.id}", + workspaceConfig.hash().toValidImageTag(), + ) + + override suspend fun tryGetResult(): ImageNameAndTag? { + return resultImage.takeIf { checkImageExists(it) } + } + + @Suppress("ktlint") + override fun generateJobYaml(): V1Job { + val jobName = "wsjob-$id" + val mpsVersion = workspaceConfig.mpsVersion + + val containerMemoryBytes = workspaceConfig.memoryLimit.toBigDecimal() + val baseImageBytes = BASE_IMAGE_MAX_HEAP_SIZE_MEGA.toBigDecimal() * 1024.toBigDecimal() * 1024.toBigDecimal() + val heapSizeBytes = heapSizeFromContainerLimit(containerMemoryBytes).coerceAtLeast(baseImageBytes) + val additionalJobMemoryBytes = Quantity.fromString("1Gi").number + val jobContainerMemoryBytes = containerLimitFromHeapSize(heapSizeBytes.coerceAtLeast(baseImageBytes)) + additionalJobMemoryBytes + val jobContainerMemoryMega = (jobContainerMemoryBytes / 1024.toBigDecimal()).toBigInteger().toBigDecimal() + val memoryLimit = Quantity(jobContainerMemoryMega * 1024.toBigDecimal(), Quantity.Format.BINARY_SI).toSuffixedString() + + val jwtToken = tokenGenerator(workspaceConfig) + val dockerConfigSecretName = System.getenv("DOCKER_CONFIG_SECRET_NAME") + val dockerConfigInternalRegistrySecretName = System.getenv("DOCKER_CONFIG_INTERN_REGISTRY_SECRET_NAME") + + return """ + apiVersion: batch/v1 + kind: Job + metadata: + name: "$jobName" + labels: + ${JOB_ID_LABEL}: $id + spec: + ttlSecondsAfterFinished: 60 + activeDeadlineSeconds: 3600 + template: + metadata: + labels: + ${JOB_ID_LABEL}: $id + spec: + activeDeadlineSeconds: 3600 + tolerations: + - key: "workspace-client" + operator: "Exists" + effect: "NoExecute" + containers: + - name: wsjob + image: ${WorkspaceJobQueue.Companion.JOB_IMAGE} + env: + - name: TARGET_REGISTRY + value: ${WorkspaceJobQueue.Companion.HELM_PREFIX}docker-registry:5000 + - name: WORKSPACE_DESTINATION_IMAGE_NAME + value: ${resultImage.name} + - name: WORKSPACE_DESTINATION_IMAGE_TAG + value: ${resultImage.tag} + - name: WORKSPACE_CONTEXT_URL + value: http://${WorkspaceJobQueue.Companion.HELM_PREFIX}workspace-manager:28104/modelix/workspaces/tasks/$id/context.tar.gz + - name: modelix_workspace_task_id + value: $id + - name: modelix_workspace_server + value: http://${WorkspaceJobQueue.Companion.HELM_PREFIX}workspace-manager:28104/ + - name: INITIAL_JWT_TOKEN + value: $jwtToken + - name: BASEIMAGE_CONTEXT_URL + value: http://${WorkspaceJobQueue.Companion.HELM_PREFIX}workspace-manager:28104/baseimage/$mpsVersion/context.tar.gz + - name: BASEIMAGE_TARGET + value: ${WorkspaceJobQueue.Companion.HELM_PREFIX}docker-registry:5000/modelix/workspace-client-baseimage:${System.getenv("MPS_BASEIMAGE_VERSION")}-mps$mpsVersion + resources: + requests: + memory: $memoryLimit + cpu: "0.1" + limits: + memory: $memoryLimit + cpu: "1.0" + volumeMounts: + ${if (dockerConfigSecretName != null) """ + - name: "docker-config" + mountPath: /secrets/config-external-registry.json + subPath: config.json + readOnly: true + - name: "docker-proxy-ca" + mountPath: /kaniko/ssl/certs/docker-proxy-ca.crt + subPath: docker-proxy-ca.crt + readOnly: true + """ else ""} + - name: "docker-config-internal-registry" + mountPath: /secrets/config-internal-registry.json + subPath: config.json + readOnly: true + restartPolicy: Never + volumes: + - name: "docker-config-internal-registry" + secret: + secretName: "$dockerConfigInternalRegistrySecretName" + items: + - key: .dockerconfigjsonUsingServiceName + path: config.json + ${if (dockerConfigSecretName != null) """ + - name: "docker-config" + secret: + secretName: "$dockerConfigSecretName" + items: + - key: .dockerconfigjson + path: config.json + - name: "docker-proxy-ca" + secret: + secretName: "$dockerConfigSecretName" + items: + - key: caCertificate + path: docker-proxy-ca.crt + """ else ""} + backoffLimit: 2 + """.trimIndent().let { Yaml.loadAs(it, V1Job::class.java) } + } +} + +data class ImageNameAndTag(val name: String, val tag: String) { + override fun toString(): String = "$name:$tag" +} + +private suspend fun checkImageExists(image: ImageNameAndTag): Boolean { + val response = HttpClient(CIO).get("http://${HELM_PREFIX}docker-registry:5000/v2/${image.name}/manifests/${image.tag}") { + basicAuth(System.getenv("INTERNAL_DOCKER_REGISTRY_USER"), System.getenv("INTERNAL_DOCKER_REGISTRY_PASSWORD")) + header("Accept", "application/vnd.oci.image.manifest.v1+json") + } + return when (response.status) { + HttpStatusCode.NotFound -> false + HttpStatusCode.OK -> true + else -> { + throw IllegalStateException("Unexpected response: ${response.status}\n${response.bodyAsText()}") + } + } +} diff --git a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceInstancesManager.kt b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceInstancesManager.kt new file mode 100644 index 00000000..047803bf --- /dev/null +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceInstancesManager.kt @@ -0,0 +1,436 @@ +package org.modelix.workspace.manager + +import io.kubernetes.client.custom.Quantity +import io.kubernetes.client.openapi.ApiException +import io.kubernetes.client.openapi.Configuration +import io.kubernetes.client.openapi.apis.AppsV1Api +import io.kubernetes.client.openapi.apis.CoreV1Api +import io.kubernetes.client.openapi.models.CoreV1Event +import io.kubernetes.client.openapi.models.CoreV1EventList +import io.kubernetes.client.openapi.models.V1Deployment +import io.kubernetes.client.openapi.models.V1EnvVar +import io.kubernetes.client.openapi.models.V1Pod +import io.kubernetes.client.openapi.models.V1Service +import io.kubernetes.client.openapi.models.V1ServicePort +import io.kubernetes.client.util.ClientBuilder +import io.kubernetes.client.util.Yaml +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import mu.KotlinLogging +import org.modelix.authorization.ModelixJWTUtil +import org.modelix.authorization.permissions.AccessControlData +import org.modelix.authorization.permissions.PermissionParts +import org.modelix.model.lazy.BranchReference +import org.modelix.model.server.ModelServerPermissionSchema +import org.modelix.services.gitconnector.GitConnectorManager +import org.modelix.services.workspaces.ContinuingCallback +import org.modelix.services.workspaces.configForBuild +import org.modelix.services.workspaces.executeSuspending +import org.modelix.services.workspaces.metadata +import org.modelix.services.workspaces.spec +import org.modelix.services.workspaces.stubs.models.WorkspaceInstance +import org.modelix.services.workspaces.stubs.models.WorkspaceInstanceState +import org.modelix.workspaces.WorkspacesPermissionSchema +import java.io.File +import java.util.Collections +import java.util.UUID +import kotlin.coroutines.suspendCoroutine + +private val LOG = KotlinLogging.logger {} + +private data class InstancesManagerState( + val instances: Map = emptyMap(), +) + +class WorkspaceInstanceStateValues( + var imageTask: WorkspaceImageTask? = null, + var draftBranches: List?> = emptyList(), + var deployment: V1Deployment? = null, + var pod: V1Pod? = null, + var enabled: Boolean = false, +) { + fun deriveState(): WorkspaceInstanceState { + val image = imageTask?.getOutput() + val imageTaskState = imageTask?.getState() + return when { + !enabled -> WorkspaceInstanceState.DISABLED + (deployment?.status?.readyReplicas ?: 0) >= 1 -> WorkspaceInstanceState.RUNNING + deployment != null -> WorkspaceInstanceState.LAUNCHING + image?.isFailure == true -> WorkspaceInstanceState.BUILD_FAILED + image?.getOrNull() != null && draftBranches.all { it?.getOrNull() != null } -> WorkspaceInstanceState.LAUNCHING + else -> when (imageTaskState) { + null -> WorkspaceInstanceState.CREATED + TaskState.CANCELLED -> WorkspaceInstanceState.BUILD_FAILED + TaskState.CREATED -> WorkspaceInstanceState.WAITING_FOR_BUILD + TaskState.ACTIVE -> WorkspaceInstanceState.WAITING_FOR_BUILD + TaskState.COMPLETED -> WorkspaceInstanceState.WAITING_FOR_BUILD + TaskState.UNKNOWN -> WorkspaceInstanceState.WAITING_FOR_BUILD + } + } + } + + fun statusText(): String { + val text = ArrayList() + + if (!enabled) text += "Instance is disabled." + if (deployment != null) text += "Deployment created." + if ((deployment?.status?.readyReplicas ?: 0) >= 1) text += "Pod is ready." + if (imageTask == null) { + text += "Build task not created yet." + } else { + text += "Build task state: ${imageTask?.getState()}." + imageTask?.getOutput()?.exceptionOrNull()?.message?.let { + text += it + } + if (imageTask?.getOutput()?.getOrNull() != null) { + text += "Image created." + } + } + for (draftBranchTask in draftBranches) { + if (draftBranchTask?.getOrNull() != null) text += "Draft branch created." + draftBranchTask?.exceptionOrNull()?.message?.let { + text += "Draft branch creation failed: $it." + } + } + + return text.joinToString(" ") + } +} + +class WorkspaceInstancesManager( + val workspaceManager: WorkspaceManager, + val buildManager: WorkspaceBuildManager, + val gitManager: GitConnectorManager, + val coroutinesScope: CoroutineScope = CoroutineScope(Dispatchers.Default), +) { + companion object { + val KUBERNETES_NAMESPACE = System.getenv("WORKSPACE_CLIENT_NAMESPACE") ?: "default" + val INSTANCE_PREFIX = System.getenv("WORKSPACE_CLIENT_PREFIX") ?: "wsclt-" + val INTERNAL_DOCKER_REGISTRY_AUTHORITY = requireNotNull(System.getenv("INTERNAL_DOCKER_REGISTRY_AUTHORITY")) + const val TIMEOUT_SECONDS = 10 + const val INSTANCE_ID_LABEL = "modelix.workspace.instance.id" + + fun WorkspaceInstance.instanceName() = INSTANCE_PREFIX + id + } + + init { + Configuration.setDefaultApiClient(ClientBuilder.cluster().build()) + } + + private val indexWasReady: MutableSet = Collections.synchronizedSet(HashSet()) + private val jwtUtil = ModelixJWTUtil().also { it.loadKeysFromEnvironment() } + + private val reconciler = Reconciler(coroutinesScope, InstancesManagerState(), ::reconcile) + private val reconcileJob = coroutinesScope.launch { + while (isActive) { + delay(2000) + reconciler.trigger() + } + } + + fun dispose() { + reconciler.dispose() + reconcileJob.cancel("disposed") + } + + fun updateInstancesMap(updater: (Map) -> Map) { + reconciler.updateDesiredState { + it.copy(instances = updater(it.instances)) + } + } + + fun getInstancesMap(): Map = reconciler.getDesiredState().instances + + suspend fun getInstanceStates(): Map { + val managerState = reconciler.getDesiredState() + + val instances: Map = managerState.instances + val stateValues = instances.keys.associateWith { WorkspaceInstanceStateValues() } + + for ((instanceId, deployment) in getExistingDeployments()) { + stateValues[instanceId]?.deployment = deployment + } + + for ((instanceId, pod) in getExistingPods()) { + stateValues[instanceId]?.pod = pod + } + + for (config in instances.values) { + val values = stateValues[config.id] ?: continue + values.enabled = config.enabled + + val imageTask = buildManager.getOrCreateWorkspaceImageTask(config.configForBuild(gitManager)) + values.imageTask = imageTask + + values.draftBranches = config.drafts.orEmpty().map { draftId -> + gitManager.getOrCreateDraftPreparationTask(draftId).also { it.launch() } + }.map { it.getOutput() } + } + + return stateValues + } + + private suspend fun getExistingDeployments(): Map { + val existingDeployments: MutableMap = HashMap() + val appsApi = AppsV1Api() + val deployments = suspendCoroutine { + appsApi + .listNamespacedDeployment(KUBERNETES_NAMESPACE) + .timeoutSeconds(TIMEOUT_SECONDS) + .executeAsync(ContinuingCallback(it)) + } + for (deployment in deployments.items) { + val instanceId = deployment.metadata?.labels?.get(INSTANCE_ID_LABEL) ?: continue + existingDeployments[instanceId] = deployment + } + return existingDeployments + } + + private suspend fun getExistingPods(): Map { + val existingPods: MutableMap = HashMap() + val coreApi = CoreV1Api() + val pods = suspendCoroutine { + coreApi + .listNamespacedPod(KUBERNETES_NAMESPACE) + .timeoutSeconds(TIMEOUT_SECONDS) + .executeAsync(ContinuingCallback(it)) + } + for (pod in pods.items) { + val instanceId = pod.metadata?.labels?.get(INSTANCE_ID_LABEL) ?: continue + existingPods[instanceId] = pod + } + return existingPods + } + + private suspend fun reconcile(newState: InstancesManagerState) { + val appsApi = AppsV1Api() + val coreApi = CoreV1Api() + val expectedInstances = newState.instances.filter { it.value.enabled } + val existingInstances = getExistingDeployments() + + val toAdd = expectedInstances - existingInstances.keys + val toRemove = existingInstances - expectedInstances.keys + for (deployment in toRemove.values) { + val name = deployment.metadata.name + try { + appsApi.deleteNamespacedDeployment(name, KUBERNETES_NAMESPACE) + .execute() + } catch (e: Exception) { + LOG.error("Failed to delete deployment $deployment", e) + } + try { + coreApi.deleteNamespacedService(name, KUBERNETES_NAMESPACE) + .execute() + } catch (e: Exception) { + LOG.error("Failed to delete service $deployment", e) + } + } + for (instance in toAdd.values) { + try { + val workspaceConfig = instance.configForBuild(gitManager) + buildManager.getOrCreateWorkspaceImageTask(workspaceConfig) + val imageTask = buildManager.getOrCreateWorkspaceImageTask(workspaceConfig).also { it.launch() } + + val draftPreparationTasks = instance.drafts.orEmpty().map { draftId -> + gitManager.getOrCreateDraftPreparationTask(draftId).also { it.launch() } + } + val draftBranches = draftPreparationTasks.map { it.getOutput()?.getOrNull() } + + val image = imageTask.getOutput()?.getOrNull() + if (image != null && draftBranches.all { it != null }) { + createDeployment(instance, image, draftBranches.map { it!! }) + createService(instance) + } + } catch (e: Exception) { + LOG.error("Failed to create deployment for workspace instance ${instance.id}", e) + } + } + + synchronized(indexWasReady) { + indexWasReady.removeAll(indexWasReady - expectedInstances.keys) + } + } + + fun getTargetHost(instanceId: UUID): String { + return INSTANCE_PREFIX + instanceId + } + + fun getDeployment(name: String, attempts: Int): V1Deployment? { + val appsApi = AppsV1Api() + var deployment: V1Deployment? = null + for (i in 0 until attempts) { + try { + deployment = appsApi.readNamespacedDeployment(name, KUBERNETES_NAMESPACE).execute() + } catch (ex: ApiException) { + LOG.error("Failed to read deployment: $name", ex) + } + if (deployment != null) break + try { + Thread.sleep(1000L) + } catch (e: InterruptedException) { + return null + } + } + return deployment + } + + fun getPod(deploymentName: String): V1Pod? { + try { + val coreApi = CoreV1Api() + val pods = coreApi.listNamespacedPod(KUBERNETES_NAMESPACE).timeoutSeconds(TIMEOUT_SECONDS).execute() + for (pod in pods.items) { + if (!pod.metadata!!.name!!.startsWith(deploymentName)) continue + return pod + } + } catch (e: Exception) { + LOG.error("", e) + return null + } + return null + } + + fun getPodLogs(instanceId: String): String? { + try { + val coreApi = CoreV1Api() + val pods = coreApi.listNamespacedPod(KUBERNETES_NAMESPACE).timeoutSeconds(TIMEOUT_SECONDS).execute() + for (pod in pods.items) { + if (pod.metadata!!.labels?.get(INSTANCE_ID_LABEL) != instanceId) continue + return coreApi + .readNamespacedPodLog(pod.metadata!!.name, KUBERNETES_NAMESPACE) + .container(pod.spec!!.containers[0].name) + .pretty("true") + .tailLines(10_000) + .execute() + } + } catch (e: Exception) { + LOG.error("", e) + return null + } + return null + } + + fun isIndexerReady(instanceId: String): Boolean { + // avoid doing the expensive check again + // also the relevant line may be truncated from the log if there is too much output + if (indexWasReady.contains(instanceId)) return true + + val log = getPodLogs(instanceId) ?: return false + val isReady = log.contains("### Index is ready") + if (isReady) { + indexWasReady.add(instanceId) + } + return isReady + } + + fun getEvents(deploymentName: String): List { + val events: CoreV1EventList = CoreV1Api() + .listNamespacedEvent(KUBERNETES_NAMESPACE) + .timeoutSeconds(TIMEOUT_SECONDS) + .execute() + return events.items + .filter { (it.involvedObject.name ?: "").contains(deploymentName) } + } + + suspend fun createDeployment( + workspaceInstance: WorkspaceInstance, + image: ImageNameAndTag, + draftBranches: List, + ): V1Deployment { + val instanceName = workspaceInstance.instanceName() + val workspaceId = workspaceInstance.config.id + + val appsApi = AppsV1Api() + + val existingDeployment = appsApi.listNamespacedDeployment(KUBERNETES_NAMESPACE) + .timeoutSeconds(TIMEOUT_SECONDS) + .executeSuspending() + .items + .firstOrNull { it.metadata.labels?.get(INSTANCE_ID_LABEL) == workspaceInstance.id } + + if (existingDeployment != null) return existingDeployment + + val deployment = Yaml.loadAs(File("/workspace-client-templates/deployment"), V1Deployment::class.java) + deployment.metadata { + name(instanceName) + putLabelsItem(INSTANCE_ID_LABEL, workspaceInstance.id) + } + deployment.spec { + selector.putMatchLabelsItem(INSTANCE_ID_LABEL, workspaceInstance.id) + template.metadata!!.putLabelsItem(INSTANCE_ID_LABEL, workspaceInstance.id) + replicas(1) + template.spec!!.containers[0].apply { + addEnvItem(V1EnvVar().name("modelix_workspace_id").value(workspaceId)) + draftBranches.firstOrNull()?.let { draftBranch -> + addEnvItem(V1EnvVar().name("REPOSITORY_ID").value(draftBranch.repositoryId.id)) + addEnvItem(V1EnvVar().name("REPOSITORY_BRANCH").value(draftBranch.branchName)) + } + // addEnvItem(V1EnvVar().name("modelix_workspace_hash").value(workspace.hash().hash)) + addEnvItem(V1EnvVar().name("WORKSPACE_MODEL_SYNC_ENABLED").value(true.toString())) + } + } + + val hasWritePermission = workspaceInstance.readonly == false + val newPermissions = ArrayList() + newPermissions += WorkspacesPermissionSchema.workspaces.workspace(workspaceId).config.read + for (draft in workspaceInstance.drafts.orEmpty().mapNotNull { gitManager.getDraft(it) }) { + val gitRepo = gitManager.getRepository(draft.gitRepositoryId) ?: continue + val modelixRepo = gitRepo.modelixRepository ?: continue + newPermissions += ModelServerPermissionSchema.repository(modelixRepo).branch(draft.modelixBranchName) + .let { if (hasWritePermission) it.write else it.read } + } + + val newToken = jwtUtil.createAccessToken(workspaceInstance.owner ?: "workspace-user@modelix.org", newPermissions.map { it.fullId }) + deployment.spec!!.template.spec!!.containers[0].addEnvItem(V1EnvVar().name("INITIAL_JWT_TOKEN").value(newToken)) + loadWorkspaceSpecificValues(workspaceInstance, deployment, image) + return appsApi.createNamespacedDeployment(KUBERNETES_NAMESPACE, deployment).executeSuspending() + } + + suspend fun createService( + workspaceInstance: WorkspaceInstance, + ): V1Service { + val instanceName = workspaceInstance.instanceName() + val coreApi = CoreV1Api() + val existingService = coreApi.listNamespacedService(KUBERNETES_NAMESPACE) + .timeoutSeconds(TIMEOUT_SECONDS) + .executeSuspending() + .items + .firstOrNull { it.metadata.labels?.get(INSTANCE_ID_LABEL) == workspaceInstance.id } + if (existingService != null) return existingService + + val service = Yaml.loadAs(File("/workspace-client-templates/service"), V1Service::class.java) + service.spec!!.ports!!.forEach { p: V1ServicePort -> p.nodePort(null) } + service.metadata!!.name(instanceName) + service.metadata!!.putLabelsItem(INSTANCE_ID_LABEL, workspaceInstance.id) + service.spec!!.putSelectorItem(INSTANCE_ID_LABEL, workspaceInstance.id) + println("Creating service: ") + println(Yaml.dump(service)) + return coreApi.createNamespacedService(KUBERNETES_NAMESPACE, service).execute() + } + + private fun loadWorkspaceSpecificValues(workspaceInstance: WorkspaceInstance, deployment: V1Deployment, image: ImageNameAndTag) { + try { + val container = deployment.spec!!.template.spec!!.containers[0] + + // The image registry is made available to the container runtime via a NodePort + // localhost in this case is the kubernetes node, not the instances-manager + container.image = "${INTERNAL_DOCKER_REGISTRY_AUTHORITY}/${image.name}:${image.tag}" + + val resources = container.resources ?: return + val memoryLimit = Quantity.fromString(workspaceInstance.config.memoryLimit) + val limits = resources.limits + if (limits != null) limits["memory"] = memoryLimit + val requests = resources.requests + if (requests != null) requests["memory"] = memoryLimit + } catch (ex: Exception) { + LOG.error("Failed to configure the deployment for the workspace ${workspaceInstance.config.id}", ex) + } + } + + private fun getAccessControlData(): AccessControlData { + return workspaceManager.accessControlPersistence.read() + } +} diff --git a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceJobQueue.kt b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceJobQueue.kt index d8013df2..8c4f0924 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceJobQueue.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceJobQueue.kt @@ -34,33 +34,28 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import org.modelix.model.persistent.HashUtil -import org.modelix.workspaces.Workspace +import org.modelix.services.workspaces.stubs.models.WorkspaceConfig +import org.modelix.workspaces.InternalWorkspaceConfig import org.modelix.workspaces.WorkspaceAndHash import org.modelix.workspaces.WorkspaceBuildStatus -import org.modelix.workspaces.WorkspaceHash -import java.io.IOException import java.util.Locale import kotlin.time.Duration.Companion.seconds -class WorkspaceJobQueue(val tokenGenerator: (Workspace) -> String) { +class WorkspaceJobQueue(val tokenGenerator: (WorkspaceConfig) -> String) { - private val workspaceHash2job: MutableMap = LinkedHashMap() + private val workspaceConfig2job: MutableMap = LinkedHashMap() private val coroutinesScope = CoroutineScope(Dispatchers.Default) init { - try { - Configuration.setDefaultApiClient(ClientBuilder.cluster().build()) - } catch (e: IOException) { - throw RuntimeException(e) - } + Configuration.setDefaultApiClient(ClientBuilder.cluster().build()) coroutinesScope.launch { while (coroutinesScope.isActive) { delay(3.seconds) try { - if (workspaceHash2job.isNotEmpty()) { + if (workspaceConfig2job.isNotEmpty()) { reconcileKubernetesJobs() - workspaceHash2job.values.forEach { it.updateLog() } + workspaceConfig2job.values.forEach { it.updateLog() } } } catch (ex: Exception) { LOG.error(ex) { "" } @@ -72,7 +67,7 @@ class WorkspaceJobQueue(val tokenGenerator: (Workspace) -> String) { private fun baseImageExists(mpsVersion: String): Boolean { return try { runBlocking { - HttpClient(CIO).get("http://${HELM_PREFIX}docker-registry:5000/v2/modelix/workspace-client-baseimage/manifests/${System.getenv("MPS_BASEIMAGE_VERSION")}-mps$mpsVersion ") { + HttpClient(CIO).get("http://${HELM_PREFIX}docker-registry:5000/v2/modelix/workspace-client-baseimage/manifests/${System.getenv("MPS_BASEIMAGE_VERSION")}-mps$mpsVersion") { header("Accept", "application/vnd.oci.image.manifest.v1+json") }.status == HttpStatusCode.OK } @@ -87,22 +82,22 @@ class WorkspaceJobQueue(val tokenGenerator: (Workspace) -> String) { } fun removeByWorkspaceId(workspaceId: String) { - synchronized(workspaceHash2job) { - workspaceHash2job -= workspaceHash2job.filter { it.value.workspace.id == workspaceId }.keys + synchronized(workspaceConfig2job) { + workspaceConfig2job -= workspaceConfig2job.filter { it.value.workspace.id == workspaceId }.keys } } - fun getJobs(): List = synchronized(workspaceHash2job) { workspaceHash2job.values.toList() } + fun getJobs(): List = synchronized(workspaceConfig2job) { workspaceConfig2job.values.toList() } fun getOrCreateJob(workspace: WorkspaceAndHash): Job { - synchronized(workspaceHash2job) { - return workspaceHash2job.getOrPut(workspace.hash()) { Job(workspace) } + synchronized(workspaceConfig2job) { + return workspaceConfig2job.getOrPut(workspace.workspace) { Job(workspace) } } } private fun reconcileKubernetesJobs() { - val expectedJobs: Map = synchronized(workspaceHash2job) { - workspaceHash2job.values.associateBy { it.kubernetesJobName } + val expectedJobs: Map = synchronized(workspaceConfig2job) { + workspaceConfig2job.values.associateBy { it.kubernetesJobName } } val existingJobs: Map = BatchV1Api() .listNamespacedJob(KUBERNETES_NAMESPACE) @@ -199,7 +194,7 @@ class WorkspaceJobQueue(val tokenGenerator: (Workspace) -> String) { val jobContainerMemoryMega = (jobContainerMemoryBytes / 1024.toBigDecimal()).toBigInteger().toBigDecimal() val memoryLimit = Quantity(jobContainerMemoryMega * 1024.toBigDecimal(), Quantity.Format.BINARY_SI).toSuffixedString() - val jwtToken = tokenGenerator(workspace.workspace) + val jwtToken = "" //tokenGenerator(workspace.workspace) val dockerConfigSecretName = System.getenv("DOCKER_CONFIG_SECRET_NAME") val dockerConfigInternalRegistrySecretName = System.getenv("DOCKER_CONFIG_INTERN_REGISTRY_SECRET_NAME") diff --git a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceJobQueueUI.kt b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceJobQueueUI.kt deleted file mode 100644 index 675fd6b4..00000000 --- a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceJobQueueUI.kt +++ /dev/null @@ -1,161 +0,0 @@ -package org.modelix.workspace.manager - -import io.ktor.server.application.call -import io.ktor.server.request.receiveParameters -import io.ktor.server.response.respondRedirect -import io.ktor.server.routing.Route -import io.ktor.server.routing.get -import io.ktor.server.routing.post -import kotlinx.html.a -import kotlinx.html.body -import kotlinx.html.br -import kotlinx.html.div -import kotlinx.html.h1 -import kotlinx.html.head -import kotlinx.html.hiddenInput -import kotlinx.html.img -import kotlinx.html.link -import kotlinx.html.meta -import kotlinx.html.postForm -import kotlinx.html.style -import kotlinx.html.submitInput -import kotlinx.html.table -import kotlinx.html.td -import kotlinx.html.th -import kotlinx.html.thead -import kotlinx.html.title -import kotlinx.html.tr -import kotlinx.html.unsafe -import org.modelix.authorization.checkPermission -import org.modelix.authorization.hasPermission -import org.modelix.workspaces.WorkspaceAndHash -import org.modelix.workspaces.WorkspaceHash -import org.modelix.workspaces.WorkspacesPermissionSchema -import org.modelix.workspaces.withHash - -class WorkspaceJobQueueUI(val manager: WorkspaceManager) { - - fun install(route: Route) { - with(route) { - installRoutes() - } - } - - private fun Route.installRoutes() { - get("/") { - call.respondHtmlSafe { - head { - title("Workspaces Build Queue") - link("../../public/modelix-base.css", rel = "stylesheet") - style { - unsafe { - +""" - tbody tr { - border: 1px solid #dddddd; - } - tbody tr:nth-of-type(even) { - background: none; - } - """.trimIndent() - } - } - meta { - httpEquiv = "refresh" - content = "3" - } - } - - body { - style = "display: flex; flex-direction: column; align-items: center;" - div { - style = "display: flex; justify-content: center;" - a("../../") { - style = "background-color: #343434; border-radius: 15px; padding: 10px;" - img("Modelix Logo") { - src = "../../public/logo-dark.svg" - width = "70px" - height = "70px" - } - } - } - div { - style = "display: flex; flex-direction: column; justify-content: center;" - h1 { +"Workspaces Build Queue" } - table { - thead { - tr { - th { - +"Workspace Name" - br { } - +"Workspace ID" - br { } - +"Workspace Hash" - } - th { +"Status" } - th { } - th { } - } - } - - val jobsByHash: Map = manager.buildJobs.getJobs().associateBy { it.workspace } - val latestWorkspaces = manager.getAllWorkspaces().sortedBy { it.id }.map { it.withHash() }.toSet() - val allWorkspaceHashes: Set = (latestWorkspaces + jobsByHash.keys).toSet() - - for (workspaceAndHash in allWorkspaceHashes.sortedBy { it.id }) { - if (!call.hasPermission(WorkspacesPermissionSchema.workspaces.workspace(workspaceAndHash.id).buildJob.view)) continue - - val job = jobsByHash[workspaceAndHash] - - tr { - td { - if (!latestWorkspaces.contains(workspaceAndHash)) style = "color: #aaa" - +workspaceAndHash.name.orEmpty() - br { } - +workspaceAndHash.id - br { } - +workspaceAndHash.hash().toString() - } - td { - +job?.status?.toString().orEmpty() - } - td { - a("../${workspaceAndHash.hash().hash}/buildlog", target = "_blank") { - +"Show Log" - } - } - td { - if (call.hasPermission(WorkspacesPermissionSchema.workspaces.workspace(workspaceAndHash.id).buildJob.restart)) { - postForm("rebuild") { - hiddenInput { - name = "workspaceHash" - value = workspaceAndHash.hash().toString() - } - submitInput(classes = "btn") { - value = "Rebuild" - } - } - } - } - } - } - } - } - } - } - } - - post("rebuild") { - val hash = WorkspaceHash( - requireNotNull(call.receiveParameters()["workspaceHash"]) { - "Parameter 'workspaceHash' missing" - }, - ) - val workspace = requireNotNull(manager.getWorkspaceForHash(hash)) { - "Workspace with hash '$hash' unknown" - } - call.checkPermission(WorkspacesPermissionSchema.workspaces.workspace(workspace.id).buildJob.restart) - manager.rebuild(hash) - call.respondRedirect(url = ".") - } - } -} diff --git a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceManager.kt b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceManager.kt index 045603a5..00ed4911 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceManager.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceManager.kt @@ -1,60 +1,31 @@ -/* - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ package org.modelix.workspace.manager -import io.ktor.client.HttpClient -import io.ktor.client.call.body -import io.ktor.client.engine.cio.CIO -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.client.plugins.expectSuccess -import io.ktor.client.request.forms.MultiPartFormDataContent -import io.ktor.client.request.forms.formData -import io.ktor.client.request.get -import io.ktor.client.request.post -import io.ktor.client.request.put -import io.ktor.client.request.setBody -import io.ktor.client.statement.bodyAsText -import io.ktor.http.ContentType -import io.ktor.http.HttpStatusCode -import io.ktor.http.appendPathSegments -import io.ktor.http.content.TextContent -import io.ktor.http.takeFrom -import io.ktor.serialization.kotlinx.json.json -import io.ktor.server.util.url -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.Serializable import org.modelix.authorization.ModelixJWTUtil import org.modelix.authorization.permissions.FileSystemAccessControlPersistence -import org.modelix.model.lazy.RepositoryId import org.modelix.model.persistent.SerializationUtil -import org.modelix.model.server.ModelServerPermissionSchema -import org.modelix.workspaces.ModelServerWorkspacePersistence +import org.modelix.services.workspaces.FileSystemPersistence +import org.modelix.services.workspaces.PersistedState +import org.modelix.services.workspaces.stubs.models.WorkspaceConfig +import org.modelix.workspaces.InternalWorkspaceConfig import org.modelix.workspaces.UploadId -import org.modelix.workspaces.Workspace -import org.modelix.workspaces.WorkspaceHash -import org.modelix.workspaces.WorkspacePersistence +import org.modelix.workspaces.WorkspaceConfigForBuild import org.modelix.workspaces.WorkspacesPermissionSchema -import org.modelix.workspaces.withHash import java.io.File -class WorkspaceManager(private val credentialsEncryption: CredentialsEncryption) { +@Serializable +data class WorkspaceManagerData(val workspaces: Map = emptyMap()) + +class WorkspaceManager(val credentialsEncryption: CredentialsEncryption) { val jwtUtil = ModelixJWTUtil().also { it.loadKeysFromEnvironment() } - private val persistenceFile = File(System.getenv("WORKSPACES_DB_FILE") ?: "/workspace-manager/config/workspaces.json") - val accessControlPersistence = FileSystemAccessControlPersistence(persistenceFile.parentFile.resolve("permissions.json")) - val workspacePersistence: WorkspacePersistence = FileSystemWorkspacePersistence(persistenceFile) + val data: SharedMutableState = PersistedState( + persistence = FileSystemPersistence( + file = File("/workspace-manager/config/workspaces-v2.json"), + serializer = WorkspaceManagerData.serializer(), + ), + defaultState = { WorkspaceManagerData() }, + ).state + val accessControlPersistence = FileSystemAccessControlPersistence(File("/workspace-manager/config/permissions.json")) private val directory: File = run { // The workspace will contain git repositories. Avoid cloning them into an existing repository. val ancestors = mutableListOf(File(".").absoluteFile) @@ -63,53 +34,36 @@ class WorkspaceManager(private val credentialsEncryption: CredentialsEncryption) val workspacesDir = if (parentRepoDir != null) File(parentRepoDir.parent, "modelix-workspaces") else File("modelix-workspaces") workspacesDir.absoluteFile } - val workspaceJobTokenGenerator: (Workspace) -> String = { workspace -> + val workspaceJobTokenGenerator: (WorkspaceConfigForBuild) -> String = { workspace -> jwtUtil.createAccessToken( "workspace-job@modelix.org", listOf( WorkspacesPermissionSchema.workspaces.workspace(workspace.id).config.read.fullId, WorkspacesPermissionSchema.workspaces.workspace(workspace.id).config.readCredentials.fullId, WorkspacesPermissionSchema.workspaces.workspace(workspace.id).buildResult.write.fullId, - ) + workspace.uploads.map { uploadId -> WorkspacesPermissionSchema.workspaces.uploads.upload(uploadId).read.fullId }, + ), // + workspace.uploads.map { uploadId -> WorkspacesPermissionSchema.workspaces.uploads.upload(uploadId).read.fullId } ) } - val buildJobs = WorkspaceJobQueue(tokenGenerator = workspaceJobTokenGenerator) - val kestraClient = KestraClient(jwtUtil) - init { - println("workspaces directory: $directory") +// val buildJobs = WorkspaceJobQueue(tokenGenerator = workspaceJobTokenGenerator) + val kestraClient = KestraClient(jwtUtil) - // migrate existing workspaces from model-server persistence to file system persistence - if (!(persistenceFile.exists())) { - val legacyWorkspacePersistence: WorkspacePersistence = ModelServerWorkspacePersistence({ - jwtUtil.createAccessToken( - "workspace-manager@modelix.org", - listOf( - "legacy-user-defined-entries/write", - "legacy-user-defined-entries/read", - "legacy-global-objects/add", - "legacy-global-objects/read", - ), - ) - }) - for (id in legacyWorkspacePersistence.getWorkspaceIds()) { - val ws = legacyWorkspacePersistence.getWorkspaceForId(id) ?: continue - workspacePersistence.update(ws) - } - } + fun updateWorkspace(workspaceId: String, updater: (WorkspaceConfig) -> WorkspaceConfig): WorkspaceConfig { + return data.update { + it.copy( + workspaces = it.workspaces + (workspaceId to updater(it.workspaces.getValue(workspaceId))), + ) + }.workspaces.getValue(workspaceId) } - @Synchronized - fun update(workspace: Workspace): WorkspaceHash { - val workspaceWithEncryptedCredentials = credentialsEncryption.copyWithEncryptedCredentials(workspace) - val hash = workspacePersistence.update(workspaceWithEncryptedCredentials) - synchronized(buildJobs) { - buildJobs.removeByWorkspaceId(workspace.id) + fun putWorkspace(workspace: WorkspaceConfig) { + require(workspace.id.isNotBlank()) + data.update { + it.copy(workspaces = it.workspaces + (workspace.id to workspace)) } - return hash } - fun getWorkspaceDirectory(workspace: Workspace) = File(directory, workspace.id) + fun getWorkspaceDirectory(workspace: InternalWorkspaceConfig) = File(directory, workspace.id) fun newUploadFolder(): File { val existingFolders = getUploadsFolder().listFiles()?.toList() ?: emptyList() @@ -133,208 +87,38 @@ class WorkspaceManager(private val credentialsEncryption: CredentialsEncryption) } } - fun buildWorkspaceDownloadFileAsync(workspaceHash: WorkspaceHash): WorkspaceJobQueue.Job { - val workspace = requireNotNull(workspacePersistence.getWorkspaceForHash(workspaceHash)) { "Workspace not found: $workspaceHash" } - return buildJobs.getOrCreateJob(workspace) - } - - fun rebuild(workspaceHash: WorkspaceHash): WorkspaceJobQueue.Job { - val workspace = requireNotNull(workspacePersistence.getWorkspaceForHash(workspaceHash)) { "Workspace not found: $workspaceHash" } - return synchronized(buildJobs) { - buildJobs.removeByWorkspaceId(workspace.id) - buildJobs.getOrCreateJob(workspace) - } - } - - fun getAllWorkspaces() = workspacePersistence.getAllWorkspaces() - fun getWorkspaceIds() = workspacePersistence.getWorkspaceIds() - fun getWorkspaceForId(workspaceId: String) = workspacePersistence.getWorkspaceForId(workspaceId)?.withHash() - fun getWorkspaceForHash(workspaceHash: WorkspaceHash) = workspacePersistence.getWorkspaceForHash(workspaceHash) - fun newWorkspace(owner: String?): Workspace { - val newWorkspace = workspacePersistence.newWorkspace() - if (owner != null) { - accessControlPersistence.update { data -> - data.withGrantToUser(owner, WorkspacesPermissionSchema.workspaces.workspace(newWorkspace.id).owner.fullId) - } + fun getAllWorkspaces() = data.getValue().workspaces.values.toList() + +// fun getWorkspaceIds() = workspacePersistence.getWorkspaceIds() + fun getWorkspace(workspaceId: String) = data.getValue().workspaces[workspaceId] +// fun getWorkspaceForHash(workspaceHash: WorkspaceHash) = workspacePersistence.getWorkspaceForHash(workspaceHash) +// fun newWorkspace(owner: String?): InternalWorkspaceConfig { +// val newWorkspace = workspacePersistence.newWorkspace() +// if (owner != null) { +// accessControlPersistence.update { data -> +// data.withGrantToUser(owner, WorkspacesPermissionSchema.workspaces.workspace(newWorkspace.id).owner.fullId) +// } +// } +// return newWorkspace +// } + + fun assignOwner(workspaceId: String, owner: String) { + accessControlPersistence.update { data -> + data.withGrantToUser(owner, WorkspacesPermissionSchema.workspaces.workspace(workspaceId).owner.fullId) } - return newWorkspace } - fun removeWorkspace(workspaceId: String) = workspacePersistence.removeWorkspace(workspaceId) - - suspend fun enqueueGitImport(workspaceId: String): List { - kestraClient.updateGitImportFlow() - val workspace = requireNotNull(getWorkspaceForId(workspaceId)) { "Workspace not found: $workspaceId" } - val existingExecutions = kestraClient.getRunningImportJobIds(workspaceId) - if (existingExecutions.isNotEmpty()) { - return existingExecutions - } - return kestraClient.enqueueGitImport(credentialsEncryption.copyWithDecryptedCredentials(workspace.workspace))["id"]!!.jsonPrimitive.content.let { listOf(it) } - } -} -class KestraClient(val jwtUtil: ModelixJWTUtil) { - private val kestraApiEndpoint = url { - takeFrom(System.getenv("KESTRA_URL")) - appendPathSegments("api", "v1") + fun removeWorkspace(workspaceId: String) { + data.update { it.copy(workspaces = it.workspaces - workspaceId) } } - private val httpClient = HttpClient(CIO) { - expectSuccess = true - install(ContentNegotiation) { - json() - } - } - - suspend fun getRunningImportJobIds(workspaceId: String): List { - val responseObject: JsonObject = httpClient.get { - url { - takeFrom(kestraApiEndpoint) - appendPathSegments("executions", "search") - parameters.append("namespace", "modelix") - parameters.append("flowId", "git_import") - parameters.append("labels", "workspace:$workspaceId") - parameters.append("state", "CREATED") - parameters.append("state", "QUEUED") - parameters.append("state", "RUNNING") - parameters.append("state", "RETRYING") - parameters.append("state", "PAUSED") - parameters.append("state", "RESTARTED") - parameters.append("state", "KILLING") - } - }.body() - - return responseObject["results"]!!.jsonArray.map { it.jsonObject["id"]!!.jsonPrimitive.content } - } - - suspend fun enqueueGitImport(workspace: Workspace): JsonObject { - val gitRepo = workspace.gitRepositories.first() - - updateGitImportFlow() - - val targetBranch = RepositoryId("workspace_${workspace.id}").getBranchReference("git-import") - val token = jwtUtil.createAccessToken( - "git-import@modelix.org", - listOf( - ModelServerPermissionSchema.repository(targetBranch.repositoryId).create.fullId, - ModelServerPermissionSchema.branch(targetBranch).rewrite.fullId, - ), - ) - - val response = httpClient.post { - url { - takeFrom(kestraApiEndpoint) - appendPathSegments("executions", "modelix", "git_import") - parameters["labels"] = "workspace:${workspace.id}" - } - setBody( - MultiPartFormDataContent( - formData { - append("git_url", gitRepo.url) - append("git_revision", "origin/${gitRepo.branch}") - append("modelix_repo_name", "workspace_${workspace.id}") - append("modelix_target_branch", "git-import") - append("token", token) - gitRepo.credentials?.also { credentials -> - append("git_user", credentials.user) - append("git_pw", credentials.password) - } - }, - ), - ) - } - - return response.body() - } - - suspend fun updateGitImportFlow() { - // language=yaml - val content = TextContent( - """ - id: git_import - namespace: modelix - - inputs: - - id: git_url - type: URI - required: true - defaults: https://github.com/coolya/Durchblick.git - - id: git_revision - type: STRING - defaults: HEAD - - id: modelix_repo_name - type: STRING - required: true - - id: modelix_target_branch - type: STRING - required: true - defaults: git-import - - id: token - type: SECRET - required: true - - type: SECRET - id: git_pw - required: false - - type: SECRET - id: git_user - required: false - - id: git_limit - type: INT - defaults: 200 - - tasks: - - id: clone_and_import - type: io.kestra.plugin.kubernetes.PodCreate - namespace: ${System.getenv("KUBERNETES_NAMESPACE")} - spec: - containers: - - name: importer - image: ${System.getenv("GIT_IMPORT_IMAGE")} - args: - - git-import-remote - - "{{ inputs.git_url }}" - - --git-user - - "{{ inputs.git_user }}" - - --git-password - - "{{ inputs.git_pw }}" - - --limit - - "{{ inputs.git_limit }}" - - --model-server - - "${System.getenv("model_server_url")}" - - --token - - "{{ inputs.token }}" - - --repository - - "{{ inputs.modelix_repo_name }}" - - --branch - - "{{ inputs.modelix_target_branch }}" - - --rev - - "{{ inputs.git_revision }}" - restartPolicy: Never - """.trimIndent(), - ContentType("application", "x-yaml"), - ) - - val response = httpClient.put { - expectSuccess = false - url { - takeFrom(kestraApiEndpoint) - appendPathSegments("flows", "modelix", "git_import") - } - setBody(content) - } - when (response.status) { - HttpStatusCode.OK -> {} - HttpStatusCode.NotFound -> { - httpClient.post { - url { - takeFrom(kestraApiEndpoint) - appendPathSegments("flows") - } - setBody(content) - } - } - else -> { - throw RuntimeException("${response.status}\n\n${response.bodyAsText()}\n\n${content.text}") - } - } - } +// suspend fun enqueueGitImport(workspaceId: String): List { +// kestraClient.updateGitImportFlow() +// val workspace = requireNotNull(getWorkspaceForId(workspaceId)) { "Workspace not found: $workspaceId" } +// val existingExecutions = kestraClient.getRunningImportJobIds(workspaceId) +// if (existingExecutions.isNotEmpty()) { +// return existingExecutions +// } +// return kestraClient.enqueueGitImport(credentialsEncryption.copyWithDecryptedCredentials(workspace.workspace))["id"]!!.jsonPrimitive.content.let { listOf(it) } +// } } diff --git a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceManagerModule.kt b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceManagerModule.kt index cc593640..0553613d 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceManagerModule.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceManagerModule.kt @@ -14,136 +14,92 @@ package org.modelix.workspace.manager -import com.charleskorn.kaml.Yaml -import io.ktor.client.HttpClient -import io.ktor.client.engine.cio.CIO -import io.ktor.client.plugins.expectSuccess -import io.ktor.client.request.bearerAuth -import io.ktor.client.request.forms.submitForm import io.ktor.http.ContentType import io.ktor.http.HttpHeaders -import io.ktor.http.HttpMethod import io.ktor.http.HttpStatusCode -import io.ktor.http.content.PartData -import io.ktor.http.content.forEachPart -import io.ktor.http.content.streamProvider import io.ktor.http.encodeURLPathPart import io.ktor.http.parameters import io.ktor.serialization.kotlinx.json.json import io.ktor.server.application.Application import io.ktor.server.application.ApplicationCall -import io.ktor.server.application.ApplicationCallPipeline import io.ktor.server.application.call import io.ktor.server.application.install -import io.ktor.server.auth.jwt.JWTPrincipal -import io.ktor.server.auth.principal -import io.ktor.server.html.respondHtml import io.ktor.server.http.content.staticResources +import io.ktor.server.plugins.calllogging.CallLogging +import io.ktor.server.plugins.calllogging.processingTimeMillis import io.ktor.server.plugins.contentnegotiation.ContentNegotiation import io.ktor.server.plugins.cors.routing.CORS -import io.ktor.server.request.receiveMultipart -import io.ktor.server.request.receiveParameters -import io.ktor.server.response.respond +import io.ktor.server.plugins.origin +import io.ktor.server.request.httpMethod +import io.ktor.server.request.path import io.ktor.server.response.respondOutputStream -import io.ktor.server.response.respondRedirect import io.ktor.server.response.respondText -import io.ktor.server.routing.RoutingContext import io.ktor.server.routing.get -import io.ktor.server.routing.intercept -import io.ktor.server.routing.post -import io.ktor.server.routing.route import io.ktor.server.routing.routing -import io.ktor.util.encodeBase64 import io.kubernetes.client.custom.Quantity -import kotlinx.html.DIV import kotlinx.html.FlowOrInteractiveOrPhrasingContent -import kotlinx.html.FormEncType -import kotlinx.html.FormMethod import kotlinx.html.HTML import kotlinx.html.HTMLTag -import kotlinx.html.InputType import kotlinx.html.a -import kotlinx.html.b import kotlinx.html.body -import kotlinx.html.br -import kotlinx.html.code import kotlinx.html.div -import kotlinx.html.form -import kotlinx.html.h1 -import kotlinx.html.h2 -import kotlinx.html.head -import kotlinx.html.hiddenInput import kotlinx.html.html import kotlinx.html.i -import kotlinx.html.img -import kotlinx.html.input -import kotlinx.html.li -import kotlinx.html.link -import kotlinx.html.meta -import kotlinx.html.p -import kotlinx.html.postForm -import kotlinx.html.pre -import kotlinx.html.span import kotlinx.html.stream.createHTML import kotlinx.html.style -import kotlinx.html.submitInput import kotlinx.html.svg -import kotlinx.html.table -import kotlinx.html.td -import kotlinx.html.textArea -import kotlinx.html.th -import kotlinx.html.thead -import kotlinx.html.title -import kotlinx.html.tr -import kotlinx.html.ul -import kotlinx.html.unsafe import kotlinx.html.visit -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.json.Json import org.apache.commons.compress.archivers.tar.TarArchiveEntry import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream -import org.apache.commons.io.FileUtils import org.modelix.authorization.ModelixAuthorization -import org.modelix.authorization.NoPermissionException -import org.modelix.authorization.checkPermission -import org.modelix.authorization.getUnverifiedJwt -import org.modelix.authorization.getUserName -import org.modelix.authorization.hasPermission import org.modelix.authorization.permissions.PermissionParts import org.modelix.authorization.permissions.PermissionSchemaBase -import org.modelix.authorization.requiresLogin -import org.modelix.gitui.GIT_REPO_DIR_ATTRIBUTE_KEY -import org.modelix.gitui.MPS_INSTANCE_URL_ATTRIBUTE_KEY -import org.modelix.gitui.gitui -import org.modelix.instancesmanager.DeploymentManager import org.modelix.instancesmanager.DeploymentsProxy -import org.modelix.instancesmanager.adminModule -import org.modelix.model.persistent.HashUtil -import org.modelix.model.server.ModelServerPermissionSchema -import org.modelix.workspace.manager.WorkspaceJobQueue.Companion.HELM_PREFIX +import org.modelix.model.client2.ModelClientV2 +import org.modelix.services.gitconnector.GitConnectorController +import org.modelix.services.gitconnector.GitConnectorManager +import org.modelix.services.mavenconnector.stubs.controllers.ModelixMavenConnectorController +import org.modelix.services.mavenconnector.stubs.controllers.ModelixMavenConnectorController.Companion.modelixMavenConnectorRoutes +import org.modelix.services.mavenconnector.stubs.controllers.ModelixMavenConnectorRepositoriesController +import org.modelix.services.mavenconnector.stubs.controllers.ModelixMavenConnectorRepositoriesController.Companion.modelixMavenConnectorRepositoriesRoutes +import org.modelix.services.mavenconnector.stubs.controllers.TypedApplicationCall +import org.modelix.services.mavenconnector.stubs.models.MavenConnectorConfig +import org.modelix.services.mavenconnector.stubs.models.MavenRepository +import org.modelix.services.mavenconnector.stubs.models.MavenRepositoryList +import org.modelix.services.workspaces.WorkspacesController import org.modelix.workspaces.Credentials import org.modelix.workspaces.GitRepository +import org.modelix.workspaces.InternalWorkspaceConfig import org.modelix.workspaces.SharedInstance -import org.modelix.workspaces.UploadId -import org.modelix.workspaces.Workspace import org.modelix.workspaces.WorkspaceAndHash -import org.modelix.workspaces.WorkspaceBuildStatus -import org.modelix.workspaces.WorkspaceHash -import org.modelix.workspaces.WorkspaceProgressItems import org.modelix.workspaces.WorkspacesPermissionSchema -import org.zeroturnaround.zip.ZipUtil import java.io.BufferedOutputStream import java.io.File import java.util.zip.GZIPOutputStream import java.util.zip.ZipInputStream -import java.util.zip.ZipOutputStream fun Application.workspaceManagerModule() { val credentialsEncryption = createCredentialEncryption() val manager = WorkspaceManager(credentialsEncryption) - val deploymentManager = DeploymentManager(manager) - val deploymentsProxy = DeploymentsProxy(deploymentManager) + // val deploymentManager = DeploymentManager(manager) + val buildManager = WorkspaceBuildManager(this, manager.workspaceJobTokenGenerator) val maxBodySize = environment.config.property("modelix.maxBodySize").getString() + val gitManager = GitConnectorManager( + scope = this, + kestraClient = manager.kestraClient, + modelClient = ModelClientV2.builder() + .url(System.getenv("model_server_url")) + .lazyAndBlockingQueries() + .authToken { + manager.jwtUtil.createAccessToken( + "git-connector@modelix.org", + listOf(PermissionSchemaBase.cluster.admin.fullId), + ) + }.build(), + ) + val gitController = GitConnectorController(gitManager) + val instancesManager = WorkspaceInstancesManager(manager, buildManager, coroutinesScope = this, gitManager = gitManager) + val deploymentsProxy = DeploymentsProxy(instancesManager) deploymentsProxy.startServer() @@ -157,1031 +113,1083 @@ fun Application.workspaceManagerModule() { json() } + install(CallLogging) { + format { call -> + // Resemble the default format but include remote host and user agent for easier tracing on who issued a certain request. + // INFO ktor.application - 200 OK: GET - /public/modelix-base.css in 60ms + val status = call.response.status() + val httpMethod = call.request.httpMethod.value + val userAgent = call.request.headers["User-Agent"] + val processingTimeMillis = call.processingTimeMillis() + val path = call.request.path() + val remoteHost = call.request.origin.remoteHost + "$status: $httpMethod - $path in ${processingTimeMillis}ms [Remote host: '$remoteHost', User agent: '$userAgent']" + } + } + routing { staticResources("static/", basePackage = "org.modelix.workspace.static") - route("instances") { - this.adminModule(deploymentManager) - } +// route("instances") { +// this.adminModule(deploymentManager) +// } - requiresLogin { - get("/") { - call.respondHtmlSafe(HttpStatusCode.OK) { - head { - title("Workspaces") - link("../public/modelix-base.css", rel = "stylesheet") - style { - unsafe { - +""" - form { - margin: auto; - } - .workspace-name { - font-weight: bold; - color: #000000; - } - """.trimIndent() - } - } - } - body { - style = "display: flex; flex-direction: column; align-items: center;" - div { - style = "display: flex; justify-content: center;" - a("../") { - style = "background-color: #343434; border-radius: 15px; padding: 10px;" - img("Modelix Logo") { - src = "../public/logo-dark.svg" - width = "70px" - height = "70px" - } - } - } - div { - style = "display: flex; flex-direction: column; justify-content: center;" - h1 { text("Workspaces") } - p { - +"A workspace allows to deploy an MPS project and all of its dependencies to Modelix and edit it in the browser." - br {} - +" Solutions are synchronized with the model server and between all MPS instances." - } - table { - thead { - tr { - th { +"Workspace" } - th { - colSpan = "6" - +"Actions" - } - } - } - manager.getWorkspaceIds() - .filter { - call.hasPermission(WorkspacesPermissionSchema.workspaces.workspace(it).list) - } - .mapNotNull { manager.getWorkspaceForId(it) }.forEach { workspaceAndHash -> - val workspace = workspaceAndHash.workspace - val workspaceId = workspace.id - val canRead = call.hasPermission(WorkspacesPermissionSchema.workspaces.workspace(workspaceId).config.read) - val canWrite = call.hasPermission(WorkspacesPermissionSchema.workspaces.workspace(workspaceId).config.write) - val canDelete = call.hasPermission(WorkspacesPermissionSchema.workspaces.workspace(workspaceId).config.delete) - tr { - td { - a(classes = "workspace-name") { - if (canRead) href = "$workspaceId/edit" - text((workspace?.name ?: "") + " ($workspaceId)") - } - } - // Shadow models based UI was removed -// td { -// if (canRead) { -// a { -// href = "../${workspaceInstanceUrl(workspaceAndHash)}/project" -// text("Open Web Interface") -// } -// } -// } - td { - if (canRead) { - a { - href = "../${workspaceInstanceUrl(workspaceAndHash)}/ide/?waitForIndexer=true" - text("Open MPS") - } - } - for (sharedInstance in workspace.sharedInstances) { - if (sharedInstance.allowWrite && !canWrite) continue - br {} - a { - href = "../${workspaceInstanceUrl(workspaceAndHash, sharedInstance)}/ide/?waitForIndexer=true" - text("Open MPS [${sharedInstance.name}]") - } - } - } - td { - if (canRead) { - a { - href = "../${workspaceInstanceUrl(workspaceAndHash)}/generator/?waitForIndexer=true" - text("Generator") - } - } - for (sharedInstance in workspace.sharedInstances) { - if (sharedInstance.allowWrite && !canWrite) continue - br {} - a { - href = "../${workspaceInstanceUrl(workspaceAndHash, sharedInstance)}/generator/?waitForIndexer=true" - text("Generator [${sharedInstance.name}]") - } - } - } - td { - if (canRead) { - workspace.gitRepositories.forEachIndexed { index, gitRepository -> - a { - href = "$workspaceId/git/$index/" - val suffix = if (gitRepository.name.isNullOrEmpty()) "" else " (${gitRepository.name})" - text("Git History$suffix") - } - } - workspace.uploadIds().associateWith { findGitRepo(manager.getUploadFolder(it)) } - .filter { it.value != null }.forEach { upload -> - a { - href = "$workspaceId/git/u${upload.key}/" - text("Git History") - } - } - } - } - td { - if (canRead) { - a { - href = "$workspaceId/model-history" - text("Model History") - } - } - } - td { - buildPermissionManagementLink(WorkspacesPermissionSchema.workspaces.workspace(workspaceId).resource) - } - td { - if (canDelete) { - postForm("./remove-workspace") { - style = "display: inline-block" - hiddenInput { - name = "workspaceId" - value = workspaceId - } - submitInput(classes = "btn") { - value = "Remove" - } - } - } - if (workspace.gitRepositories.isNotEmpty()) { - postForm("./api/workspaces/$workspaceId/git-import/trigger") { - style = "display: inline-block" - submitInput(classes = "btn") { - value = "Git Import" - } - } - } - } - } - } - if (call.hasPermission(WorkspacesPermissionSchema.workspaces.add)) { - tr { - td { - colSpan = "7" - form { - action = "new" - method = FormMethod.post - input(classes = "btn") { - type = InputType.submit - value = "+ New Workspace" - } - } - } - } - } - } - } - br {} - div { - a(href = "permissions/resources/workspaces/") { +"Permissions" } - +" | " - a(href = "build-queue/") { +"Build Jobs" } - +" | " - a(href = "instances/") { +"Instances" } - } - } - } - } + MavenControllerImpl().install(this) + WorkspacesController(manager, instancesManager, buildManager, gitManager).install(this) + gitController.install(this) - get("{workspaceId}/hash") { - val workspaceId = call.parameters["workspaceId"]!! - call.checkPermission(WorkspacesPermissionSchema.workspaces.workspace(workspaceId).list) - val workspaceAndHash = manager.getWorkspaceForId(workspaceId) - if (workspaceAndHash == null) { - call.respond(HttpStatusCode.NotFound, "Workspace $workspaceId not found") - } else { - call.respondText(workspaceAndHash.hash().hash, ContentType.Text.Plain, HttpStatusCode.OK) - } + modelixMavenConnectorRoutes(object : ModelixMavenConnectorController { + override suspend fun getMavenConnectorConfig(call: TypedApplicationCall) { + TODO("Not yet implemented") } + }) - route("{workspaceId}/git/{repoOrUploadIndex}/") { - this.intercept(ApplicationCallPipeline.Call) { - val workspaceId = call.parameters["workspaceId"]!! - call.checkPermission(WorkspacesPermissionSchema.workspaces.workspace(workspaceId).config.read) - val repoOrUploadIndex = call.parameters["repoOrUploadIndex"]!! - var repoIndex: Int? = null - var uploadId: UploadId? = null - if (repoOrUploadIndex.startsWith("u")) { - uploadId = UploadId(repoOrUploadIndex.drop(1)) - } else { - repoIndex = repoOrUploadIndex.toInt() - } - val workspaceAndHash = manager.getWorkspaceForId(workspaceId) - if (workspaceAndHash == null) { - call.respondText("Workspace $workspaceId not found", ContentType.Text.Plain, HttpStatusCode.NotFound) - return@intercept - } - val workspace = workspaceAndHash.workspace - val repoDir: File - if (repoIndex != null) { - val repos = workspace.gitRepositories - if (!repos.indices.contains(repoIndex)) { - call.respondText("Git repository with index $repoIndex doesn't exist", ContentType.Text.Plain, HttpStatusCode.NotFound) - return@intercept - } - val repo = repos[repoIndex] - val gitRepoWitDecryptedCredentials = credentialsEncryption.copyWithDecryptedCredentials(repo) - val repoManager = GitRepositoryManager(gitRepoWitDecryptedCredentials, manager.getWorkspaceDirectory(workspace)) - if (!repoManager.repoDirectory.exists()) { - repoManager.updateRepo() - } - repoDir = repoManager.repoDirectory - } else { - val uploadFolder = manager.getUploadFolder(uploadId!!) - if (!uploadFolder.exists()) { - call.respondText("Upload $uploadId doesn't exist", ContentType.Text.Plain, HttpStatusCode.NotFound) - return@intercept - } - if (uploadFolder.resolve(".git").exists()) { - repoDir = uploadFolder - } else { - val repoDirFromUpload = findGitRepo(uploadFolder) - if (repoDirFromUpload == null) { - call.respondText("No git repository found in upload $uploadId", ContentType.Text.Plain, HttpStatusCode.NotFound) - return@intercept - } - repoDir = repoDirFromUpload - } - } - call.attributes.put(GIT_REPO_DIR_ATTRIBUTE_KEY, repoDir) - call.attributes.put(MPS_INSTANCE_URL_ATTRIBUTE_KEY, "../../../../${workspaceInstanceUrl(workspaceAndHash)}/") - } - gitui() + modelixMavenConnectorRepositoriesRoutes(object : ModelixMavenConnectorRepositoriesController { + override suspend fun deleteMavenRepository( + repositoryId: String, + call: ApplicationCall, + ) { + TODO("Not yet implemented") } - post("new") { - call.checkPermission(WorkspacesPermissionSchema.workspaces.add) - val jwt = call.principal() - val workspace = manager.newWorkspace(jwt?.getUserName()) - call.respondRedirect("${workspace.id}/edit") + override suspend fun listMavenRepositories(call: TypedApplicationCall) { + TODO("Not yet implemented") } - route("uploads") { - get("{uploadId}") { - val uploadId = UploadId(call.parameters["uploadId"]!!) - call.checkPermission(WorkspacesPermissionSchema.workspaces.uploads.upload(uploadId.id).read) - val folder = manager.getUploadFolder(uploadId) - call.respondOutputStream(ContentType.Application.Zip) { - ZipOutputStream(this).use { zip -> - zip.copyFiles(folder, mapPath = { folder.toPath().parent.relativize(it) }, extractZipFiles = true) - } - } - } + override suspend fun getMavenRepository( + repositoryId: String, + call: TypedApplicationCall, + ) { + TODO("Not yet implemented") } - route("build-queue") { - WorkspaceJobQueueUI(manager).install(this) + override suspend fun updateMavenRepository( + repositoryId: String, + mavenRepository: MavenRepository, + call: ApplicationCall, + ) { + TODO("Not yet implemented") } - - route(Regex("(?[a-z0-9]+)")) { - fun RoutingContext.workspaceId() = call.parameters["workspaceId"]!! - - get("edit") { - val id = workspaceId() - call.checkPermission(WorkspacesPermissionSchema.workspaces.workspace(id).config.read) - val workspaceAndHash = manager.getWorkspaceForId(id) - if (workspaceAndHash == null) { - call.respond(HttpStatusCode.NotFound, "Workspace $id not found") - return@get - } - val workspace = workspaceAndHash.workspace - val canWrite = call.hasPermission(WorkspacesPermissionSchema.workspaces.workspace(workspace.id).config.write) - - this.call.respondHtml(HttpStatusCode.OK) { - head { - title { text("Edit Workspace") } - link("../../public/modelix-base.css", rel = "stylesheet") - link("../../public/menu-bar.css", rel = "stylesheet") - } - body { - div("menu") { - a("../../") { - style = "height: 70px;" - img("Modelix Logo") { - src = "../../public/logo-dark.svg" - width = "70px" - height = "70px" - } - } - div("menuItem") { - a("../") { +"Workspace List" } - } - div("menuItem") { - a("../${workspaceAndHash.hash().hash}/buildlog") { +"Build Log" } - } - // Shadow models based UI was removed + }) +// +// requiresLogin { +// get("/") { +// call.respondHtmlSafe(HttpStatusCode.OK) { +// head { +// title("Workspaces") +// link("../public/modelix-base.css", rel = "stylesheet") +// style { +// unsafe { +// +""" +// form { +// margin: auto; +// } +// .workspace-name { +// font-weight: bold; +// color: #000000; +// } +// """.trimIndent() +// } +// } +// } +// body { +// style = "display: flex; flex-direction: column; align-items: center;" +// div { +// style = "display: flex; justify-content: center;" +// a("../") { +// style = "background-color: #343434; border-radius: 15px; padding: 10px;" +// img("Modelix Logo") { +// src = "../public/logo-dark.svg" +// width = "70px" +// height = "70px" +// } +// } +// } +// div { +// style = "display: flex; flex-direction: column; justify-content: center;" +// h1 { text("Workspaces") } +// p { +// +"A workspace allows to deploy an MPS project and all of its dependencies to Modelix and edit it in the browser." +// br {} +// +" Solutions are synchronized with the model server and between all MPS instances." +// } +// table { +// thead { +// tr { +// th { +"Workspace" } +// th { +// colSpan = "6" +// +"Actions" +// } +// } +// } +// manager.getWorkspaceIds() +// .filter { +// call.hasPermission(WorkspacesPermissionSchema.workspaces.workspace(it).list) +// } +// .mapNotNull { manager.getWorkspaceForId(it) }.forEach { workspaceAndHash -> +// val workspace = workspaceAndHash.workspace +// val workspaceId = workspace.id +// val canRead = call.hasPermission(WorkspacesPermissionSchema.workspaces.workspace(workspaceId).config.read) +// val canWrite = call.hasPermission(WorkspacesPermissionSchema.workspaces.workspace(workspaceId).config.write) +// val canDelete = call.hasPermission(WorkspacesPermissionSchema.workspaces.workspace(workspaceId).config.delete) +// tr { +// td { +// a(classes = "workspace-name") { +// if (canRead) href = "$workspaceId/edit" +// text((workspace?.name ?: "") + " ($workspaceId)") +// } +// } +// // Shadow models based UI was removed +// // td { +// // if (canRead) { +// // a { +// // href = "../${workspaceInstanceUrl(workspaceAndHash)}/project" +// // text("Open Web Interface") +// // } +// // } +// // } +// td { +// if (canRead) { +// a { +// href = "../${workspaceInstanceUrl(workspaceAndHash)}/ide/?waitForIndexer=true" +// text("Open MPS") +// } +// } +// for (sharedInstance in workspace.sharedInstances) { +// if (sharedInstance.allowWrite && !canWrite) continue +// br {} +// a { +// href = "../${workspaceInstanceUrl(workspaceAndHash, sharedInstance)}/ide/?waitForIndexer=true" +// text("Open MPS [${sharedInstance.name}]") +// } +// } +// } +// td { +// if (canRead) { +// a { +// href = "../${workspaceInstanceUrl(workspaceAndHash)}/generator/?waitForIndexer=true" +// text("Generator") +// } +// } +// for (sharedInstance in workspace.sharedInstances) { +// if (sharedInstance.allowWrite && !canWrite) continue +// br {} +// a { +// href = "../${workspaceInstanceUrl(workspaceAndHash, sharedInstance)}/generator/?waitForIndexer=true" +// text("Generator [${sharedInstance.name}]") +// } +// } +// } +// td { +// if (canRead) { +// workspace.gitRepositories.forEachIndexed { index, gitRepository -> +// a { +// href = "$workspaceId/git/$index/" +// val suffix = if (gitRepository.name.isNullOrEmpty()) "" else " (${gitRepository.name})" +// text("Git History$suffix") +// } +// } +// workspace.uploadIds().associateWith { findGitRepo(manager.getUploadFolder(it)) } +// .filter { it.value != null }.forEach { upload -> +// a { +// href = "$workspaceId/git/u${upload.key}/" +// text("Git History") +// } +// } +// } +// } +// td { +// if (canRead) { +// a { +// href = "$workspaceId/model-history" +// text("Model History") +// } +// } +// } +// td { +// buildPermissionManagementLink(WorkspacesPermissionSchema.workspaces.workspace(workspaceId).resource) +// } +// td { +// if (canDelete) { +// postForm("./remove-workspace") { +// style = "display: inline-block" +// hiddenInput { +// name = "workspaceId" +// value = workspaceId +// } +// submitInput(classes = "btn") { +// value = "Remove" +// } +// } +// } +// if (workspace.gitRepositories.isNotEmpty()) { +// postForm("./api/workspaces/$workspaceId/git-import/trigger") { +// style = "display: inline-block" +// submitInput(classes = "btn") { +// value = "Git Import" +// } +// } +// } +// } +// } +// } +// if (call.hasPermission(WorkspacesPermissionSchema.workspaces.add)) { +// tr { +// td { +// colSpan = "7" +// form { +// action = "new" +// method = FormMethod.post +// input(classes = "btn") { +// type = InputType.submit +// value = "+ New Workspace" +// } +// } +// } +// } +// } +// } +// } +// br {} +// div { +// a(href = "permissions/resources/workspaces/") { +"Permissions" } +// +" | " +// a(href = "build-queue/") { +"Build Jobs" } +// +" | " +// a(href = "instances/") { +"Instances" } +// } +// } +// } +// } +// +// get("{workspaceId}/hash") { +// val workspaceId = call.parameters["workspaceId"]!! +// call.checkPermission(WorkspacesPermissionSchema.workspaces.workspace(workspaceId).list) +// val workspaceAndHash = manager.getWorkspaceForId(workspaceId) +// if (workspaceAndHash == null) { +// call.respond(HttpStatusCode.NotFound, "Workspace $workspaceId not found") +// } else { +// call.respondText(workspaceAndHash.hash().hash, ContentType.Text.Plain, HttpStatusCode.OK) +// } +// } +// +// route("{workspaceId}/git/{repoOrUploadIndex}/") { +// this.intercept(ApplicationCallPipeline.Call) { +// val workspaceId = call.parameters["workspaceId"]!! +// call.checkPermission(WorkspacesPermissionSchema.workspaces.workspace(workspaceId).config.read) +// val repoOrUploadIndex = call.parameters["repoOrUploadIndex"]!! +// var repoIndex: Int? = null +// var uploadId: UploadId? = null +// if (repoOrUploadIndex.startsWith("u")) { +// uploadId = UploadId(repoOrUploadIndex.drop(1)) +// } else { +// repoIndex = repoOrUploadIndex.toInt() +// } +// val workspaceAndHash = manager.getWorkspaceForId(workspaceId) +// if (workspaceAndHash == null) { +// call.respondText("Workspace $workspaceId not found", ContentType.Text.Plain, HttpStatusCode.NotFound) +// return@intercept +// } +// val workspace = workspaceAndHash.workspace +// val repoDir: File +// if (repoIndex != null) { +// val repos = workspace.gitRepositories +// if (!repos.indices.contains(repoIndex)) { +// call.respondText("Git repository with index $repoIndex doesn't exist", ContentType.Text.Plain, HttpStatusCode.NotFound) +// return@intercept +// } +// val repo = repos[repoIndex] +// val gitRepoWitDecryptedCredentials = credentialsEncryption.copyWithDecryptedCredentials(repo) +// val repoManager = GitRepositoryManager(gitRepoWitDecryptedCredentials, manager.getWorkspaceDirectory(workspace)) +// if (!repoManager.repoDirectory.exists()) { +// repoManager.updateRepo() +// } +// repoDir = repoManager.repoDirectory +// } else { +// val uploadFolder = manager.getUploadFolder(uploadId!!) +// if (!uploadFolder.exists()) { +// call.respondText("Upload $uploadId doesn't exist", ContentType.Text.Plain, HttpStatusCode.NotFound) +// return@intercept +// } +// if (uploadFolder.resolve(".git").exists()) { +// repoDir = uploadFolder +// } else { +// val repoDirFromUpload = findGitRepo(uploadFolder) +// if (repoDirFromUpload == null) { +// call.respondText("No git repository found in upload $uploadId", ContentType.Text.Plain, HttpStatusCode.NotFound) +// return@intercept +// } +// repoDir = repoDirFromUpload +// } +// } +// call.attributes.put(GIT_REPO_DIR_ATTRIBUTE_KEY, repoDir) +// call.attributes.put(MPS_INSTANCE_URL_ATTRIBUTE_KEY, "../../../../${workspaceInstanceUrl(workspaceAndHash)}/") +// } +// gitui() +// } +// +// post("new") { +// call.checkPermission(WorkspacesPermissionSchema.workspaces.add) +// val jwt = call.principal() +// val workspace = manager.newWorkspace(jwt?.getUserName()) +// call.respondRedirect("${workspace.id}/edit") +// } +// +// route("uploads") { +// get("{uploadId}") { +// val uploadId = UploadId(call.parameters["uploadId"]!!) +// call.checkPermission(WorkspacesPermissionSchema.workspaces.uploads.upload(uploadId.id).read) +// val folder = manager.getUploadFolder(uploadId) +// call.respondOutputStream(ContentType.Application.Zip) { +// ZipOutputStream(this).use { zip -> +// zip.copyFiles(folder, mapPath = { folder.toPath().parent.relativize(it) }, extractZipFiles = true) +// } +// } +// } +// } +// +// route("build-queue") { +// WorkspaceJobQueueUI(manager).install(this) +// } +// +// route(Regex("(?[a-z0-9]+)")) { +// fun RoutingContext.workspaceId() = call.parameters["workspaceId"]!! +// +// get("edit") { +// val id = workspaceId() +// call.checkPermission(WorkspacesPermissionSchema.workspaces.workspace(id).config.read) +// val workspaceAndHash = manager.getWorkspaceForId(id) +// if (workspaceAndHash == null) { +// call.respond(HttpStatusCode.NotFound, "Workspace $id not found") +// return@get +// } +// val workspace = workspaceAndHash.workspace +// val canWrite = call.hasPermission(WorkspacesPermissionSchema.workspaces.workspace(workspace.id).config.write) +// +// this.call.respondHtml(HttpStatusCode.OK) { +// head { +// title { text("Edit Workspace") } +// link("../../public/modelix-base.css", rel = "stylesheet") +// link("../../public/menu-bar.css", rel = "stylesheet") +// } +// body { +// div("menu") { +// a("../../") { +// style = "height: 70px;" +// img("Modelix Logo") { +// src = "../../public/logo-dark.svg" +// width = "70px" +// height = "70px" +// } +// } // div("menuItem") { -// a("../../${workspaceInstanceUrl(workspaceAndHash)}/project") { +"Open Web Interface" } +// a("../") { +"Workspace List" } // } - div("menuItem") { - a("../../${workspaceInstanceUrl(workspaceAndHash)}/ide/?waitForIndexer=true") { +"Open MPS" } - } - div("menuItem") { - a("../../${workspaceInstanceUrl(workspaceAndHash)}/generator/?waitForIndexer=true") { +"Generator" } - } - div("menuItem") { - val resource = WorkspacesPermissionSchema.workspaces.workspace(workspaceId()).resource - a("../permissions/resources/${resource.fullId.encodeURLPathPart()}/") { - +"Permissions" - } - } - workspace.gitRepositories.forEachIndexed { index, gitRepository -> - div("menuItem") { - a("git/$index/") { - val suffix = if (gitRepository.name.isNullOrEmpty()) "" else " (${gitRepository.name})" - text("Git History$suffix") - } - } - } - workspace.uploadIds().associateWith { findGitRepo(manager.getUploadFolder(it)) } - .filter { it.value != null }.forEach { upload -> - div("menuItem") { - a("git/u${upload.key}/") { - text("Git History") - } - } - } - } - br() - div { - style = "display: flex" - div { - h1 { +"Edit Workspace" } - form { - val configYaml = workspace.maskCredentials().toYaml() - action = "./update" - method = FormMethod.post - textArea { - name = "content" - style = "width: 800px; height: 500px; border-radius: 4px; padding: 12px;" - text(configYaml) - } - if (canWrite) { - br() - input(classes = "btn") { - type = InputType.submit - value = "Save Changes" - } - } - } - } - div { - style = "display: inline-block; margin-top: 15px; padding: 0px 12px;" - h2 { - style = "margin-bottom: 10px;" - +"Explanation" - } - ul { - style = "margin-top: 0;" - li { - b { +"name" } - +": Is just shown to the user in the workspace list." - } - li { - b { +"mpsVersion" } - +": MPS major version. Supported values: 2020.3, 2021.1, 2021.2, 2021.3, 2022.2, 2022.3, 2023.2, 2023.3, 2024.1" - } - li { - b { +"modelRepositories" } - +": Currently not used. A separate repository on the model server is created for each workspace." - +" The ID of the repository for this workspace is " - i { +"workspace_${workspace.id}" } - +"." - } - li { - b { +"gitRepositories" } - +": Git repository containing an MPS project. No build script is required." - +" Modelix will build all languages including their dependencies after cloning the repository." - +" If this repository is not public, credentials can be specified." - +" Alternatively, a project can be uploaded as a .zip file. (see below)" - ul { - li { - b { +"url" } - +": Address of the Git repository." - } - li { - b { +"name" } - +": Currently not used." - } - li { - b { +"branch" } - +": If no commitHash is specified, the latest commit from this branch is used." - } - li { - b { +"commitHash" } - +": A Git commit hash can be specified to ensure that always the same version is used." - } - li { - b { +"paths" } - +": If this repository contains additional modules that you don't want to use in Modelix," - +" you can specify a list of folders that you want to include." - } - li { - b { +"credentials" } - +": Credentials for password-based or token-based authentication. The credentials are encrypted before they are stored." - ul { - li { - b { +"user" } - +": The Git user. The value " - code { - +MASKED_CREDENTIAL_VALUE - } - +" indicates an already saved user. Replace the value to set a new user." - } - li { - b { +"password" } - +": The Git password or token. The value " - code { - +MASKED_CREDENTIAL_VALUE - } - +" indicates an already saved password. Replace the value to set a new password." - } - } - } - } - } - li { - b { +"mavenRepositories" } - +": Some artifacts are bundled with Modelix." - +" If you additional ones, here you can specify maven repositories that contain them." - ul { - li { - b { +"url" } - +": You probably want to use this one: " - i { +"https://artifacts.itemis.cloud/repository/maven-mps/" } - } - } - } - li { - b { +"mavenDependencies" } - +": Maven coordinates to a .zip file containing MPS modules/plugins." - +" Example: " - i { +"de.itemis.mps:extensions:2020.3.2179.1ee9c94:zip" } - } - li { - b { +"uploads" } - +": There is a special section for managing uploads. Directly editing this list is not required." - } - li { - b { +"ignoredModules" } - +": A list of MPS module IDs that should be excluding from generation." - +" Also missing dependencies that should be ignored can be listed here." - +" This section is usually used when the generation fails and editing the project is not possible." - } - li { - b { +"modelSyncEnabled" } - +": Synchronization with the model-server for real-time collaboration" - } - } - } - } - br() - div { - style = "padding: 3px;" - b { +"Uploads:" } - val allUploads = manager.getExistingUploads().associateBy { it.name } - val uploadContent: (Map.Entry) -> String = { uploads -> - val fileNames: List = (uploads.value?.listFiles()?.toList() ?: listOf()) - fileNames.joinToString(", ") { it.name } - } - table { - for (upload in allUploads.toSortedMap()) { - val uploadResource = WorkspacesPermissionSchema.workspaces.uploads.upload(upload.key) - tr { - td { +upload.key } - td { +uploadContent(upload) } - td { - if (canWrite) { - if (workspace.uploads.contains(upload.key)) { - form { - action = "./remove-upload" - method = FormMethod.post - input { - type = InputType.hidden - name = "uploadId" - value = upload.key - } - input { - type = InputType.submit - value = "Remove" - } - } - } else { - form { - action = "./use-upload" - method = FormMethod.post - input { - type = InputType.hidden - name = "uploadId" - value = upload.key - } - input { - type = InputType.submit - value = "Add" - } - } - } - } - } - td { - if (call.hasPermission(uploadResource.delete)) { - form { - action = "./delete-upload" - method = FormMethod.post - hiddenInput { - name = "uploadId" - value = upload.key - } - submitInput(classes = "btn") { - style = "background-color: red" - value = "Delete" - } - } - } - } - } - } - } - if (canWrite) { - br() - br() - b { +"Upload new file or directory (max $maxBodySize MiB):" } - form { - action = "./upload" - method = FormMethod.post - encType = FormEncType.multipartFormData - div { - span { - style = "display: inline-block; width: 140px;" - +"Choose File(s): " - } - input { - type = InputType.file - name = "file" - multiple = true - } - } - div { - span { - style = "display: inline-block; width: 147px;" - +"Choose Directory: " - } - input { - type = InputType.file - name = "folder" - attributes["webkitdirectory"] = "true" - attributes["mozdirectory"] = "true" - } - } - div { - input(classes = "btn") { - type = InputType.submit - value = "Upload" - } - } - } - } - } - } - } - } - - post("update") { - val id = workspaceId() - val workspaceAndHash = manager.getWorkspaceForId(id) - if (workspaceAndHash == null) { - call.respond(HttpStatusCode.NotFound, "Workspace $id not found") - return@post - } - call.checkPermission(WorkspacesPermissionSchema.workspaces.workspace(id).config.write) - val yamlText = call.receiveParameters()["content"] - if (yamlText == null) { - call.respond(HttpStatusCode.BadRequest, "Content missing") - return@post - } - val uncheckedWorkspaceConfig = try { - Yaml.default.decodeFromString(yamlText) - } catch (e: Exception) { - call.respond(HttpStatusCode.BadRequest, e.message ?: "Parse error") - return@post - } - val newWorkspaceConfig = sanitizeReceivedWorkspaceConfig(uncheckedWorkspaceConfig, workspaceAndHash.workspace) - manager.update(newWorkspaceConfig) - call.respondRedirect("./edit") - } - - post("add-maven-dependency") { - val id = workspaceId() - call.checkPermission(WorkspacesPermissionSchema.workspaces.workspace(id).config.write) - val workspaceAndHash = manager.getWorkspaceForId(id) - if (workspaceAndHash == null) { - call.respond(HttpStatusCode.NotFound, "Workspace $id not found") - return@post - } - val workspace = workspaceAndHash.workspace - val coordinates = call.receiveParameters()["coordinates"] - if (coordinates.isNullOrEmpty()) { - call.respond(HttpStatusCode.BadRequest, "coordinates missing") - } else { - manager.update(workspace.copy(mavenDependencies = workspace.mavenDependencies + coordinates)) - call.respondRedirect("./edit") - } - } - - post("upload") { - val workspaceId = workspaceId() - call.checkPermission(WorkspacesPermissionSchema.workspaces.uploads.add) - call.checkPermission(WorkspacesPermissionSchema.workspaces.workspace(workspaceId).config.write) - val workspace = manager.getWorkspaceForId(workspaceId)?.workspace - if (workspace == null) { - call.respondText("Workspace $workspaceId not found", ContentType.Text.Plain, HttpStatusCode.NotFound) - return@post - } - - val outputFolder = manager.newUploadFolder() - - call.receiveMultipart().forEachPart { part -> - if (part is PartData.FileItem) { - val name = part.originalFileName - if (!name.isNullOrEmpty()) { - val outputFile = File(outputFolder, name) - part.streamProvider().use { - FileUtils.copyToFile(it, outputFile) - } - if (outputFile.extension.lowercase() == "zip") { - ZipUtil.explode(outputFile) - } - } - } - part.dispose() - } - - manager.update(workspace.copy(uploads = workspace.uploads + outputFolder.name)) - - call.respondRedirect("./edit") - } - - post("use-upload") { - val workspaceId = workspaceId() - call.checkPermission(WorkspacesPermissionSchema.workspaces.workspace(workspaceId).config.write) - val uploadId = call.receiveParameters()["uploadId"]!! - call.checkPermission(WorkspacesPermissionSchema.workspaces.uploads.upload(uploadId).read) - val workspace = manager.getWorkspaceForId(workspaceId)?.workspace!! - manager.update(workspace.copy(uploads = workspace.uploads + uploadId)) - call.respondRedirect("./edit") - } - - post("remove-upload") { - val workspaceId = workspaceId() - call.checkPermission(WorkspacesPermissionSchema.workspaces.workspace(workspaceId).config.write) - val uploadId = call.receiveParameters()["uploadId"]!! - val workspace = manager.getWorkspaceForId(workspaceId)?.workspace!! - manager.update(workspace.copy(uploads = workspace.uploads - uploadId)) - call.respondRedirect("./edit") - } - - post("delete-upload") { - val uploadId = UploadId(call.receiveParameters()["uploadId"]!!) - call.checkPermission(WorkspacesPermissionSchema.workspaces.uploads.upload(uploadId.id).delete) - val allWorkspaces = manager.getWorkspaceIds().mapNotNull { manager.getWorkspaceForId(it)?.workspace } - for (workspace in allWorkspaces.filter { it.uploadIds().contains(uploadId) }) { - manager.update(workspace.copy(uploads = workspace.uploads - uploadId.id)) - } - manager.deleteUpload(uploadId) - call.respondRedirect("./edit") - } - - get("model-history") { - // ensure the user has the necessary permission on the model-server - val userId = call.getUserName() - if (userId != null) { - val repositoryResource = ModelServerPermissionSchema.repository("workspace_${workspaceId()}") - val permissionId = when { - call.hasPermission(WorkspacesPermissionSchema.workspaces.workspace(workspaceId()).modelRepository.write) -> repositoryResource.write - call.hasPermission(WorkspacesPermissionSchema.workspaces.workspace(workspaceId()).modelRepository.read) -> repositoryResource.read - else -> null - } - if (permissionId != null) { - HttpClient(CIO).submitForm( - url = System.getenv("model_server_url") + "permissions/grant", - formParameters = parameters { - append("userId", userId) - append("permissionId", permissionId.fullId) - }, - ) { - expectSuccess = true - bearerAuth( - manager.jwtUtil.createAccessToken( - "workspace-manager@modelix.org", - listOf( - PermissionSchemaBase.cluster.admin.fullId, - ), - ), - ) - } - } - } - - call.respondRedirect("../../model/history/workspace_${workspaceId()}/master/") - } - } - - route(Regex("(?" + HashUtil.HASH_PATTERN.pattern + ")")) { - this.intercept(ApplicationCallPipeline.Call) { - val workspaceHash = WorkspaceHash(call.parameters["workspaceHash"]!!) - val workspace = manager.getWorkspaceForHash(workspaceHash)?.workspace - if (workspace != null) { - call.checkPermission(WorkspacesPermissionSchema.workspaces.workspace(workspace.id).config.read) - } - } - - get { - val workspaceHash = WorkspaceHash(call.parameters["workspaceHash"]!!) - val workspace = manager.getWorkspaceForHash(workspaceHash)?.workspace - if (workspace == null) { - call.respond(HttpStatusCode.NotFound, "workspace $workspaceHash not found") - return@get - } - val decryptCredentials = call.request.queryParameters["decryptCredentials"] == "true" - val decrypted = if (decryptCredentials) { - // TODO check permission to read decrypted credentials - credentialsEncryption.copyWithDecryptedCredentials(workspace) - } else { - workspace - } - call.respond(decrypted) - } - - get("buildlog") { - val workspaceHash = WorkspaceHash(call.parameters["workspaceHash"]!!) - val job = manager.buildWorkspaceDownloadFileAsync(workspaceHash) - val respondStatus: suspend (String, DIV.() -> Unit) -> Unit = { refresh, text -> - call.respondHtmlSafe { - head { - meta { - httpEquiv = "refresh" - content = refresh - } - } - body { - div { - text() - } - br { } - br { } - pre { - +job.getLog() - } - } - } - } - when (job.status) { - WorkspaceBuildStatus.New, WorkspaceBuildStatus.Queued -> respondStatus("3") { +"Workspace is queued for building ..." } - WorkspaceBuildStatus.Running -> respondStatus("10") { +"Downloading and building modules ..." } - WorkspaceBuildStatus.FailedBuild -> respondStatus("10") { +"Failed to build the workspace ..." } - WorkspaceBuildStatus.FailedZip -> respondStatus("30") { +"Failed to ZIP the workspace ..." } - WorkspaceBuildStatus.AllSuccessful, WorkspaceBuildStatus.ZipSuccessful -> { - respondStatus("30") { - if (job.status == WorkspaceBuildStatus.ZipSuccessful) { - +"Failed to build the workspace. " - } - +"Workspace image is ready" - } - } - } - } - - get("status") { - val workspaceHash = WorkspaceHash(call.parameters["workspaceHash"]!!) - val job = manager.buildWorkspaceDownloadFileAsync(workspaceHash) - call.respondText(job.status.toString(), ContentType.Text.Plain, HttpStatusCode.OK) - } - - get("output") { - val workspaceHash = WorkspaceHash(call.parameters["workspaceHash"]!!) - val job = manager.buildWorkspaceDownloadFileAsync(workspaceHash) - call.respondText(job.getLog(), ContentType.Text.Plain, HttpStatusCode.OK) - } - - get("context.tar.gz") { - val workspaceHash = WorkspaceHash(call.parameters["workspaceHash"]!!) - val workspace = manager.getWorkspaceForHash(workspaceHash)!! - val httpProxy: String? = System.getenv("MODELIX_HTTP_PROXY")?.takeIf { it.isNotEmpty() } - - call.checkPermission(WorkspacesPermissionSchema.workspaces.workspace(workspace.id).config.readCredentials) - - // more extensive check to ensure only the build job has access - if (!run { - val token = call.principal()?.payload ?: return@run false - if (!manager.jwtUtil.isAccessToken(token)) return@run false - if (call.getUnverifiedJwt()?.keyId != manager.jwtUtil.getPrivateKey()?.keyID) return@run false - true - } - ) { - throw NoPermissionException("Only permitted to the workspace-job") - } - - val mpsVersion = workspace.userDefinedOrDefaultMpsVersion - val jwtToken = manager.workspaceJobTokenGenerator(workspace.workspace) - - val containerMemoryBytes = Quantity.fromString(workspace.memoryLimit).number - var maxHeapSizeBytes = heapSizeFromContainerLimit(containerMemoryBytes) - val maxHeapSizeMega = (maxHeapSizeBytes / 1024.toBigDecimal() / 1024.toBigDecimal()).toBigInteger() - - call.respondTarGz { tar -> - @Suppress("ktlint") - tar.putFile("Dockerfile", """ - FROM ${HELM_PREFIX}docker-registry:5000/modelix/workspace-client-baseimage:${System.getenv("MPS_BASEIMAGE_VERSION")}-mps$mpsVersion - - ENV modelix_workspace_id=${workspace.id} - ENV modelix_workspace_hash=${workspace.hash()} - ENV modelix_workspace_server=http://${HELM_PREFIX}workspace-manager:28104/ - ENV INITIAL_JWT_TOKEN=$jwtToken - - RUN /etc/cont-init.d/10-init-users.sh && /etc/cont-init.d/99-set-user-home.sh - - RUN sed -i.bak '/-Xmx/d' /mps/bin/mps64.vmoptions \ - && sed -i.bak '/-XX:MaxRAMPercentage/d' /mps/bin/mps64.vmoptions \ - && echo "-Xmx${maxHeapSizeMega}m" >> /mps/bin/mps64.vmoptions \ - && cat /mps/bin/mps64.vmoptions > /mps/bin/mps.vmoptions - - COPY clone.sh /clone.sh - RUN chmod +x /clone.sh && chown app:app /clone.sh - USER app - RUN /clone.sh - USER root - RUN rm /clone.sh - USER app - - RUN rm -rf /mps-projects/default-mps-project - - RUN mkdir /config/home/job \ - && cd /config/home/job \ - && wget -q "http://${HELM_PREFIX}workspace-manager:28104/static/workspace-job.tar" \ - && tar -xf workspace-job.tar \ - && cd /mps-projects/workspace-${workspace.id} \ - && /config/home/job/workspace-job/bin/workspace-job \ - && rm -rf /config/home/job - - RUN /update-recent-projects.sh \ - && echo "${WorkspaceProgressItems().build.runIndexer.logMessageStart}" \ - && ( /run-indexer.sh || echo "${WorkspaceProgressItems().build.runIndexer.logMessageFailed}" ) \ - && echo "${WorkspaceProgressItems().build.runIndexer.logMessageDone}" - - USER root - """.trimIndent().toByteArray()) - - // Separate file for git command because they may contain the credentials - // and the commands shouldn't appear in the log - @Suppress("ktlint") - tar.putFile("clone.sh", """ - #!/bin/sh - - echo "### START build-gitClone ###" - - ${if (httpProxy == null) "" else """ - export http_proxy="$httpProxy" - export https_proxy="$httpProxy" - export HTTP_PROXY="$httpProxy" - export HTTPS_PROXY="$httpProxy" - """} - - if ${ - workspace.gitRepositories.flatMapIndexed { index, git -> - val dir = "/mps-projects/workspace-${workspace.id}/git/$index/" - - // https://learn.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=Linux#use-a-pat - val authHeader = git.credentials?.let { - credentialsEncryption.decrypt(it) - }?.let { - """ -c http.extraheader="Authorization: Basic ${(it.user + ":" + it.password).encodeBase64()}"""" - } ?: "" - - listOf( - "mkdir -p $dir", - "cd $dir", - "git$authHeader clone ${git.url}", - "cd *", - "git checkout " + (git.commitHash ?: ("origin/" + git.branch)), - ) - }.joinToString(" && ") - } - then - echo "### DONE build-gitClone ###" - else - echo "### FAILED build-gitClone ###" - fi - """.lines().joinToString("\n") { it.trim() }.toByteArray()) - } - } - } - - post("/remove-workspace") { - val workspaceId = call.receiveParameters()["workspaceId"]!! - call.checkPermission(WorkspacesPermissionSchema.workspaces.workspace(workspaceId).delete) - manager.removeWorkspace(workspaceId) - call.respondRedirect(".") - } - - route("rest") { - get("access-control-data") { - call.checkPermission(PermissionSchemaBase.permissionData.read) - call.respondText( - Json.encodeToString(manager.accessControlPersistence.read()), - ContentType.Application.Json, - ) - } - route("workspaces") { - get { - val workspaces = manager.getAllWorkspaces().filter { - call.hasPermission(WorkspacesPermissionSchema.workspaces.workspace(it.id).list) - } - // TODO MODELIX-1057 Credentials can be exposed here. - // The "rest" endpoints are to be removed after merging workspace- and instance-manager - call.respondText(Json.encodeToString(workspaces), ContentType.Application.Json) - } - get("ids") { - val ids = manager.getWorkspaceIds().filter { - call.hasPermission(WorkspacesPermissionSchema.workspaces.workspace(it).list) - } - call.respondText(ids.joinToString("\n")) - } - route("by-id") { - route("{workspaceId}") { - get("workspace.json") { - val workspaceId = call.parameters["workspaceId"]!! - call.checkPermission(WorkspacesPermissionSchema.workspaces.workspace(workspaceId).config.read) - val workspace = manager.getWorkspaceForId(workspaceId)?.workspace - if (workspace == null) { - call.respond(HttpStatusCode.NotFound, "Workspace not found: $workspaceId") - return@get - } - // TODO MODELIX-1057 Credentials can be exposed here. - // The "rest" endpoints are to be removed after merging workspace- and instance-manager - call.respondText(Json.encodeToString(workspace), ContentType.Application.Json) - } - } - } - route("by-hash") { - route("{workspaceHash}") { - get("workspace.json") { - val workspaceHash = WorkspaceHash(call.parameters["workspaceHash"]!!) - val workspace = manager.getWorkspaceForHash(workspaceHash)?.workspace - if (workspace == null) { - call.respond(HttpStatusCode.NotFound, "Workspace not found: $workspaceHash") - return@get - } - call.checkPermission(WorkspacesPermissionSchema.workspaces.workspace(workspace.id).config.read) - // TODO MODELIX-1057 Credentials can be exposed here. - // The "rest" endpoints are to be removed after merging workspace- and instance-manager - call.respondText(Json.encodeToString(workspace), ContentType.Application.Json) - } - } - } - } - } - - route("api") { - route("workspaces") { - route("{workspaceId}") { - route("git-import") { - post("trigger") { - val workspaceId = call.parameters["workspaceId"]!! - call.checkPermission(WorkspacesPermissionSchema.workspaces.workspace(workspaceId).modelRepository.write) - val executionIds = manager.enqueueGitImport(workspaceId) - call.respondHtml { - body { - div { - +"Import job added" - } - br {} - for (executionId in executionIds) { - div { - a(href = "../../../../../ui/executions/modelix/git_import/$executionId/gantt") { - +"Show Progress ($executionId)" - } - } - } - } - } - } - } - } - } - } - } +// div("menuItem") { +// a("../${workspaceAndHash.hash().hash}/buildlog") { +"Build Log" } +// } +// // Shadow models based UI was removed +// // div("menuItem") { +// // a("../../${workspaceInstanceUrl(workspaceAndHash)}/project") { +"Open Web Interface" } +// // } +// div("menuItem") { +// a("../../${workspaceInstanceUrl(workspaceAndHash)}/ide/?waitForIndexer=true") { +"Open MPS" } +// } +// div("menuItem") { +// a("../../${workspaceInstanceUrl(workspaceAndHash)}/generator/?waitForIndexer=true") { +"Generator" } +// } +// div("menuItem") { +// val resource = WorkspacesPermissionSchema.workspaces.workspace(workspaceId()).resource +// a("../permissions/resources/${resource.fullId.encodeURLPathPart()}/") { +// +"Permissions" +// } +// } +// workspace.gitRepositories.forEachIndexed { index, gitRepository -> +// div("menuItem") { +// a("git/$index/") { +// val suffix = if (gitRepository.name.isNullOrEmpty()) "" else " (${gitRepository.name})" +// text("Git History$suffix") +// } +// } +// } +// workspace.uploadIds().associateWith { findGitRepo(manager.getUploadFolder(it)) } +// .filter { it.value != null }.forEach { upload -> +// div("menuItem") { +// a("git/u${upload.key}/") { +// text("Git History") +// } +// } +// } +// } +// br() +// div { +// style = "display: flex" +// div { +// h1 { +"Edit Workspace" } +// form { +// val configYaml = workspace.maskCredentials().toYaml() +// action = "./update" +// method = FormMethod.post +// textArea { +// name = "content" +// style = "width: 800px; height: 500px; border-radius: 4px; padding: 12px;" +// text(configYaml) +// } +// if (canWrite) { +// br() +// input(classes = "btn") { +// type = InputType.submit +// value = "Save Changes" +// } +// } +// } +// } +// div { +// style = "display: inline-block; margin-top: 15px; padding: 0px 12px;" +// h2 { +// style = "margin-bottom: 10px;" +// +"Explanation" +// } +// ul { +// style = "margin-top: 0;" +// li { +// b { +"name" } +// +": Is just shown to the user in the workspace list." +// } +// li { +// b { +"mpsVersion" } +// +": MPS major version. Supported values: 2020.3, 2021.1, 2021.2, 2021.3, 2022.2, 2022.3, 2023.2, 2023.3, 2024.1" +// } +// li { +// b { +"modelRepositories" } +// +": Currently not used. A separate repository on the model server is created for each workspace." +// +" The ID of the repository for this workspace is " +// i { +"workspace_${workspace.id}" } +// +"." +// } +// li { +// b { +"gitRepositories" } +// +": Git repository containing an MPS project. No build script is required." +// +" Modelix will build all languages including their dependencies after cloning the repository." +// +" If this repository is not public, credentials can be specified." +// +" Alternatively, a project can be uploaded as a .zip file. (see below)" +// ul { +// li { +// b { +"url" } +// +": Address of the Git repository." +// } +// li { +// b { +"name" } +// +": Currently not used." +// } +// li { +// b { +"branch" } +// +": If no commitHash is specified, the latest commit from this branch is used." +// } +// li { +// b { +"commitHash" } +// +": A Git commit hash can be specified to ensure that always the same version is used." +// } +// li { +// b { +"paths" } +// +": If this repository contains additional modules that you don't want to use in Modelix," +// +" you can specify a list of folders that you want to include." +// } +// li { +// b { +"credentials" } +// +": Credentials for password-based or token-based authentication. The credentials are encrypted before they are stored." +// ul { +// li { +// b { +"user" } +// +": The Git user. The value " +// code { +// +MASKED_CREDENTIAL_VALUE +// } +// +" indicates an already saved user. Replace the value to set a new user." +// } +// li { +// b { +"password" } +// +": The Git password or token. The value " +// code { +// +MASKED_CREDENTIAL_VALUE +// } +// +" indicates an already saved password. Replace the value to set a new password." +// } +// } +// } +// } +// } +// li { +// b { +"mavenRepositories" } +// +": Some artifacts are bundled with Modelix." +// +" If you additional ones, here you can specify maven repositories that contain them." +// ul { +// li { +// b { +"url" } +// +": You probably want to use this one: " +// i { +"https://artifacts.itemis.cloud/repository/maven-mps/" } +// } +// } +// } +// li { +// b { +"mavenDependencies" } +// +": Maven coordinates to a .zip file containing MPS modules/plugins." +// +" Example: " +// i { +"de.itemis.mps:extensions:2020.3.2179.1ee9c94:zip" } +// } +// li { +// b { +"uploads" } +// +": There is a special section for managing uploads. Directly editing this list is not required." +// } +// li { +// b { +"ignoredModules" } +// +": A list of MPS module IDs that should be excluding from generation." +// +" Also missing dependencies that should be ignored can be listed here." +// +" This section is usually used when the generation fails and editing the project is not possible." +// } +// li { +// b { +"modelSyncEnabled" } +// +": Synchronization with the model-server for real-time collaboration" +// } +// } +// } +// } +// br() +// div { +// style = "padding: 3px;" +// b { +"Uploads:" } +// val allUploads = manager.getExistingUploads().associateBy { it.name } +// val uploadContent: (Map.Entry) -> String = { uploads -> +// val fileNames: List = (uploads.value?.listFiles()?.toList() ?: listOf()) +// fileNames.joinToString(", ") { it.name } +// } +// table { +// for (upload in allUploads.toSortedMap()) { +// val uploadResource = WorkspacesPermissionSchema.workspaces.uploads.upload(upload.key) +// tr { +// td { +upload.key } +// td { +uploadContent(upload) } +// td { +// if (canWrite) { +// if (workspace.uploads.contains(upload.key)) { +// form { +// action = "./remove-upload" +// method = FormMethod.post +// input { +// type = InputType.hidden +// name = "uploadId" +// value = upload.key +// } +// input { +// type = InputType.submit +// value = "Remove" +// } +// } +// } else { +// form { +// action = "./use-upload" +// method = FormMethod.post +// input { +// type = InputType.hidden +// name = "uploadId" +// value = upload.key +// } +// input { +// type = InputType.submit +// value = "Add" +// } +// } +// } +// } +// } +// td { +// if (call.hasPermission(uploadResource.delete)) { +// form { +// action = "./delete-upload" +// method = FormMethod.post +// hiddenInput { +// name = "uploadId" +// value = upload.key +// } +// submitInput(classes = "btn") { +// style = "background-color: red" +// value = "Delete" +// } +// } +// } +// } +// } +// } +// } +// if (canWrite) { +// br() +// br() +// b { +"Upload new file or directory (max $maxBodySize MiB):" } +// form { +// action = "./upload" +// method = FormMethod.post +// encType = FormEncType.multipartFormData +// div { +// span { +// style = "display: inline-block; width: 140px;" +// +"Choose File(s): " +// } +// input { +// type = InputType.file +// name = "file" +// multiple = true +// } +// } +// div { +// span { +// style = "display: inline-block; width: 147px;" +// +"Choose Directory: " +// } +// input { +// type = InputType.file +// name = "folder" +// attributes["webkitdirectory"] = "true" +// attributes["mozdirectory"] = "true" +// } +// } +// div { +// input(classes = "btn") { +// type = InputType.submit +// value = "Upload" +// } +// } +// } +// } +// } +// } +// } +// } +// +// post("update") { +// val id = workspaceId() +// val workspaceAndHash = manager.getWorkspaceForId(id) +// if (workspaceAndHash == null) { +// call.respond(HttpStatusCode.NotFound, "Workspace $id not found") +// return@post +// } +// call.checkPermission(WorkspacesPermissionSchema.workspaces.workspace(id).config.write) +// val yamlText = call.receiveParameters()["content"] +// if (yamlText == null) { +// call.respond(HttpStatusCode.BadRequest, "Content missing") +// return@post +// } +// val uncheckedWorkspaceConfig = try { +// Yaml.default.decodeFromString(yamlText) +// } catch (e: Exception) { +// call.respond(HttpStatusCode.BadRequest, e.message ?: "Parse error") +// return@post +// } +// val newWorkspaceConfig = sanitizeReceivedWorkspaceConfig(uncheckedWorkspaceConfig, workspaceAndHash.workspace) +// manager.update(newWorkspaceConfig) +// call.respondRedirect("./edit") +// } +// +// post("add-maven-dependency") { +// val id = workspaceId() +// call.checkPermission(WorkspacesPermissionSchema.workspaces.workspace(id).config.write) +// val workspaceAndHash = manager.getWorkspaceForId(id) +// if (workspaceAndHash == null) { +// call.respond(HttpStatusCode.NotFound, "Workspace $id not found") +// return@post +// } +// val workspace = workspaceAndHash.workspace +// val coordinates = call.receiveParameters()["coordinates"] +// if (coordinates.isNullOrEmpty()) { +// call.respond(HttpStatusCode.BadRequest, "coordinates missing") +// } else { +// manager.update(workspace.copy(mavenDependencies = workspace.mavenDependencies + coordinates)) +// call.respondRedirect("./edit") +// } +// } +// +// post("upload") { +// val workspaceId = workspaceId() +// call.checkPermission(WorkspacesPermissionSchema.workspaces.uploads.add) +// call.checkPermission(WorkspacesPermissionSchema.workspaces.workspace(workspaceId).config.write) +// val workspace = manager.getWorkspaceForId(workspaceId)?.workspace +// if (workspace == null) { +// call.respondText("Workspace $workspaceId not found", ContentType.Text.Plain, HttpStatusCode.NotFound) +// return@post +// } +// +// val outputFolder = manager.newUploadFolder() +// +// call.receiveMultipart().forEachPart { part -> +// if (part is PartData.FileItem) { +// val name = part.originalFileName +// if (!name.isNullOrEmpty()) { +// val outputFile = File(outputFolder, name) +// part.streamProvider().use { +// FileUtils.copyToFile(it, outputFile) +// } +// if (outputFile.extension.lowercase() == "zip") { +// ZipUtil.explode(outputFile) +// } +// } +// } +// part.dispose() +// } +// +// manager.update(workspace.copy(uploads = workspace.uploads + outputFolder.name)) +// +// call.respondRedirect("./edit") +// } +// +// post("use-upload") { +// val workspaceId = workspaceId() +// call.checkPermission(WorkspacesPermissionSchema.workspaces.workspace(workspaceId).config.write) +// val uploadId = call.receiveParameters()["uploadId"]!! +// call.checkPermission(WorkspacesPermissionSchema.workspaces.uploads.upload(uploadId).read) +// val workspace = manager.getWorkspaceForId(workspaceId)?.workspace!! +// manager.update(workspace.copy(uploads = workspace.uploads + uploadId)) +// call.respondRedirect("./edit") +// } +// +// post("remove-upload") { +// val workspaceId = workspaceId() +// call.checkPermission(WorkspacesPermissionSchema.workspaces.workspace(workspaceId).config.write) +// val uploadId = call.receiveParameters()["uploadId"]!! +// val workspace = manager.getWorkspaceForId(workspaceId)?.workspace!! +// manager.update(workspace.copy(uploads = workspace.uploads - uploadId)) +// call.respondRedirect("./edit") +// } +// +// post("delete-upload") { +// val uploadId = UploadId(call.receiveParameters()["uploadId"]!!) +// call.checkPermission(WorkspacesPermissionSchema.workspaces.uploads.upload(uploadId.id).delete) +// val allWorkspaces = manager.getWorkspaceIds().mapNotNull { manager.getWorkspaceForId(it)?.workspace } +// for (workspace in allWorkspaces.filter { it.uploadIds().contains(uploadId) }) { +// manager.update(workspace.copy(uploads = workspace.uploads - uploadId.id)) +// } +// manager.deleteUpload(uploadId) +// call.respondRedirect("./edit") +// } +// +// get("model-history") { +// // ensure the user has the necessary permission on the model-server +// val userId = call.getUserName() +// if (userId != null) { +// val repositoryResource = ModelServerPermissionSchema.repository("workspace_${workspaceId()}") +// val permissionId = when { +// call.hasPermission(WorkspacesPermissionSchema.workspaces.workspace(workspaceId()).modelRepository.write) -> repositoryResource.write +// call.hasPermission(WorkspacesPermissionSchema.workspaces.workspace(workspaceId()).modelRepository.read) -> repositoryResource.read +// else -> null +// } +// if (permissionId != null) { +// HttpClient(CIO).submitForm( +// url = System.getenv("model_server_url") + "permissions/grant", +// formParameters = parameters { +// append("userId", userId) +// append("permissionId", permissionId.fullId) +// }, +// ) { +// expectSuccess = true +// bearerAuth( +// manager.jwtUtil.createAccessToken( +// "workspace-manager@modelix.org", +// listOf( +// PermissionSchemaBase.cluster.admin.fullId, +// ), +// ), +// ) +// } +// } +// } +// +// call.respondRedirect("../../model/history/workspace_${workspaceId()}/master/") +// } +// } +// +// route(Regex("(?" + HashUtil.HASH_PATTERN.pattern + ")")) { +// this.intercept(ApplicationCallPipeline.Call) { +// val workspaceHash = WorkspaceHash(call.parameters["workspaceHash"]!!) +// val workspace = manager.getWorkspaceForHash(workspaceHash)?.workspace +// if (workspace != null) { +// call.checkPermission(WorkspacesPermissionSchema.workspaces.workspace(workspace.id).config.read) +// } +// } +// +// get { +// val workspaceHash = WorkspaceHash(call.parameters["workspaceHash"]!!) +// val workspace = manager.getWorkspaceForHash(workspaceHash)?.workspace +// if (workspace == null) { +// call.respond(HttpStatusCode.NotFound, "workspace $workspaceHash not found") +// return@get +// } +// val decryptCredentials = call.request.queryParameters["decryptCredentials"] == "true" +// val decrypted = if (decryptCredentials) { +// // TODO check permission to read decrypted credentials +// credentialsEncryption.copyWithDecryptedCredentials(workspace) +// } else { +// workspace +// } +// call.respond(decrypted) +// } +// +// get("buildlog") { +// val workspaceHash = WorkspaceHash(call.parameters["workspaceHash"]!!) +// val job = manager.buildWorkspaceDownloadFileAsync(workspaceHash) +// val respondStatus: suspend (String, DIV.() -> Unit) -> Unit = { refresh, text -> +// call.respondHtmlSafe { +// head { +// meta { +// httpEquiv = "refresh" +// content = refresh +// } +// } +// body { +// div { +// text() +// } +// br { } +// br { } +// pre { +// +job.getLog() +// } +// } +// } +// } +// when (job.status) { +// WorkspaceBuildStatus.New, WorkspaceBuildStatus.Queued -> respondStatus("3") { +"Workspace is queued for building ..." } +// WorkspaceBuildStatus.Running -> respondStatus("10") { +"Downloading and building modules ..." } +// WorkspaceBuildStatus.FailedBuild -> respondStatus("10") { +"Failed to build the workspace ..." } +// WorkspaceBuildStatus.FailedZip -> respondStatus("30") { +"Failed to ZIP the workspace ..." } +// WorkspaceBuildStatus.AllSuccessful, WorkspaceBuildStatus.ZipSuccessful -> { +// respondStatus("30") { +// if (job.status == WorkspaceBuildStatus.ZipSuccessful) { +// +"Failed to build the workspace. " +// } +// +"Workspace image is ready" +// } +// } +// } +// } +// +// get("status") { +// val workspaceHash = WorkspaceHash(call.parameters["workspaceHash"]!!) +// val job = manager.buildWorkspaceDownloadFileAsync(workspaceHash) +// call.respondText(job.status.toString(), ContentType.Text.Plain, HttpStatusCode.OK) +// } +// +// get("output") { +// val workspaceHash = WorkspaceHash(call.parameters["workspaceHash"]!!) +// val job = manager.buildWorkspaceDownloadFileAsync(workspaceHash) +// call.respondText(job.getLog(), ContentType.Text.Plain, HttpStatusCode.OK) +// } +// +// get("context.tar.gz") { +// val workspaceHash = WorkspaceHash(call.parameters["workspaceHash"]!!) +// val workspace = manager.getWorkspaceForHash(workspaceHash)!! +// val httpProxy: String? = System.getenv("MODELIX_HTTP_PROXY")?.takeIf { it.isNotEmpty() } +// +// call.checkPermission(WorkspacesPermissionSchema.workspaces.workspace(workspace.id).config.readCredentials) +// +// // more extensive check to ensure only the build job has access +// if (!run { +// val token = call.principal()?.payload ?: return@run false +// if (!manager.jwtUtil.isAccessToken(token)) return@run false +// if (call.getUnverifiedJwt()?.keyId != manager.jwtUtil.getPrivateKey()?.keyID) return@run false +// true +// } +// ) { +// throw NoPermissionException("Only permitted to the workspace-job") +// } +// +// val mpsVersion = workspace.userDefinedOrDefaultMpsVersion +// val jwtToken = manager.workspaceJobTokenGenerator(workspace.workspace) +// +// val containerMemoryBytes = Quantity.fromString(workspace.memoryLimit).number +// var maxHeapSizeBytes = heapSizeFromContainerLimit(containerMemoryBytes) +// val maxHeapSizeMega = (maxHeapSizeBytes / 1024.toBigDecimal() / 1024.toBigDecimal()).toBigInteger() +// +// call.respondTarGz { tar -> +// @Suppress("ktlint") +// tar.putFile("Dockerfile", """ +// FROM ${HELM_PREFIX}docker-registry:5000/modelix/workspace-client-baseimage:${System.getenv("MPS_BASEIMAGE_VERSION")}-mps$mpsVersion +// +// ENV modelix_workspace_id=${workspace.id} +// ENV modelix_workspace_hash=${workspace.hash()} +// ENV modelix_workspace_server=http://${HELM_PREFIX}workspace-manager:28104/ +// ENV INITIAL_JWT_TOKEN=$jwtToken +// +// RUN /etc/cont-init.d/10-init-users.sh && /etc/cont-init.d/99-set-user-home.sh +// +// RUN sed -i.bak '/-Xmx/d' /mps/bin/mps64.vmoptions \ +// && sed -i.bak '/-XX:MaxRAMPercentage/d' /mps/bin/mps64.vmoptions \ +// && echo "-Xmx${maxHeapSizeMega}m" >> /mps/bin/mps64.vmoptions \ +// && cat /mps/bin/mps64.vmoptions > /mps/bin/mps.vmoptions +// +// COPY clone.sh /clone.sh +// RUN chmod +x /clone.sh && chown app:app /clone.sh +// USER app +// RUN /clone.sh +// USER root +// RUN rm /clone.sh +// USER app +// +// RUN rm -rf /mps-projects/default-mps-project +// +// RUN mkdir /config/home/job \ +// && cd /config/home/job \ +// && wget -q "http://${HELM_PREFIX}workspace-manager:28104/static/workspace-job.tar" \ +// && tar -xf workspace-job.tar \ +// && cd /mps-projects/workspace-${workspace.id} \ +// && /config/home/job/workspace-job/bin/workspace-job \ +// && rm -rf /config/home/job +// +// RUN /update-recent-projects.sh \ +// && echo "${WorkspaceProgressItems().build.runIndexer.logMessageStart}" \ +// && ( /run-indexer.sh || echo "${WorkspaceProgressItems().build.runIndexer.logMessageFailed}" ) \ +// && echo "${WorkspaceProgressItems().build.runIndexer.logMessageDone}" +// +// USER root +// """.trimIndent().toByteArray()) +// +// // Separate file for git command because they may contain the credentials +// // and the commands shouldn't appear in the log +// @Suppress("ktlint") +// tar.putFile("clone.sh", """ +// #!/bin/sh +// +// echo "### START build-gitClone ###" +// +// ${if (httpProxy == null) "" else """ +// export http_proxy="$httpProxy" +// export https_proxy="$httpProxy" +// export HTTP_PROXY="$httpProxy" +// export HTTPS_PROXY="$httpProxy" +// """} +// +// if ${ +// workspace.gitRepositories.flatMapIndexed { index, git -> +// val dir = "/mps-projects/workspace-${workspace.id}/git/$index/" +// +// // https://learn.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=Linux#use-a-pat +// val authHeader = git.credentials?.let { +// credentialsEncryption.decrypt(it) +// }?.let { +// """ -c http.extraheader="Authorization: Basic ${(it.user + ":" + it.password).encodeBase64()}"""" +// } ?: "" +// +// listOf( +// "mkdir -p $dir", +// "cd $dir", +// "git$authHeader clone ${git.url}", +// "cd *", +// "git checkout " + (git.commitHash ?: ("origin/" + git.branch)), +// ) +// }.joinToString(" && ") +// } +// then +// echo "### DONE build-gitClone ###" +// else +// echo "### FAILED build-gitClone ###" +// fi +// """.lines().joinToString("\n") { it.trim() }.toByteArray()) +// } +// } +// } +// +// post("/remove-workspace") { +// val workspaceId = call.receiveParameters()["workspaceId"]!! +// call.checkPermission(WorkspacesPermissionSchema.workspaces.workspace(workspaceId).delete) +// manager.removeWorkspace(workspaceId) +// call.respondRedirect(".") +// } +// +// route("rest") { +// get("access-control-data") { +// call.checkPermission(PermissionSchemaBase.permissionData.read) +// call.respondText( +// Json.encodeToString(manager.accessControlPersistence.read()), +// ContentType.Application.Json, +// ) +// } +// route("workspaces") { +// get { +// val workspaces = manager.getAllWorkspaces().filter { +// call.hasPermission(WorkspacesPermissionSchema.workspaces.workspace(it.id).list) +// } +// // TODO MODELIX-1057 Credentials can be exposed here. +// // The "rest" endpoints are to be removed after merging workspace- and instance-manager +// call.respondText(Json.encodeToString(workspaces), ContentType.Application.Json) +// } +// get("ids") { +// val ids = manager.getWorkspaceIds().filter { +// call.hasPermission(WorkspacesPermissionSchema.workspaces.workspace(it).list) +// } +// call.respondText(ids.joinToString("\n")) +// } +// route("by-id") { +// route("{workspaceId}") { +// get("workspace.json") { +// val workspaceId = call.parameters["workspaceId"]!! +// call.checkPermission(WorkspacesPermissionSchema.workspaces.workspace(workspaceId).config.read) +// val workspace = manager.getWorkspaceForId(workspaceId)?.workspace +// if (workspace == null) { +// call.respond(HttpStatusCode.NotFound, "Workspace not found: $workspaceId") +// return@get +// } +// // TODO MODELIX-1057 Credentials can be exposed here. +// // The "rest" endpoints are to be removed after merging workspace- and instance-manager +// call.respondText(Json.encodeToString(workspace), ContentType.Application.Json) +// } +// } +// } +// route("by-hash") { +// route("{workspaceHash}") { +// get("workspace.json") { +// val workspaceHash = WorkspaceHash(call.parameters["workspaceHash"]!!) +// val workspace = manager.getWorkspaceForHash(workspaceHash)?.workspace +// if (workspace == null) { +// call.respond(HttpStatusCode.NotFound, "Workspace not found: $workspaceHash") +// return@get +// } +// call.checkPermission(WorkspacesPermissionSchema.workspaces.workspace(workspace.id).config.read) +// // TODO MODELIX-1057 Credentials can be exposed here. +// // The "rest" endpoints are to be removed after merging workspace- and instance-manager +// call.respondText(Json.encodeToString(workspace), ContentType.Application.Json) +// } +// } +// } +// } +// } +// +// route("api") { +// route("workspaces") { +// route("{workspaceId}") { +// route("git-import") { +// post("trigger") { +// val workspaceId = call.parameters["workspaceId"]!! +// call.checkPermission(WorkspacesPermissionSchema.workspaces.workspace(workspaceId).modelRepository.write) +// val executionIds = manager.enqueueGitImport(workspaceId) +// call.respondHtml { +// body { +// div { +// +"Import job added" +// } +// br {} +// for (executionId in executionIds) { +// div { +// a(href = "../../../../../ui/executions/modelix/git_import/$executionId/gantt") { +// +"Show Progress ($executionId)" +// } +// } +// } +// } +// } +// } +// } +// } +// } +// } +// } get("baseimage/{mpsVersion}/context.tar.gz") { val mpsVersion = call.parameters["mpsVersion"]!! @@ -1235,11 +1243,11 @@ fun Application.workspaceManagerModule() { install(CORS) { anyHost() + anyMethod() + allowOrigins { true } allowHeader(HttpHeaders.ContentType) - allowMethod(HttpMethod.Options) - allowMethod(HttpMethod.Get) - allowMethod(HttpMethod.Put) - allowMethod(HttpMethod.Post) + allowHeader(HttpHeaders.AccessControlAllowOrigin) + allowHeader(HttpHeaders.Authorization) } } @@ -1291,7 +1299,7 @@ suspend fun ApplicationCall.respondTarGz(body: (TarArchiveOutputStream) -> Unit) } } -fun sanitizeReceivedWorkspaceConfig(receivedWorkspaceConfig: Workspace, existingWorkspaceConfig: Workspace): Workspace = +fun sanitizeReceivedWorkspaceConfig(receivedWorkspaceConfig: InternalWorkspaceConfig, existingWorkspaceConfig: InternalWorkspaceConfig): InternalWorkspaceConfig = mergeMaskedCredentialsWithPreviousCredentials(receivedWorkspaceConfig, existingWorkspaceConfig) .copy( // set ID just in case the user copy-pastes a workspace and forgets to change the ID @@ -1301,7 +1309,7 @@ fun sanitizeReceivedWorkspaceConfig(receivedWorkspaceConfig: Workspace, existing const val MASKED_CREDENTIAL_VALUE = "••••••••" -fun Workspace.maskCredentials(): Workspace { +fun InternalWorkspaceConfig.maskCredentials(): InternalWorkspaceConfig { val gitRepositories = this.gitRepositories.map { repository -> repository.copy( credentials = repository.credentials?.copy( @@ -1314,9 +1322,9 @@ fun Workspace.maskCredentials(): Workspace { } fun mergeMaskedCredentialsWithPreviousCredentials( - receivedWorkspaceConfig: Workspace, - existingWorkspaceConfig: Workspace, -): Workspace { + receivedWorkspaceConfig: InternalWorkspaceConfig, + existingWorkspaceConfig: InternalWorkspaceConfig, +): InternalWorkspaceConfig { val gitRepositories = receivedWorkspaceConfig.gitRepositories.mapIndexed { i, receivedRepository -> // Credentials will be reused, when: // * When the URL is the same, @@ -1377,7 +1385,7 @@ private fun FlowOrInteractiveOrPhrasingContent.buildPermissionManagementLink(res } } -private fun TarArchiveOutputStream.putFile(name: String, content: ByteArray) { +fun TarArchiveOutputStream.putFile(name: String, content: ByteArray) { putArchiveEntry(TarArchiveEntry(name).also { it.size = content.size.toLong() }) write(content) closeArchiveEntry() diff --git a/workspace-manager/src/test/kotlin/org/modelix/instancesmanager/RedirectedURLTest.kt b/workspace-manager/src/test/kotlin/org/modelix/instancesmanager/RedirectedURLTest.kt deleted file mode 100644 index 50836f59..00000000 --- a/workspace-manager/src/test/kotlin/org/modelix/instancesmanager/RedirectedURLTest.kt +++ /dev/null @@ -1,42 +0,0 @@ -package org.modelix.instancesmanager - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull - -class RedirectedURLTest { - - fun createRedirectUrlToPort(portString: String, pathAfterPort: String): RedirectedURL { - return RedirectedURL( - remainingPath = "/port/$portString$pathAfterPort", - workspaceReference = "workspace", - sharedInstanceName = "own", - instanceName = null, - userToken = null, - ) - } - - @Test - fun `create redirect URL for valid port number with path after port`() { - val redirectedURL = createRedirectUrlToPort("65535", "/some_path") - assertEquals("http://workspace:65535/some_path", redirectedURL.getURLToRedirectTo(false)) - } - - @Test - fun `create redirect URL for valid port number without path after port`() { - val redirectedURL = createRedirectUrlToPort("65535", "") - assertEquals("http://workspace:65535", redirectedURL.getURLToRedirectTo(false)) - } - - @Test - fun `do no create redirect URL for port that is out of range`() { - val redirectedURL = createRedirectUrlToPort("65536", "") - assertNull(redirectedURL.getURLToRedirectTo(false)) - } - - @Test - fun `do no create redirect URL for port that is not a number`() { - val redirectedURL = createRedirectUrlToPort("not_a_number", "") - assertNull(redirectedURL.getURLToRedirectTo(false)) - } -} diff --git a/workspace-manager/src/test/kotlin/org/modelix/workspace/manager/WorkspaceManagerModuleTest.kt b/workspace-manager/src/test/kotlin/org/modelix/workspace/manager/WorkspaceManagerModuleTest.kt index 1287cd53..ff2440cb 100644 --- a/workspace-manager/src/test/kotlin/org/modelix/workspace/manager/WorkspaceManagerModuleTest.kt +++ b/workspace-manager/src/test/kotlin/org/modelix/workspace/manager/WorkspaceManagerModuleTest.kt @@ -4,13 +4,13 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import org.modelix.workspaces.Credentials import org.modelix.workspaces.GitRepository -import org.modelix.workspaces.Workspace +import org.modelix.workspaces.InternalWorkspaceConfig class WorkspaceManagerModuleTest { @Test fun `credential values are masked`() { - val workspaceConfig = Workspace( + val workspaceConfig = InternalWorkspaceConfig( id = "aId", gitRepositories = listOf( GitRepository( @@ -32,7 +32,7 @@ class WorkspaceManagerModuleTest { @Test fun `previous credentials are not used if credentials where removed`() { - val existingWorkspaceConfig = Workspace( + val existingWorkspaceConfig = InternalWorkspaceConfig( id = "aId", gitRepositories = listOf( GitRepository( @@ -44,7 +44,7 @@ class WorkspaceManagerModuleTest { ), ), ) - val newWorkspaceConfig = Workspace( + val newWorkspaceConfig = InternalWorkspaceConfig( id = "aId", gitRepositories = listOf( GitRepository( @@ -60,8 +60,8 @@ class WorkspaceManagerModuleTest { @Test fun `masked credentials are ignored if no previous repository exist`() { - val existingWorkspaceConfig = Workspace(id = "aId") - val newWorkspaceConfig = Workspace( + val existingWorkspaceConfig = InternalWorkspaceConfig(id = "aId") + val newWorkspaceConfig = InternalWorkspaceConfig( id = "aId", gitRepositories = listOf( GitRepository( @@ -77,7 +77,7 @@ class WorkspaceManagerModuleTest { val mergedWorkspaceConfig = mergeMaskedCredentialsWithPreviousCredentials(newWorkspaceConfig, existingWorkspaceConfig) assertEquals( - Workspace( + InternalWorkspaceConfig( id = "aId", gitRepositories = listOf( GitRepository( @@ -91,8 +91,8 @@ class WorkspaceManagerModuleTest { @Test fun `new credentials are used if no previous repository exist`() { - val existingWorkspaceConfig = Workspace(id = "aId") - val newWorkspaceConfig = Workspace( + val existingWorkspaceConfig = InternalWorkspaceConfig(id = "aId") + val newWorkspaceConfig = InternalWorkspaceConfig( id = "aId", gitRepositories = listOf( GitRepository( @@ -107,7 +107,7 @@ class WorkspaceManagerModuleTest { val mergedWorkspaceConfig = mergeMaskedCredentialsWithPreviousCredentials(newWorkspaceConfig, existingWorkspaceConfig) - val expectedWorkspaceConfig = Workspace( + val expectedWorkspaceConfig = InternalWorkspaceConfig( id = "aId", gitRepositories = listOf( GitRepository( @@ -124,7 +124,7 @@ class WorkspaceManagerModuleTest { @Test fun `previous credentials are removed when URL changes`() { - val existingWorkspaceConfig = Workspace( + val existingWorkspaceConfig = InternalWorkspaceConfig( id = "aId", gitRepositories = listOf( GitRepository( @@ -136,7 +136,7 @@ class WorkspaceManagerModuleTest { ), ), ) - val newWorkspaceConfig = Workspace( + val newWorkspaceConfig = InternalWorkspaceConfig( id = "aId", gitRepositories = listOf( GitRepository( @@ -150,7 +150,7 @@ class WorkspaceManagerModuleTest { ) val mergedWorkspaceConfig = mergeMaskedCredentialsWithPreviousCredentials(newWorkspaceConfig, existingWorkspaceConfig) - val expectedWorkspaceConfig = Workspace( + val expectedWorkspaceConfig = InternalWorkspaceConfig( id = "aId", gitRepositories = listOf( GitRepository( @@ -163,7 +163,7 @@ class WorkspaceManagerModuleTest { @Test fun `masked credentials are replaced with previous credentials`() { - val existingWorkspaceConfig = Workspace( + val existingWorkspaceConfig = InternalWorkspaceConfig( id = "aId", gitRepositories = listOf( GitRepository( @@ -192,7 +192,7 @@ class WorkspaceManagerModuleTest { @Test fun `new credentials are not replaced with previous credentials`() { - val existingWorkspaceConfig = Workspace( + val existingWorkspaceConfig = InternalWorkspaceConfig( id = "aId", gitRepositories = listOf( GitRepository( @@ -204,7 +204,7 @@ class WorkspaceManagerModuleTest { ), ), ) - val newWorkspaceConfig = Workspace( + val newWorkspaceConfig = InternalWorkspaceConfig( id = "aId", gitRepositories = listOf( GitRepository( diff --git a/workspaces/src/main/kotlin/org/modelix/workspaces/Workspace.kt b/workspaces/src/main/kotlin/org/modelix/workspaces/InternalWorkspaceConfig.kt similarity index 82% rename from workspaces/src/main/kotlin/org/modelix/workspaces/Workspace.kt rename to workspaces/src/main/kotlin/org/modelix/workspaces/InternalWorkspaceConfig.kt index abc179a9..a4b7c301 100644 --- a/workspaces/src/main/kotlin/org/modelix/workspaces/Workspace.kt +++ b/workspaces/src/main/kotlin/org/modelix/workspaces/InternalWorkspaceConfig.kt @@ -22,12 +22,13 @@ import org.modelix.model.persistent.HashUtil const val DEFAULT_MPS_VERSION = "2024.1" @Serializable -data class Workspace( +data class InternalWorkspaceConfig( val id: String, val name: String? = null, val mpsVersion: String? = null, - val memoryLimit: String = "2.0Gi", + val memoryLimit: String = "2Gi", val modelRepositories: List = listOf(), + val gitRepositoryIds: List? = null, val gitRepositories: List = listOf(), val mavenRepositories: List = listOf(), val mavenDependencies: List = listOf(), @@ -42,6 +43,18 @@ data class Workspace( ) { fun uploadIds() = uploads.map { UploadId(it) } fun toYaml() = Yaml.default.encodeToString(this).replace("\nwaitForIndexer: false", "").replace("\nwaitForIndexer: true", "") + + fun normalizeForBuild() = copy( + name = null, + mpsVersion = mpsVersion ?: DEFAULT_MPS_VERSION, + memoryLimit = "2Gi", + modelRepositories = emptyList(), + gitRepositories = gitRepositories.map { it.copy(credentials = null) }, + loadUsedModulesOnly = false, + sharedInstances = emptyList(), + waitForIndexer = true, + modelSyncEnabled = false, + ) } /** @@ -50,7 +63,7 @@ data class Workspace( * There was an issue in the communication between the workspace-job and the workspace-manager, * because of a hash mismatch. */ -data class WorkspaceAndHash(val workspace: Workspace, private val hash: WorkspaceHash) { +data class WorkspaceAndHash(val workspace: InternalWorkspaceConfig, private val hash: WorkspaceHash) { fun hash(): WorkspaceHash = hash fun uploadIds() = workspace.uploadIds() @@ -72,8 +85,8 @@ data class WorkspaceAndHash(val workspace: Workspace, private val hash: Workspac val sharedInstances = workspace.sharedInstances } -fun Workspace.withHash(hash: WorkspaceHash) = WorkspaceAndHash(this, hash) -fun Workspace.withHash() = WorkspaceAndHash(this, WorkspaceHash(HashUtil.sha256(Json.encodeToString(this)))) +fun InternalWorkspaceConfig.withHash(hash: WorkspaceHash) = WorkspaceAndHash(this, hash) +fun InternalWorkspaceConfig.withHash() = WorkspaceAndHash(this, WorkspaceHash(HashUtil.sha256(Json.encodeToString(this)))) @Serializable data class GenerationDependency(val from: String, val to: String) diff --git a/workspaces/src/main/kotlin/org/modelix/workspaces/WorkspaceConfigForBuild.kt b/workspaces/src/main/kotlin/org/modelix/workspaces/WorkspaceConfigForBuild.kt new file mode 100644 index 00000000..e310eb65 --- /dev/null +++ b/workspaces/src/main/kotlin/org/modelix/workspaces/WorkspaceConfigForBuild.kt @@ -0,0 +1,36 @@ +package org.modelix.workspaces + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.modelix.model.persistent.HashUtil + +@Serializable +data class WorkspaceConfigForBuild( + val id: String, + val mpsVersion: String, + val gitRepositories: Set, + val memoryLimit: Long, + val mavenRepositories: Set, + val mavenArtifacts: Set, + val ignoredModules: Set, + val additionalGenerationDependencies: Set>, + val loadUsedModulesOnly: Boolean, +) + +@Serializable +data class GitConfigForBuild( + val url: String, + val username: String?, + val password: String?, + val branch: String, + val commitHash: String, +) + +@Serializable +data class MavenRepositoryForBuild( + val url: String, + val username: String?, + val password: String?, +) + +fun WorkspaceConfigForBuild.hash(): String = HashUtil.sha256(Json.encodeToString(this)) diff --git a/workspaces/src/main/kotlin/org/modelix/workspaces/WorkspacePersistence.kt b/workspaces/src/main/kotlin/org/modelix/workspaces/WorkspacePersistence.kt index 7aa495bb..5561e7d9 100644 --- a/workspaces/src/main/kotlin/org/modelix/workspaces/WorkspacePersistence.kt +++ b/workspaces/src/main/kotlin/org/modelix/workspaces/WorkspacePersistence.kt @@ -13,7 +13,6 @@ */ package org.modelix.workspaces -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.modelix.model.client.RestWebModelClient import org.modelix.model.persistent.HashUtil @@ -21,12 +20,12 @@ import org.modelix.model.persistent.SerializationUtil interface WorkspacePersistence { fun getWorkspaceIds(): Set - fun newWorkspace(): Workspace + fun newWorkspace(): InternalWorkspaceConfig fun removeWorkspace(workspaceId: String) - fun getAllWorkspaces(): List - fun getWorkspaceForId(id: String): Workspace? + fun getAllWorkspaces(): List + fun getWorkspaceForId(id: String): InternalWorkspaceConfig? fun getWorkspaceForHash(hash: WorkspaceHash): WorkspaceAndHash? - fun update(workspace: Workspace): WorkspaceHash + fun update(workspace: InternalWorkspaceConfig): WorkspaceHash } class ModelServerWorkspacePersistence(authTokenProvider: () -> String?) : WorkspacePersistence { @@ -46,8 +45,8 @@ class ModelServerWorkspacePersistence(authTokenProvider: () -> String?) : Worksp } @Synchronized - override fun newWorkspace(): Workspace { - val workspace = Workspace( + override fun newWorkspace(): InternalWorkspaceConfig { + val workspace = InternalWorkspaceConfig( id = generateId(), modelRepositories = listOf(ModelRepository(id = "default")), ) @@ -63,12 +62,12 @@ class ModelServerWorkspacePersistence(authTokenProvider: () -> String?) : Worksp private fun key(workspaceId: String) = "workspace-$workspaceId" - override fun getWorkspaceForId(id: String): Workspace? { + override fun getWorkspaceForId(id: String): InternalWorkspaceConfig? { require(id.matches(Regex("[a-f0-9]{9,16}"))) { "Invalid workspace ID: $id" } return getWorkspaceForIdOrHash(id)?.workspace } - override fun getAllWorkspaces(): List { + override fun getAllWorkspaces(): List { return getWorkspaceIds().mapNotNull { getWorkspaceForId(it) } } @@ -95,17 +94,17 @@ class ModelServerWorkspacePersistence(authTokenProvider: () -> String?) : Worksp modelClient.put(key(id), hash.toString()) } } - return Json.decodeFromString(json).withHash(hash) + return Json.decodeFromString(json).withHash(hash) } @Synchronized override fun getWorkspaceForHash(hash: WorkspaceHash): WorkspaceAndHash? { val json = modelClient.get(hash.toString()) ?: return null - return Json.decodeFromString(json).withHash(hash) + return Json.decodeFromString(json).withHash(hash) } @Synchronized - override fun update(workspace: Workspace): WorkspaceHash { + override fun update(workspace: InternalWorkspaceConfig): WorkspaceHash { val mpsVersion = workspace.mpsVersion require(mpsVersion == null || mpsVersion.matches(Regex("""20\d\d\.\d"""))) { "Invalid major MPS version: '$mpsVersion'. Examples for valid values: '2020.3', '2021.1', '2021.2'."