From 84b180a6536f93d2f73b7ef57fc12672033151ef Mon Sep 17 00:00:00 2001 From: Sascha Lisson Date: Sat, 26 Apr 2025 21:40:57 +0200 Subject: [PATCH 01/16] fix: CORS configuration adjusted for the modelix-dashboard --- .../workspace/manager/WorkspaceManagerModule.kt | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) 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..1ccf7b85 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 @@ -22,7 +22,6 @@ 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 @@ -41,6 +40,9 @@ import io.ktor.server.html.respondHtml import io.ktor.server.http.content.staticResources import io.ktor.server.plugins.contentnegotiation.ContentNegotiation import io.ktor.server.plugins.cors.routing.CORS +import io.ktor.server.plugins.origin +import io.ktor.server.request.httpMethod +import io.ktor.server.request.path import io.ktor.server.request.receiveMultipart import io.ktor.server.request.receiveParameters import io.ktor.server.response.respond @@ -1235,11 +1237,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) } } From 4818033a2d7dcadd0e6b9ce849b91cdc9ff6547f Mon Sep 17 00:00:00 2001 From: Sascha Lisson Date: Sat, 26 Apr 2025 21:41:37 +0200 Subject: [PATCH 02/16] feat: call logging --- gradle/libs.versions.toml | 1 + workspace-manager/build.gradle.kts | 1 + .../workspace/manager/WorkspaceManagerModule.kt | 16 ++++++++++++++++ 3 files changed, 18 insertions(+) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bac8fa46..12cc814f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -36,6 +36,7 @@ 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" } diff --git a/workspace-manager/build.gradle.kts b/workspace-manager/build.gradle.kts index e94f47ac..d25cd46a 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 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 1ccf7b85..c8c70ab8 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 @@ -38,6 +38,8 @@ 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.plugins.origin @@ -159,6 +161,20 @@ 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") From 36979a6f8bff56903185890a25229cb0dc4538d9 Mon Sep 17 00:00:00 2001 From: Sascha Lisson Date: Sun, 27 Apr 2025 15:36:09 +0200 Subject: [PATCH 03/16] feat: openapi generator --- gradle/libs.versions.toml | 2 + settings.gradle.kts | 1 + workspace-manager-openapi/build.gradle.kts | 65 ++++++++++ .../specifications/workspace-manager.yaml | 120 ++++++++++++++++++ workspace-manager/build.gradle.kts | 1 + .../workspace/manager/MavenControllerImpl.kt | 62 +++++++++ .../manager/WorkspaceManagerModule.kt | 2 + 7 files changed, 253 insertions(+) create mode 100644 workspace-manager-openapi/build.gradle.kts create mode 100644 workspace-manager-openapi/specifications/workspace-manager.yaml create mode 100644 workspace-manager/src/main/kotlin/org/modelix/workspace/manager/MavenControllerImpl.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 12cc814f..04c4413b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,6 +40,7 @@ ktor-server-call-logging = { group = "io.ktor", name = "ktor-server-call-logging 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" } @@ -86,3 +87,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/settings.gradle.kts b/settings.gradle.kts index 8f6faf50..f3e31294 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -30,4 +30,5 @@ include("gitui") include("workspace-client-plugin") include("workspace-job") include("workspace-manager") +include("workspace-manager-openapi") include("workspaces") diff --git a/workspace-manager-openapi/build.gradle.kts b/workspace-manager-openapi/build.gradle.kts new file mode 100644 index 00000000..009388d3 --- /dev/null +++ b/workspace-manager-openapi/build.gradle.kts @@ -0,0 +1,65 @@ +import org.openapitools.generator.gradle.plugin.tasks.GenerateTask + +plugins { + base + id("ch.acanda.gradle.fabrikt") version "1.15.2" + kotlin("jvm") + alias(libs.plugins.openapi.generator) + alias(libs.plugins.kotlin.serialization) +} + +dependencies { + implementation(libs.ktor.server.core) + implementation(libs.ktor.server.auth) + implementation(libs.ktor.server.data.conversion) +} + +tasks.processResources { + dependsOn(tasks.fabriktGenerate) +} +tasks.compileKotlin { + dependsOn(tasks.fabriktGenerate) +} + +fabrikt { + generate("workspaces") { + apiFile = file("specifications/workspace-manager.yaml") + basePackage = "org.modelix.service.workspaces" + validationLibrary = NoValidation + model { + generate = enabled + serializationLibrary = Kotlin + includeCompanionObject = enabled + // ignoreUnknownProperties = enabled + extensibleEnums = enabled + } + controller { + generate = enabled + target = Ktor + authentication = enabled + suspendModifier = enabled + completionStage = enabled + } + } +} + +val generatedKotlinSrc = project.layout.buildDirectory.dir("generated/sources/fabrikt/src/main/kotlin") +sourceSets["main"].resources.srcDir(generatedKotlinSrc) + +fun GenerateTask.commonGenerateConfig() { + group = "openapi tools" + inputSpecRootDirectory = layout.projectDirectory.dir("specifications").asFile.absolutePath + inputSpecRootDirectorySkipMerge = false +} + +val generateTypescript by tasks.registering(GenerateTask::class) { + commonGenerateConfig() + generatorName = "typescript-fetch" + outputDir = layout.buildDirectory.dir("generate/typescript-fetch").get().asFile.absolutePath +} + +val generateRedux by tasks.registering(GenerateTask::class) { + commonGenerateConfig() + generatorName = "typescript-redux-query" + outputDir = layout.buildDirectory.dir("generate/typescript-redux-query").get().asFile.absolutePath +} diff --git a/workspace-manager-openapi/specifications/workspace-manager.yaml b/workspace-manager-openapi/specifications/workspace-manager.yaml new file mode 100644 index 00000000..a4a83c72 --- /dev/null +++ b/workspace-manager-openapi/specifications/workspace-manager.yaml @@ -0,0 +1,120 @@ +openapi: "3.0.3" +info: + title: "Modelix Workspaces" + version: "1.0.0" +servers: + - url: '/modelix/workspaces' + description: modelix-workspaces-manager +paths: + /connectivity/maven/: + get: + operationId: getMavenConnectorConfig + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/MavenConnectorConfig' + /connectivity/maven/repositories/: + get: + operationId: listMavenRepositories + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/MavenRepositoryList' + /connectivity/maven/repositories/{repositoryId}: + get: + operationId: getMavenRepository + parameters: + - name: repositoryId + in: path + required: true + schema: + type: string + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/MavenRepository' + /connectivity/maven/artifacts/: + get: + operationId: listMavenArtifacts + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/MavenArtifactList' +components: + schemas: + MavenConnectorConfig: + type: object + properties: + repositories: + type: array + required: true + items: + $ref: '#/components/schemas/MavenRepository' + artifacts: + type: array + required: true + items: + $ref: '#/components/schemas/MavenArtifact' + MavenRepositoryList: + type: object + properties: + repositories: + type: array + required: true + items: + $ref: '#/components/schemas/MavenRepository' + MavenRepository: + type: object + properties: + id: + type: string + required: true + nullable: false + url: + type: string + required: true + nullable: false + MavenArtifactList: + type: object + properties: + artifacts: + type: array + required: true + items: + $ref: '#/components/schemas/MavenArtifact' + MavenArtifact: + type: object + properties: + groupId: + type: string + required: true + nullable: false + artifactId: + type: string + required: true + nullable: false + version: + type: string + required: false + nullable: true + + securitySchemes: + modelixJwtAuth: + type: http + scheme: bearer + bearerFormat: JWT + +security: + - modelixJwtAuth: [] diff --git a/workspace-manager/build.gradle.kts b/workspace-manager/build.gradle.kts index d25cd46a..6dea3061 100644 --- a/workspace-manager/build.gradle.kts +++ b/workspace-manager/build.gradle.kts @@ -50,6 +50,7 @@ dependencies { implementation(libs.zt.zip) implementation(project(":gitui")) implementation(project(":workspaces")) + implementation(project(":workspace-manager-openapi")) 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/workspace/manager/MavenControllerImpl.kt b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/MavenControllerImpl.kt new file mode 100644 index 00000000..8e0feb2e --- /dev/null +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/MavenControllerImpl.kt @@ -0,0 +1,62 @@ +package org.modelix.workspace.manager + +import io.ktor.http.HttpStatusCode +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import org.modelix.service.workspaces.controllers.ConnectivityMavenArtifactsController +import org.modelix.service.workspaces.controllers.ConnectivityMavenArtifactsController.Companion.connectivityMavenArtifactsRoutes +import org.modelix.service.workspaces.controllers.ConnectivityMavenController +import org.modelix.service.workspaces.controllers.ConnectivityMavenController.Companion.connectivityMavenRoutes +import org.modelix.service.workspaces.controllers.ConnectivityMavenRepositoriesController +import org.modelix.service.workspaces.controllers.ConnectivityMavenRepositoriesController.Companion.connectivityMavenRepositoriesRoutes +import org.modelix.service.workspaces.controllers.TypedApplicationCall +import org.modelix.service.workspaces.models.MavenArtifact +import org.modelix.service.workspaces.models.MavenArtifactList +import org.modelix.service.workspaces.models.MavenConnectorConfig +import org.modelix.service.workspaces.models.MavenRepository +import org.modelix.service.workspaces.models.MavenRepositoryList + +class MavenControllerImpl : + ConnectivityMavenRepositoriesController, + ConnectivityMavenArtifactsController, + ConnectivityMavenController { + + val 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.connectivityMavenRoutes(this) + route.connectivityMavenRepositoriesRoutes(this) + route.connectivityMavenArtifactsRoutes(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 listMavenRepositories(call: TypedApplicationCall) { + call.respondTyped(MavenRepositoryList(data.repositories)) + } + + override suspend fun listMavenArtifacts(call: TypedApplicationCall) { + call.respondTyped(MavenArtifactList(data.artifacts)) + } +} 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 c8c70ab8..73a7e69a 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 @@ -182,6 +182,8 @@ fun Application.workspaceManagerModule() { this.adminModule(deploymentManager) } + MavenControllerImpl().install(this) + requiresLogin { get("/") { call.respondHtmlSafe(HttpStatusCode.OK) { From 804a7184eecb16ef54d5befd93969ef9111a5581 Mon Sep 17 00:00:00 2001 From: Sascha Lisson Date: Fri, 2 May 2025 10:10:08 +0200 Subject: [PATCH 04/16] feat: workspaces editor --- gradle/libs.versions.toml | 1 + settings.gradle.kts | 1 - workspace-manager-openapi/build.gradle.kts | 65 ------- .../specifications/workspace-manager.yaml | 120 ------------ workspace-manager/build.gradle.kts | 2 +- .../workspace/manager/MavenControllerImpl.kt | 90 +++++++-- .../manager/WorkspaceManagerModule.kt | 43 +++++ .../workspace/manager/WorkspacesController.kt | 179 ++++++++++++++++++ .../org/modelix/workspaces/Workspace.kt | 2 +- 9 files changed, 294 insertions(+), 209 deletions(-) delete mode 100644 workspace-manager-openapi/build.gradle.kts delete mode 100644 workspace-manager-openapi/specifications/workspace-manager.yaml create mode 100644 workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspacesController.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 04c4413b..f66eec49 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -56,6 +56,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 = "1.0.0" } [bundles] ktor-client = [ diff --git a/settings.gradle.kts b/settings.gradle.kts index f3e31294..8f6faf50 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -30,5 +30,4 @@ include("gitui") include("workspace-client-plugin") include("workspace-job") include("workspace-manager") -include("workspace-manager-openapi") include("workspaces") diff --git a/workspace-manager-openapi/build.gradle.kts b/workspace-manager-openapi/build.gradle.kts deleted file mode 100644 index 009388d3..00000000 --- a/workspace-manager-openapi/build.gradle.kts +++ /dev/null @@ -1,65 +0,0 @@ -import org.openapitools.generator.gradle.plugin.tasks.GenerateTask - -plugins { - base - id("ch.acanda.gradle.fabrikt") version "1.15.2" - kotlin("jvm") - alias(libs.plugins.openapi.generator) - alias(libs.plugins.kotlin.serialization) -} - -dependencies { - implementation(libs.ktor.server.core) - implementation(libs.ktor.server.auth) - implementation(libs.ktor.server.data.conversion) -} - -tasks.processResources { - dependsOn(tasks.fabriktGenerate) -} -tasks.compileKotlin { - dependsOn(tasks.fabriktGenerate) -} - -fabrikt { - generate("workspaces") { - apiFile = file("specifications/workspace-manager.yaml") - basePackage = "org.modelix.service.workspaces" - validationLibrary = NoValidation - model { - generate = enabled - serializationLibrary = Kotlin - includeCompanionObject = enabled - // ignoreUnknownProperties = enabled - extensibleEnums = enabled - } - controller { - generate = enabled - target = Ktor - authentication = enabled - suspendModifier = enabled - completionStage = enabled - } - } -} - -val generatedKotlinSrc = project.layout.buildDirectory.dir("generated/sources/fabrikt/src/main/kotlin") -sourceSets["main"].resources.srcDir(generatedKotlinSrc) - -fun GenerateTask.commonGenerateConfig() { - group = "openapi tools" - inputSpecRootDirectory = layout.projectDirectory.dir("specifications").asFile.absolutePath - inputSpecRootDirectorySkipMerge = false -} - -val generateTypescript by tasks.registering(GenerateTask::class) { - commonGenerateConfig() - generatorName = "typescript-fetch" - outputDir = layout.buildDirectory.dir("generate/typescript-fetch").get().asFile.absolutePath -} - -val generateRedux by tasks.registering(GenerateTask::class) { - commonGenerateConfig() - generatorName = "typescript-redux-query" - outputDir = layout.buildDirectory.dir("generate/typescript-redux-query").get().asFile.absolutePath -} diff --git a/workspace-manager-openapi/specifications/workspace-manager.yaml b/workspace-manager-openapi/specifications/workspace-manager.yaml deleted file mode 100644 index a4a83c72..00000000 --- a/workspace-manager-openapi/specifications/workspace-manager.yaml +++ /dev/null @@ -1,120 +0,0 @@ -openapi: "3.0.3" -info: - title: "Modelix Workspaces" - version: "1.0.0" -servers: - - url: '/modelix/workspaces' - description: modelix-workspaces-manager -paths: - /connectivity/maven/: - get: - operationId: getMavenConnectorConfig - responses: - "200": - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/MavenConnectorConfig' - /connectivity/maven/repositories/: - get: - operationId: listMavenRepositories - responses: - "200": - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/MavenRepositoryList' - /connectivity/maven/repositories/{repositoryId}: - get: - operationId: getMavenRepository - parameters: - - name: repositoryId - in: path - required: true - schema: - type: string - responses: - "200": - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/MavenRepository' - /connectivity/maven/artifacts/: - get: - operationId: listMavenArtifacts - responses: - "200": - description: OK - content: - application/json: - schema: - $ref: '#/components/schemas/MavenArtifactList' -components: - schemas: - MavenConnectorConfig: - type: object - properties: - repositories: - type: array - required: true - items: - $ref: '#/components/schemas/MavenRepository' - artifacts: - type: array - required: true - items: - $ref: '#/components/schemas/MavenArtifact' - MavenRepositoryList: - type: object - properties: - repositories: - type: array - required: true - items: - $ref: '#/components/schemas/MavenRepository' - MavenRepository: - type: object - properties: - id: - type: string - required: true - nullable: false - url: - type: string - required: true - nullable: false - MavenArtifactList: - type: object - properties: - artifacts: - type: array - required: true - items: - $ref: '#/components/schemas/MavenArtifact' - MavenArtifact: - type: object - properties: - groupId: - type: string - required: true - nullable: false - artifactId: - type: string - required: true - nullable: false - version: - type: string - required: false - nullable: true - - securitySchemes: - modelixJwtAuth: - type: http - scheme: bearer - bearerFormat: JWT - -security: - - modelixJwtAuth: [] diff --git a/workspace-manager/build.gradle.kts b/workspace-manager/build.gradle.kts index 6dea3061..90b9826a 100644 --- a/workspace-manager/build.gradle.kts +++ b/workspace-manager/build.gradle.kts @@ -50,7 +50,7 @@ dependencies { implementation(libs.zt.zip) implementation(project(":gitui")) implementation(project(":workspaces")) - implementation(project(":workspace-manager-openapi")) + 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/workspace/manager/MavenControllerImpl.kt b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/MavenControllerImpl.kt index 8e0feb2e..bff2de12 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/MavenControllerImpl.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/MavenControllerImpl.kt @@ -1,27 +1,27 @@ 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.service.workspaces.controllers.ConnectivityMavenArtifactsController -import org.modelix.service.workspaces.controllers.ConnectivityMavenArtifactsController.Companion.connectivityMavenArtifactsRoutes -import org.modelix.service.workspaces.controllers.ConnectivityMavenController -import org.modelix.service.workspaces.controllers.ConnectivityMavenController.Companion.connectivityMavenRoutes -import org.modelix.service.workspaces.controllers.ConnectivityMavenRepositoriesController -import org.modelix.service.workspaces.controllers.ConnectivityMavenRepositoriesController.Companion.connectivityMavenRepositoriesRoutes -import org.modelix.service.workspaces.controllers.TypedApplicationCall -import org.modelix.service.workspaces.models.MavenArtifact -import org.modelix.service.workspaces.models.MavenArtifactList -import org.modelix.service.workspaces.models.MavenConnectorConfig -import org.modelix.service.workspaces.models.MavenRepository -import org.modelix.service.workspaces.models.MavenRepositoryList +import org.modelix.services.maven_connector.stubs.controllers.ModelixMavenConnectorArtifactsController +import org.modelix.services.maven_connector.stubs.controllers.ModelixMavenConnectorArtifactsController.Companion.modelixMavenConnectorArtifactsRoutes +import org.modelix.services.maven_connector.stubs.controllers.ModelixMavenConnectorController +import org.modelix.services.maven_connector.stubs.controllers.ModelixMavenConnectorController.Companion.modelixMavenConnectorRoutes +import org.modelix.services.maven_connector.stubs.controllers.ModelixMavenConnectorRepositoriesController +import org.modelix.services.maven_connector.stubs.controllers.ModelixMavenConnectorRepositoriesController.Companion.modelixMavenConnectorRepositoriesRoutes +import org.modelix.services.maven_connector.stubs.controllers.TypedApplicationCall +import org.modelix.services.maven_connector.stubs.models.MavenArtifact +import org.modelix.services.maven_connector.stubs.models.MavenArtifactList +import org.modelix.services.maven_connector.stubs.models.MavenConnectorConfig +import org.modelix.services.maven_connector.stubs.models.MavenRepository +import org.modelix.services.maven_connector.stubs.models.MavenRepositoryList class MavenControllerImpl : - ConnectivityMavenRepositoriesController, - ConnectivityMavenArtifactsController, - ConnectivityMavenController { - - val data = MavenConnectorConfig( + ModelixMavenConnectorController, + ModelixMavenConnectorArtifactsController, + ModelixMavenConnectorRepositoriesController { + var data = MavenConnectorConfig( repositories = listOf( MavenRepository(id = "itemis", "https://artifacts.itemis.cloud/repository/maven-mps/"), ), @@ -35,16 +35,16 @@ class MavenControllerImpl : } fun install(route: Route) { - route.connectivityMavenRoutes(this) - route.connectivityMavenRepositoriesRoutes(this) - route.connectivityMavenArtifactsRoutes(this) + 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 } + val repository = data.repositories.find { it.id == repositoryId } if (repository == null) { call.respond(HttpStatusCode.NotFound) } else { @@ -52,6 +52,29 @@ class MavenControllerImpl : } } + 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)) } @@ -59,4 +82,29 @@ class MavenControllerImpl : 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/WorkspaceManagerModule.kt b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceManagerModule.kt index 73a7e69a..e6323aaa 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 @@ -124,6 +124,14 @@ 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.services.maven_connector.stubs.controllers.ModelixMavenConnectorController +import org.modelix.services.maven_connector.stubs.controllers.ModelixMavenConnectorController.Companion.modelixMavenConnectorRoutes +import org.modelix.services.maven_connector.stubs.controllers.ModelixMavenConnectorRepositoriesController +import org.modelix.services.maven_connector.stubs.controllers.ModelixMavenConnectorRepositoriesController.Companion.modelixMavenConnectorRepositoriesRoutes +import org.modelix.services.maven_connector.stubs.controllers.TypedApplicationCall +import org.modelix.services.maven_connector.stubs.models.MavenConnectorConfig +import org.modelix.services.maven_connector.stubs.models.MavenRepository +import org.modelix.services.maven_connector.stubs.models.MavenRepositoryList import org.modelix.workspace.manager.WorkspaceJobQueue.Companion.HELM_PREFIX import org.modelix.workspaces.Credentials import org.modelix.workspaces.GitRepository @@ -183,6 +191,41 @@ fun Application.workspaceManagerModule() { } MavenControllerImpl().install(this) + WorkspacesController(manager, deploymentManager).install(this) + + modelixMavenConnectorRoutes(object : ModelixMavenConnectorController { + override suspend fun getMavenConnectorConfig(call: TypedApplicationCall) { + TODO("Not yet implemented") + } + }) + + modelixMavenConnectorRepositoriesRoutes(object : ModelixMavenConnectorRepositoriesController { + override suspend fun deleteMavenRepository( + repositoryId: String, + call: ApplicationCall, + ) { + TODO("Not yet implemented") + } + + override suspend fun listMavenRepositories(call: TypedApplicationCall) { + TODO("Not yet implemented") + } + + override suspend fun getMavenRepository( + repositoryId: String, + call: TypedApplicationCall, + ) { + TODO("Not yet implemented") + } + + override suspend fun updateMavenRepository( + repositoryId: String, + mavenRepository: MavenRepository, + call: ApplicationCall, + ) { + TODO("Not yet implemented") + } + }) requiresLogin { get("/") { diff --git a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspacesController.kt b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspacesController.kt new file mode 100644 index 00000000..ee8a2764 --- /dev/null +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspacesController.kt @@ -0,0 +1,179 @@ +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.authorization.getUserName +import org.modelix.instancesmanager.DeploymentManager +import org.modelix.services.workspaces.stubs.controllers.ModelixWorkspacesDraftsController +import org.modelix.services.workspaces.stubs.controllers.ModelixWorkspacesDraftsController.Companion.modelixWorkspacesDraftsRoutes +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.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.GitChangeDraft +import org.modelix.services.workspaces.stubs.models.GitChangeDraftList +import org.modelix.services.workspaces.stubs.models.GitRepository +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.WorkspaceList +import org.modelix.workspaces.DEFAULT_MPS_VERSION +import org.modelix.workspaces.MavenRepository +import org.modelix.workspaces.Workspace +import java.util.UUID + +class WorkspacesController(val manager: WorkspaceManager, val deployments: DeploymentManager) { + + private var workspaceInstances: WorkspaceInstanceList = WorkspaceInstanceList(emptyList()) + private val drafts: GitChangeDraftList = GitChangeDraftList(emptyList()) + + fun install(route: Route) { + route.install_() + } + + private fun Route.install_() { + modelixWorkspacesWorkspacesRoutes(object : ModelixWorkspacesWorkspacesController { + override suspend fun getWorkspace( + workspaceId: String, + call: TypedApplicationCall, + ) { + val workspace = manager.getWorkspaceForId(workspaceId) + if (workspace == null) { + call.respond(HttpStatusCode.NotFound) + } else { + call.respondTyped(workspace.workspace.convert()) + } + } + + override suspend fun listWorkspaces(call: TypedApplicationCall) { + call.respondTyped( + WorkspaceList( + workspaces = manager.getAllWorkspaces().map { it.convert() }, + ), + ) + } + + override suspend fun deleteWorkspace( + workspaceId: String, + call: ApplicationCall, + ) { + manager.removeWorkspace(workspaceId) + call.respond(HttpStatusCode.OK) + } + + override suspend fun updateWorkspace( + workspaceId: String, + legacyWorkspaceConfig: WorkspaceConfig, + call: ApplicationCall, + ) { + val oldConfig = manager.getWorkspaceForId(workspaceId)?.workspace + ?: manager.newWorkspace(owner = call.getUserName()) + manager.update( + oldConfig.copy( + name = legacyWorkspaceConfig.name, + mpsVersion = legacyWorkspaceConfig.mpsVersion, + memoryLimit = legacyWorkspaceConfig.memoryLimit, + gitRepositories = legacyWorkspaceConfig.gitRepositories.map { + org.modelix.workspaces.GitRepository(it.url, null) + }, + mavenRepositories = (legacyWorkspaceConfig.mavenRepositories ?: emptyList()).map { + MavenRepository(it.url) + } + ), + ) + call.respond(HttpStatusCode.OK) + } + }) + + modelixWorkspacesInstancesRoutes(object : ModelixWorkspacesInstancesController { + override suspend fun getInstance( + instanceId: String, + call: TypedApplicationCall, + ) { + val instance = workspaceInstances.instances.find { it.id == instanceId } + if (instance == null) { + call.respond(HttpStatusCode.NotFound) + } else { + call.respondTyped(instance) + } + } + + override suspend fun listInstances(workspaceId: String?, call: TypedApplicationCall) { + val filteredInstances = if (workspaceId == null) { + workspaceInstances + } else { + workspaceInstances.copy(instances = workspaceInstances.instances.filter { it.workspaceId == workspaceId }) + } + call.respondTyped(filteredInstances) + } + + override suspend fun createInstance( + workspaceInstance: WorkspaceInstance, + call: TypedApplicationCall + ) { + workspaceInstances = workspaceInstances.copy( + instances = workspaceInstances.instances + workspaceInstance.copy( + id = UUID.randomUUID().toString(), + workspaceId = workspaceInstance.workspaceId, + drafts = emptyList(), + owner = call.getUserName(), + state = WorkspaceInstanceState.CREATED + ) + ) + } + }) + + modelixWorkspacesInstancesEnabledRoutes(object : ModelixWorkspacesInstancesEnabledController { + override suspend fun enableInstance( + instanceId: String, + workspaceInstanceEnabled: WorkspaceInstanceEnabled, + call: ApplicationCall + ) { + workspaceInstances = workspaceInstances.copy( + instances = workspaceInstances.instances.map { + if (it.id == instanceId) { + it.copy(enabled = workspaceInstanceEnabled.enabled) + } else { + it + } + } + ) + call.respond(HttpStatusCode.OK) + } + }) + + modelixWorkspacesDraftsRoutes(object : ModelixWorkspacesDraftsController { + override suspend fun getDraft( + draftId: String, + call: TypedApplicationCall, + ) { + val draft = drafts.drafts.find { it.id == draftId } + if (draft == null) { + call.respond(HttpStatusCode.NotFound) + } else { + call.respondTyped(draft) + } + } + + override suspend fun listDrafts(call: TypedApplicationCall) { + call.respondTyped(drafts) + } + }) + } + + private fun Workspace.convert() = WorkspaceConfig( + id = id, + name = name ?: "", + mpsVersion = mpsVersion ?: DEFAULT_MPS_VERSION, + memoryLimit = memoryLimit, + gitRepositories = gitRepositories.map { GitRepository(it.url, null) }, + mavenRepositories = mavenRepositories.map { org.modelix.services.workspaces.stubs.models.MavenRepository(it.url) }, + ) +} diff --git a/workspaces/src/main/kotlin/org/modelix/workspaces/Workspace.kt b/workspaces/src/main/kotlin/org/modelix/workspaces/Workspace.kt index abc179a9..7c1ed3d5 100644 --- a/workspaces/src/main/kotlin/org/modelix/workspaces/Workspace.kt +++ b/workspaces/src/main/kotlin/org/modelix/workspaces/Workspace.kt @@ -26,7 +26,7 @@ data class Workspace( 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 gitRepositories: List = listOf(), val mavenRepositories: List = listOf(), From b637b60859ac1b9498e015992e9179655597dde6 Mon Sep 17 00:00:00 2001 From: Sascha Lisson Date: Sun, 4 May 2025 06:25:47 +0200 Subject: [PATCH 05/16] feat: workspace instances editor --- .../instancesmanager/AssignmentData.kt | 7 +- .../manager/WorkspaceBuildManager.kt | 15 + .../manager/WorkspaceInstancesManager.kt | 352 ++++++++++++++++++ .../manager/WorkspaceManagerModule.kt | 8 +- .../workspace/manager/WorkspacesController.kt | 100 +++-- 5 files changed, 445 insertions(+), 37 deletions(-) create mode 100644 workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceBuildManager.kt create mode 100644 workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceInstancesManager.kt diff --git a/workspace-manager/src/main/kotlin/org/modelix/instancesmanager/AssignmentData.kt b/workspace-manager/src/main/kotlin/org/modelix/instancesmanager/AssignmentData.kt index afc156a6..9e98acf2 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/instancesmanager/AssignmentData.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/instancesmanager/AssignmentData.kt @@ -15,4 +15,9 @@ package org.modelix.instancesmanager import org.modelix.workspaces.WorkspaceAndHash -class AssignmentData(val workspace: WorkspaceAndHash, val unassignedInstances: Int, val instances: List, val isLatest: Boolean) +class AssignmentData( + val workspace: WorkspaceAndHash, + val unassignedInstances: Int, + val instances: List, + val isLatest: Boolean +) 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..026e0bf8 --- /dev/null +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceBuildManager.kt @@ -0,0 +1,15 @@ +package org.modelix.workspace.manager + +import org.modelix.services.workspaces.stubs.models.WorkspaceConfig + +class WorkspaceBuildManager { + + fun getImageName(workspace: WorkspaceConfig): String { + return "modelix-workspaces/ws${workspace.id}" + } + + fun getImageTag(workspace: WorkspaceConfig): String { + return "TODO" + } + +} \ No newline at end of file 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..ba6f46ce --- /dev/null +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceInstancesManager.kt @@ -0,0 +1,352 @@ +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.V1DeploymentSpec +import io.kubernetes.client.openapi.models.V1EnvVar +import io.kubernetes.client.openapi.models.V1ObjectMeta +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.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +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.instancesmanager.DeploymentManager +import org.modelix.instancesmanager.InstanceName +import org.modelix.model.server.ModelServerPermissionSchema +import org.modelix.services.workspaces.stubs.models.WorkspaceInstance +import org.modelix.services.workspaces.stubs.models.WorkspaceInstanceList +import org.modelix.workspaces.WorkspaceAndHash +import org.modelix.workspaces.WorkspaceHash +import org.modelix.workspaces.WorkspacesPermissionSchema +import java.io.File +import java.util.Collections +import java.util.function.Consumer +import java.util.regex.Pattern + +private val LOG = KotlinLogging.logger {} + +class WorkspaceInstancesManager( + val workspaceManager: WorkspaceManager, + val buildManager: WorkspaceBuildManager, + 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 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")) + const val TIMEOUT_SECONDS = 10 + const val INSTANCE_ID_LABEL = "modelix.workspace.instance.id" + + fun WorkspaceInstance.instanceName() = 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 stateChanges = Channel(capacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + private val instanceList = SharedMutableState(WorkspaceInstanceList(emptyList())) + .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") + } + } + + fun dispose() { + reconciliationJob.cancel() + } + + fun updateInstancesList(updater: (WorkspaceInstanceList) -> WorkspaceInstanceList) { + instanceList.update(updater) + } + + fun getInstancesList() = instanceList.getValue() + + private fun getExistingDeployments(): Map { + val existingDeployments: MutableMap = HashMap() + val appsApi = AppsV1Api() + val deployments = appsApi + .listNamespacedDeployment(KUBERNETES_NAMESPACE) + .timeoutSeconds(TIMEOUT_SECONDS) + .execute() + for (deployment in deployments.items) { + val instanceId = deployment.metadata?.labels?.get(INSTANCE_ID_LABEL) ?: continue + existingDeployments[instanceId] = deployment + } + return existingDeployments + } + + private fun reconcile(instanceList: WorkspaceInstanceList) { + val appsApi = AppsV1Api() + val coreApi = CoreV1Api() + val expectedInstances = instanceList.instances.filter { it.enabled }.associateBy { it.id } + 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, DeploymentManager.Companion.KUBERNETES_NAMESPACE) + .execute() + } catch (e: Exception) { + LOG.error("Failed to delete deployment $deployment", e) + } + try { + coreApi.deleteNamespacedService(name, DeploymentManager.Companion.KUBERNETES_NAMESPACE) + .execute() + } catch (e: Exception) { + LOG.error("Failed to delete service $deployment", e) + } + } + for (instance in toAdd.values) { + try { + createDeployment(instance) + } catch (e: Exception) { + LOG.error("Failed to create deployment for workspace ${instance.config.id}", e) + } + } + + synchronized(indexWasReady) { + indexWasReady.removeAll(indexWasReady - expectedInstances.keys) + } + } + + 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(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) } + } + + fun createDeployment( + workspaceInstance: WorkspaceInstance, + ) { + val instanceName = workspaceInstance.instanceName() + val workspaceId = workspaceInstance.config.id + + val appsApi = AppsV1Api() + val deployment = Yaml.loadAs(File("/workspace-client-templates/deployment"), V1Deployment::class.java) + deployment.metadata { + name(instanceName.name) + 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)) + addEnvItem(V1EnvVar().name("REPOSITORY_ID").value("workspace_$workspaceId")) + //addEnvItem(V1EnvVar().name("modelix_workspace_hash").value(workspace.hash().hash)) + addEnvItem(V1EnvVar().name("WORKSPACE_MODEL_SYNC_ENABLED").value(false.toString())) + } + } + + var userId: String? = null + var hasWritePermission = workspaceInstance.readonly == false + val newPermissions = ArrayList() + newPermissions += WorkspacesPermissionSchema.workspaces.workspace(workspaceId).config.read + newPermissions += ModelServerPermissionSchema.repository("workspace_" + workspaceId) + .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) + 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.spec!!.ports!!.forEach(Consumer { p: V1ServicePort -> p.nodePort(null) }) + service.metadata!!.name(instanceName.name) + service.metadata!!.putLabelsItem(INSTANCE_ID_LABEL, workspaceInstance.id) + service.spec!!.putSelectorItem(INSTANCE_ID_LABEL, workspaceInstance.id) + println("Creating service: ") + println(Yaml.dump(service)) + coreApi.createNamespacedService(KUBERNETES_NAMESPACE, service).execute() + } + } + + private fun loadWorkspaceSpecificValues(workspaceInstance: WorkspaceInstance, deployment: V1Deployment) { + try { + val container = deployment.spec!!.template.spec!!.containers[0] + + val imageName = buildManager.getImageName(workspaceInstance.config) + val imageTag = buildManager.getImageTag(workspaceInstance.config) + + // 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}/${imageName}:${imageTag}" + + 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 getWorkspaceByHash(hash: WorkspaceHash): WorkspaceAndHash { + return requireNotNull(workspaceManager.getWorkspaceForHash(hash)) { + "Workspace not found: $hash" + } + } + + private fun getAccessControlData(): AccessControlData { + return workspaceManager.accessControlPersistence.read() + } +} + +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) + } + } + } +} + +fun V1Deployment.metadata(body: V1ObjectMeta.() -> Unit): V1ObjectMeta { + return (metadata ?: V1ObjectMeta().also { metadata = it }).apply(body) +} + +fun V1Deployment.spec(body: V1DeploymentSpec.() -> Unit): V1DeploymentSpec { + return (spec ?: V1DeploymentSpec().also { spec = it }).apply(body) +} \ No newline at end of file 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 e6323aaa..2fb5a82f 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 @@ -153,8 +153,10 @@ 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() + val instancesManager = WorkspaceInstancesManager(manager, buildManager, coroutinesScope = this) + //val deploymentsProxy = DeploymentsProxy(deploymentManager) val maxBodySize = environment.config.property("modelix.maxBodySize").getString() deploymentsProxy.startServer() @@ -191,7 +193,7 @@ fun Application.workspaceManagerModule() { } MavenControllerImpl().install(this) - WorkspacesController(manager, deploymentManager).install(this) + WorkspacesController(manager, instancesManager).install(this) modelixMavenConnectorRoutes(object : ModelixMavenConnectorController { override suspend fun getMavenConnectorConfig(call: TypedApplicationCall) { diff --git a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspacesController.kt b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspacesController.kt index ee8a2764..b92e2c8b 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspacesController.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspacesController.kt @@ -2,10 +2,11 @@ package org.modelix.workspace.manager 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 org.modelix.authorization.getUserName -import org.modelix.instancesmanager.DeploymentManager import org.modelix.services.workspaces.stubs.controllers.ModelixWorkspacesDraftsController import org.modelix.services.workspaces.stubs.controllers.ModelixWorkspacesDraftsController.Companion.modelixWorkspacesDraftsRoutes import org.modelix.services.workspaces.stubs.controllers.ModelixWorkspacesInstancesController @@ -25,13 +26,16 @@ import org.modelix.services.workspaces.stubs.models.WorkspaceInstanceList import org.modelix.services.workspaces.stubs.models.WorkspaceInstanceState import org.modelix.services.workspaces.stubs.models.WorkspaceList import org.modelix.workspaces.DEFAULT_MPS_VERSION -import org.modelix.workspaces.MavenRepository import org.modelix.workspaces.Workspace +import org.modelix.workspaces.MavenRepository +import org.modelix.workspaces.WorkspacesPermissionSchema import java.util.UUID -class WorkspacesController(val manager: WorkspaceManager, val deployments: DeploymentManager) { +class WorkspacesController( + val manager: WorkspaceManager, + val instancesManager: WorkspaceInstancesManager, +) { - private var workspaceInstances: WorkspaceInstanceList = WorkspaceInstanceList(emptyList()) private val drafts: GitChangeDraftList = GitChangeDraftList(emptyList()) fun install(route: Route) { @@ -97,7 +101,7 @@ class WorkspacesController(val manager: WorkspaceManager, val deployments: Deplo instanceId: String, call: TypedApplicationCall, ) { - val instance = workspaceInstances.instances.find { it.id == instanceId } + val instance = instancesManager.getInstancesList().instances.find { it.id == instanceId } if (instance == null) { call.respond(HttpStatusCode.NotFound) } else { @@ -106,10 +110,11 @@ class WorkspacesController(val manager: WorkspaceManager, val deployments: Deplo } override suspend fun listInstances(workspaceId: String?, call: TypedApplicationCall) { - val filteredInstances = if (workspaceId == null) { - workspaceInstances + val allInstances = instancesManager.getInstancesList() + val filteredInstances = if (workspaceId != null) { + allInstances.copy(instances = allInstances.instances.filter { it.config.id == workspaceId }) } else { - workspaceInstances.copy(instances = workspaceInstances.instances.filter { it.workspaceId == workspaceId }) + allInstances } call.respondTyped(filteredInstances) } @@ -118,15 +123,42 @@ class WorkspacesController(val manager: WorkspaceManager, val deployments: Deplo workspaceInstance: WorkspaceInstance, call: TypedApplicationCall ) { - workspaceInstances = workspaceInstances.copy( - instances = workspaceInstances.instances + workspaceInstance.copy( - id = UUID.randomUUID().toString(), - workspaceId = workspaceInstance.workspaceId, - drafts = emptyList(), - owner = call.getUserName(), - state = WorkspaceInstanceState.CREATED + 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 + } + } + } + + instancesManager.updateInstancesList { list -> + list.copy( + instances = list.instances + workspaceInstance.copy( + id = UUID.randomUUID().toString(), + drafts = emptyList(), + owner = call.getUserName(), + state = WorkspaceInstanceState.CREATED, + readonly = readonly + ) ) - ) + } + } + + override suspend fun deleteInstance( + instanceId: String, + call: ApplicationCall + ) { + instancesManager.updateInstancesList { list -> + list.copy( + instances = list.instances.filter { it.id != instanceId } + ) + } } }) @@ -136,15 +168,17 @@ class WorkspacesController(val manager: WorkspaceManager, val deployments: Deplo workspaceInstanceEnabled: WorkspaceInstanceEnabled, call: ApplicationCall ) { - workspaceInstances = workspaceInstances.copy( - instances = workspaceInstances.instances.map { - if (it.id == instanceId) { - it.copy(enabled = workspaceInstanceEnabled.enabled) - } else { - it + instancesManager.updateInstancesList { list -> + list.copy( + instances = list.instances.map { + if (it.id == instanceId) { + it.copy(enabled = workspaceInstanceEnabled.enabled) + } else { + it + } } - } - ) + ) + } call.respond(HttpStatusCode.OK) } }) @@ -167,13 +201,13 @@ class WorkspacesController(val manager: WorkspaceManager, val deployments: Deplo } }) } - - private fun Workspace.convert() = WorkspaceConfig( - id = id, - name = name ?: "", - mpsVersion = mpsVersion ?: DEFAULT_MPS_VERSION, - memoryLimit = memoryLimit, - gitRepositories = gitRepositories.map { GitRepository(it.url, null) }, - mavenRepositories = mavenRepositories.map { org.modelix.services.workspaces.stubs.models.MavenRepository(it.url) }, - ) } + +fun Workspace.convert() = WorkspaceConfig( + id = id, + name = name ?: "", + mpsVersion = mpsVersion ?: DEFAULT_MPS_VERSION, + memoryLimit = memoryLimit, + gitRepositories = gitRepositories.map { GitRepository(it.url, null) }, + mavenRepositories = mavenRepositories.map { org.modelix.services.workspaces.stubs.models.MavenRepository(it.url) }, +) From d56330851b42f79e310e4530c80789032d6dc218 Mon Sep 17 00:00:00 2001 From: Sascha Lisson Date: Sun, 4 May 2025 07:28:59 +0200 Subject: [PATCH 06/16] refactor: rename Workspace -> LegacyWorkspace --- .../kotlin/org/modelix/workspace/job/Main.kt | 4 ++-- .../org/modelix/workspace/job/MavenDownloader.kt | 4 ++-- .../modelix/workspace/job/WorkspaceBuildJob.kt | 4 ++-- .../workspace/manager/CredentialsEncryption.kt | 6 +++--- .../manager/FileSystemWorkspacePersistence.kt | 16 ++++++++-------- .../workspace/manager/WorkspaceJobQueue.kt | 4 ++-- .../workspace/manager/WorkspaceManager.kt | 12 ++++++------ .../workspace/manager/WorkspaceManagerModule.kt | 14 +++++++------- .../workspace/manager/WorkspacesController.kt | 4 ++-- 9 files changed, 34 insertions(+), 34 deletions(-) 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..cee1e651 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,7 +27,7 @@ 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.LegacyWorkspace import org.modelix.workspaces.WorkspaceBuildStatus import org.modelix.workspaces.WorkspaceHash import org.modelix.workspaces.withHash @@ -61,7 +61,7 @@ fun main(args: Array) { runBlocking { printNewJobStatus(WorkspaceBuildStatus.Running) - val workspace: Workspace = httpClient.get { + val workspace: LegacyWorkspace = httpClient.get { url { takeFrom(serverUrl) appendPathSegments(workspaceHash.hash) 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..85d6aae7 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.LegacyWorkspace import org.zeroturnaround.zip.ZipUtil import java.io.File import java.util.* -class MavenDownloader(val workspace: Workspace, val workspaceDir: File) { +class MavenDownloader(val workspace: LegacyWorkspace, 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..4f0b4ef6 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 @@ -40,7 +40,7 @@ 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.LegacyWorkspace import org.modelix.workspaces.WorkspaceAndHash import org.modelix.workspaces.WorkspaceBuildStatus import org.modelix.workspaces.WorkspaceProgressItems @@ -239,7 +239,7 @@ class WorkspaceBuildJob(val workspace: WorkspaceAndHash, val httpClient: HttpCli } } - private fun Workspace.additionalGenerationDependenciesAsMap(): Map> { + private fun LegacyWorkspace.additionalGenerationDependenciesAsMap(): Map> { return additionalGenerationDependencies .groupBy { ModuleId(it.from) } .mapValues { it.value.map { ModuleId(it.to) }.toSet() } 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..e872e136 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.LegacyWorkspace /** * @@ -46,10 +46,10 @@ class CredentialsEncryption(key: String) { } } -fun CredentialsEncryption.copyWithEncryptedCredentials(workspace: Workspace): Workspace = +fun CredentialsEncryption.copyWithEncryptedCredentials(workspace: LegacyWorkspace): LegacyWorkspace = workspace.copy(gitRepositories = workspace.gitRepositories.map(::copyWithEncryptedCredentials)) -fun CredentialsEncryption.copyWithDecryptedCredentials(workspace: Workspace): Workspace = +fun CredentialsEncryption.copyWithDecryptedCredentials(workspace: LegacyWorkspace): LegacyWorkspace = 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..98c6b5ea 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 @@ -5,7 +5,7 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.modelix.model.persistent.SerializationUtil import org.modelix.workspaces.ModelRepository -import org.modelix.workspaces.Workspace +import org.modelix.workspaces.LegacyWorkspace import org.modelix.workspaces.WorkspaceAndHash import org.modelix.workspaces.WorkspaceHash import org.modelix.workspaces.WorkspacePersistence @@ -16,8 +16,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 +41,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(): LegacyWorkspace { + val workspace = LegacyWorkspace( id = newWorkspaceId(), modelRepositories = listOf(ModelRepository(id = "default")), ) @@ -61,7 +61,7 @@ class FileSystemWorkspacePersistence(val file: File) : WorkspacePersistence { writeDBFile() } - override fun getWorkspaceForId(id: String): Workspace? { + override fun getWorkspaceForId(id: String): LegacyWorkspace? { return db.workspaces[id] } @@ -70,7 +70,7 @@ class FileSystemWorkspacePersistence(val file: File) : WorkspacePersistence { } @Synchronized - override fun update(workspace: Workspace): WorkspaceHash { + override fun update(workspace: LegacyWorkspace): 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/WorkspaceJobQueue.kt b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceJobQueue.kt index d8013df2..26125a9f 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,7 +34,7 @@ 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.workspaces.LegacyWorkspace import org.modelix.workspaces.WorkspaceAndHash import org.modelix.workspaces.WorkspaceBuildStatus import org.modelix.workspaces.WorkspaceHash @@ -42,7 +42,7 @@ import java.io.IOException import java.util.Locale import kotlin.time.Duration.Companion.seconds -class WorkspaceJobQueue(val tokenGenerator: (Workspace) -> String) { +class WorkspaceJobQueue(val tokenGenerator: (LegacyWorkspace) -> String) { private val workspaceHash2job: MutableMap = LinkedHashMap() private val coroutinesScope = CoroutineScope(Dispatchers.Default) 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..b14b05d4 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 @@ -43,7 +43,7 @@ import org.modelix.model.persistent.SerializationUtil import org.modelix.model.server.ModelServerPermissionSchema import org.modelix.workspaces.ModelServerWorkspacePersistence import org.modelix.workspaces.UploadId -import org.modelix.workspaces.Workspace +import org.modelix.workspaces.LegacyWorkspace import org.modelix.workspaces.WorkspaceHash import org.modelix.workspaces.WorkspacePersistence import org.modelix.workspaces.WorkspacesPermissionSchema @@ -63,7 +63,7 @@ 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: (LegacyWorkspace) -> String = { workspace -> jwtUtil.createAccessToken( "workspace-job@modelix.org", listOf( @@ -100,7 +100,7 @@ class WorkspaceManager(private val credentialsEncryption: CredentialsEncryption) } @Synchronized - fun update(workspace: Workspace): WorkspaceHash { + fun update(workspace: LegacyWorkspace): WorkspaceHash { val workspaceWithEncryptedCredentials = credentialsEncryption.copyWithEncryptedCredentials(workspace) val hash = workspacePersistence.update(workspaceWithEncryptedCredentials) synchronized(buildJobs) { @@ -109,7 +109,7 @@ class WorkspaceManager(private val credentialsEncryption: CredentialsEncryption) return hash } - fun getWorkspaceDirectory(workspace: Workspace) = File(directory, workspace.id) + fun getWorkspaceDirectory(workspace: LegacyWorkspace) = File(directory, workspace.id) fun newUploadFolder(): File { val existingFolders = getUploadsFolder().listFiles()?.toList() ?: emptyList() @@ -150,7 +150,7 @@ class WorkspaceManager(private val credentialsEncryption: CredentialsEncryption) fun getWorkspaceIds() = workspacePersistence.getWorkspaceIds() fun getWorkspaceForId(workspaceId: String) = workspacePersistence.getWorkspaceForId(workspaceId)?.withHash() fun getWorkspaceForHash(workspaceHash: WorkspaceHash) = workspacePersistence.getWorkspaceForHash(workspaceHash) - fun newWorkspace(owner: String?): Workspace { + fun newWorkspace(owner: String?): LegacyWorkspace { val newWorkspace = workspacePersistence.newWorkspace() if (owner != null) { accessControlPersistence.update { data -> @@ -206,7 +206,7 @@ class KestraClient(val jwtUtil: ModelixJWTUtil) { return responseObject["results"]!!.jsonArray.map { it.jsonObject["id"]!!.jsonPrimitive.content } } - suspend fun enqueueGitImport(workspace: Workspace): JsonObject { + suspend fun enqueueGitImport(workspace: LegacyWorkspace): JsonObject { val gitRepo = workspace.gitRepositories.first() updateGitImportFlow() 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 2fb5a82f..68d8db0d 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 @@ -137,7 +137,7 @@ import org.modelix.workspaces.Credentials import org.modelix.workspaces.GitRepository import org.modelix.workspaces.SharedInstance import org.modelix.workspaces.UploadId -import org.modelix.workspaces.Workspace +import org.modelix.workspaces.LegacyWorkspace import org.modelix.workspaces.WorkspaceAndHash import org.modelix.workspaces.WorkspaceBuildStatus import org.modelix.workspaces.WorkspaceHash @@ -837,7 +837,7 @@ fun Application.workspaceManagerModule() { return@post } val uncheckedWorkspaceConfig = try { - Yaml.default.decodeFromString(yamlText) + Yaml.default.decodeFromString(yamlText) } catch (e: Exception) { call.respond(HttpStatusCode.BadRequest, e.message ?: "Parse error") return@post @@ -1356,7 +1356,7 @@ suspend fun ApplicationCall.respondTarGz(body: (TarArchiveOutputStream) -> Unit) } } -fun sanitizeReceivedWorkspaceConfig(receivedWorkspaceConfig: Workspace, existingWorkspaceConfig: Workspace): Workspace = +fun sanitizeReceivedWorkspaceConfig(receivedWorkspaceConfig: LegacyWorkspace, existingWorkspaceConfig: LegacyWorkspace): LegacyWorkspace = mergeMaskedCredentialsWithPreviousCredentials(receivedWorkspaceConfig, existingWorkspaceConfig) .copy( // set ID just in case the user copy-pastes a workspace and forgets to change the ID @@ -1366,7 +1366,7 @@ fun sanitizeReceivedWorkspaceConfig(receivedWorkspaceConfig: Workspace, existing const val MASKED_CREDENTIAL_VALUE = "••••••••" -fun Workspace.maskCredentials(): Workspace { +fun LegacyWorkspace.maskCredentials(): LegacyWorkspace { val gitRepositories = this.gitRepositories.map { repository -> repository.copy( credentials = repository.credentials?.copy( @@ -1379,9 +1379,9 @@ fun Workspace.maskCredentials(): Workspace { } fun mergeMaskedCredentialsWithPreviousCredentials( - receivedWorkspaceConfig: Workspace, - existingWorkspaceConfig: Workspace, -): Workspace { + receivedWorkspaceConfig: LegacyWorkspace, + existingWorkspaceConfig: LegacyWorkspace, +): LegacyWorkspace { val gitRepositories = receivedWorkspaceConfig.gitRepositories.mapIndexed { i, receivedRepository -> // Credentials will be reused, when: // * When the URL is the same, diff --git a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspacesController.kt b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspacesController.kt index b92e2c8b..3c6081fb 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspacesController.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspacesController.kt @@ -26,7 +26,7 @@ import org.modelix.services.workspaces.stubs.models.WorkspaceInstanceList import org.modelix.services.workspaces.stubs.models.WorkspaceInstanceState import org.modelix.services.workspaces.stubs.models.WorkspaceList import org.modelix.workspaces.DEFAULT_MPS_VERSION -import org.modelix.workspaces.Workspace +import org.modelix.workspaces.LegacyWorkspace import org.modelix.workspaces.MavenRepository import org.modelix.workspaces.WorkspacesPermissionSchema import java.util.UUID @@ -203,7 +203,7 @@ class WorkspacesController( } } -fun Workspace.convert() = WorkspaceConfig( +fun LegacyWorkspace.convert() = WorkspaceConfig( id = id, name = name ?: "", mpsVersion = mpsVersion ?: DEFAULT_MPS_VERSION, From f76d2f4488e47cf1845f684eea974714273039fb Mon Sep 17 00:00:00 2001 From: Sascha Lisson Date: Mon, 5 May 2025 16:46:35 +0200 Subject: [PATCH 07/16] feat: workspace build job --- .../kotlin/org/modelix/workspace/job/Main.kt | 17 +- .../modelix/workspace/job/MavenDownloader.kt | 4 +- .../workspace/job/WorkspaceBuildJob.kt | 12 +- .../InternalWorkspaceInstanceConfig.kt | 13 + .../workspaces/KubernetesApiExtensions.kt | 158 +++++++++++ .../workspaces/WorkspaceConfigExtensions.kt | 18 ++ .../manager/CredentialsEncryption.kt | 6 +- .../manager/FileSystemWorkspacePersistence.kt | 17 +- .../modelix/workspace/manager/Reconciler.kt | 45 +++ .../workspace/manager/SharedMutableState.kt | 39 +++ .../org/modelix/workspace/manager/Task.kt | 48 ++++ .../manager/WorkspaceBuildManager.kt | 241 +++++++++++++++- .../manager/WorkspaceInstancesManager.kt | 180 ++++++------ .../workspace/manager/WorkspaceJobQueue.kt | 34 +-- .../workspace/manager/WorkspaceJobQueueUI.kt | 1 - .../workspace/manager/WorkspaceManager.kt | 14 +- .../manager/WorkspaceManagerModule.kt | 33 +-- .../workspace/manager/WorkspacesController.kt | 263 ++++++++++++++++-- .../manager/WorkspaceManagerModuleTest.kt | 32 +-- ...orkspace.kt => InternalWorkspaceConfig.kt} | 20 +- .../workspaces/WorkspacePersistence.kt | 23 +- 21 files changed, 985 insertions(+), 233 deletions(-) create mode 100644 workspace-manager/src/main/kotlin/org/modelix/services/workspaces/InternalWorkspaceInstanceConfig.kt create mode 100644 workspace-manager/src/main/kotlin/org/modelix/services/workspaces/KubernetesApiExtensions.kt create mode 100644 workspace-manager/src/main/kotlin/org/modelix/services/workspaces/WorkspaceConfigExtensions.kt create mode 100644 workspace-manager/src/main/kotlin/org/modelix/workspace/manager/Reconciler.kt create mode 100644 workspace-manager/src/main/kotlin/org/modelix/workspace/manager/SharedMutableState.kt create mode 100644 workspace-manager/src/main/kotlin/org/modelix/workspace/manager/Task.kt rename workspaces/src/main/kotlin/org/modelix/workspaces/{Workspace.kt => InternalWorkspaceConfig.kt} (84%) 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 cee1e651..5acfb7fc 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.LegacyWorkspace +import org.modelix.workspaces.InternalWorkspaceConfig import org.modelix.workspaces.WorkspaceBuildStatus -import org.modelix.workspaces.WorkspaceHash -import org.modelix.workspaces.withHash +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: LegacyWorkspace = httpClient.get { + val workspace: InternalWorkspaceConfig = 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 85d6aae7..d34fd924 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.LegacyWorkspace +import org.modelix.workspaces.InternalWorkspaceConfig import org.zeroturnaround.zip.ZipUtil import java.io.File import java.util.* -class MavenDownloader(val workspace: LegacyWorkspace, val workspaceDir: File) { +class MavenDownloader(val workspace: InternalWorkspaceConfig, 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 4f0b4ef6..5d8c87ab 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 @@ -40,7 +40,7 @@ import org.modelix.buildtools.SourceModuleOwner import org.modelix.buildtools.newChild import org.modelix.buildtools.xmlToString import org.modelix.workspaces.UploadId -import org.modelix.workspaces.LegacyWorkspace +import org.modelix.workspaces.InternalWorkspaceConfig import org.modelix.workspaces.WorkspaceAndHash import org.modelix.workspaces.WorkspaceBuildStatus import org.modelix.workspaces.WorkspaceProgressItems @@ -56,7 +56,7 @@ 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: InternalWorkspaceConfig, val httpClient: HttpClient, val serverUrl: String) { private val workspaceDir = File(".").canonicalFile val progressItems = WorkspaceProgressItems() @@ -100,7 +100,7 @@ class WorkspaceBuildJob(val workspace: WorkspaceAndHash, val httpClient: HttpCli private fun copyMavenDependencies(): List { return workspace.mavenDependencies.map { mavenDep -> LOG.info { "Resolving $mavenDep" } - MavenDownloader(workspace.workspace, workspaceDir).downloadAndCopyFromMaven(mavenDep) { println(it) } + MavenDownloader(workspace, workspaceDir).downloadAndCopyFromMaven(mavenDep) { println(it) } } } @@ -126,7 +126,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 +141,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 +239,7 @@ class WorkspaceBuildJob(val workspace: WorkspaceAndHash, val httpClient: HttpCli } } - private fun LegacyWorkspace.additionalGenerationDependenciesAsMap(): Map> { + private fun InternalWorkspaceConfig.additionalGenerationDependenciesAsMap(): Map> { return additionalGenerationDependencies .groupBy { ModuleId(it.from) } .mapValues { it.value.map { ModuleId(it.to) }.toSet() } 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..fff4690c --- /dev/null +++ b/workspace-manager/src/main/kotlin/org/modelix/services/workspaces/InternalWorkspaceInstanceConfig.kt @@ -0,0 +1,13 @@ +package org.modelix.services.workspaces + +import org.modelix.services.workspaces.stubs.models.WorkspaceInstance +import org.modelix.workspaces.DEFAULT_MPS_VERSION +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..2d30a1fb --- /dev/null +++ b/workspace-manager/src/main/kotlin/org/modelix/services/workspaces/KubernetesApiExtensions.kt @@ -0,0 +1,158 @@ +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 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..45101a33 --- /dev/null +++ b/workspace-manager/src/main/kotlin/org/modelix/services/workspaces/WorkspaceConfigExtensions.kt @@ -0,0 +1,18 @@ +package org.modelix.services.workspaces + +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 = "", + gitRepositories = gitRepositories.map { it.copy(credentials = null) }, + runConfig = null +) + +fun WorkspaceConfig.hashForBuild(): String = normalizeForBuild().hash() + +fun String.toValidImageTag() = replace("*", "") 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 e872e136..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.LegacyWorkspace +import org.modelix.workspaces.InternalWorkspaceConfig /** * @@ -46,10 +46,10 @@ class CredentialsEncryption(key: String) { } } -fun CredentialsEncryption.copyWithEncryptedCredentials(workspace: LegacyWorkspace): LegacyWorkspace = +fun CredentialsEncryption.copyWithEncryptedCredentials(workspace: InternalWorkspaceConfig): InternalWorkspaceConfig = workspace.copy(gitRepositories = workspace.gitRepositories.map(::copyWithEncryptedCredentials)) -fun CredentialsEncryption.copyWithDecryptedCredentials(workspace: LegacyWorkspace): LegacyWorkspace = +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 98c6b5ea..c91cb935 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.ModelRepository -import org.modelix.workspaces.LegacyWorkspace +import org.modelix.workspaces.InternalWorkspaceConfig 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(): LegacyWorkspace { - val workspace = LegacyWorkspace( + 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): LegacyWorkspace? { + 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: LegacyWorkspace): 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/Reconciler.kt b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/Reconciler.kt new file mode 100644 index 00000000..039b216b --- /dev/null +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/Reconciler.kt @@ -0,0 +1,45 @@ +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() +} \ No newline at end of file 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..fa84bf2b --- /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) + } + } + } +} \ No newline at end of file diff --git a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/Task.kt b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/Task.kt new file mode 100644 index 00000000..bfc07737 --- /dev/null +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/Task.kt @@ -0,0 +1,48 @@ +package org.modelix.workspace.manager + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import org.modelix.workspaces.InternalWorkspaceConfig +import java.util.UUID + +abstract class Task(val scope: CoroutineScope) { + val id: UUID = UUID.randomUUID() + private var job: Deferred? = null + protected abstract suspend fun process(): R + + @Synchronized + fun launch(): Deferred { + return job ?: scope.async { process() }.also { job = it } + } + + suspend fun waitForOutput(): R { + return launch().await() + } + + fun getState() = 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 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 index 026e0bf8..251a45da 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceBuildManager.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceBuildManager.kt @@ -1,15 +1,244 @@ package org.modelix.workspace.manager -import org.modelix.services.workspaces.stubs.models.WorkspaceConfig +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.apis.BatchV1Api +import io.kubernetes.client.openapi.models.V1Job +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.workspace.manager.WorkspaceJobQueue.Companion.HELM_PREFIX +import org.modelix.workspace.manager.WorkspaceJobQueue.Companion.JOB_IMAGE +import org.modelix.workspace.manager.WorkspaceJobQueue.Companion.KUBERNETES_NAMESPACE +import org.modelix.workspaces.DEFAULT_MPS_VERSION +import org.modelix.workspaces.InternalWorkspaceConfig +import org.modelix.workspaces.withHash +import java.util.UUID +import kotlin.coroutines.suspendCoroutine +import kotlin.time.Duration.Companion.minutes -class WorkspaceBuildManager { +private val LOG = mu.KotlinLogging.logger { } - fun getImageName(workspace: WorkspaceConfig): String { - return "modelix-workspaces/ws${workspace.id}" +class WorkspaceBuildManager( + val coroutinesScope: CoroutineScope, + val tokenGenerator: (InternalWorkspaceConfig) -> String +) { + + private val workspaceImageTasks = ReusableTasks() + + fun getOrCreateWorkspaceImageTask(workspaceConfig: InternalWorkspaceConfig): WorkspaceImageTask { + return workspaceImageTasks.getOrCreateTask(workspaceConfig.normalizeForBuild()) { + WorkspaceImageTask(workspaceConfig, tokenGenerator, coroutinesScope) + } + } + + fun getWorkspaceConfigByTaskId(taskId: UUID): InternalWorkspaceConfig? { + return workspaceImageTasks.getAll().find { it.id == taskId }?.workspaceConfig } +} - fun getImageTag(workspace: WorkspaceConfig): String { - return "TODO" +enum class TaskState { + CREATED, + ACTIVE, + CANCELLED, + COMPLETED, + UNKNOWN +} + +class WorkspaceBaseImageTask(val mpsVersion: String, scope: CoroutineScope) : Task(scope) { + private val resultImage = ImageNameAndTag( + "modelix/workspace-client-baseimage", + "${System.getenv("MPS_BASEIMAGE_VERSION")}-mps$mpsVersion" + ) + override suspend fun process(): ImageNameAndTag { + + return resultImage } +} +data class ImageNameAndTag(val name: String, val tag: String) { + override fun toString(): String = "$name:$tag" +} + +class WorkspaceImageTask( + val workspaceConfig: InternalWorkspaceConfig, + val tokenGenerator: (InternalWorkspaceConfig) -> String, + scope: CoroutineScope +) : Task(scope) { + companion object { + const val JOB_ID_LABEL = "modelix.workspace.job.id" + } + + private val resultImage = ImageNameAndTag( + "modelix-workspaces/ws${workspaceConfig.id}", + workspaceConfig.withHash().hash().toValidImageTag() + ) + + override suspend fun process(): ImageNameAndTag { + withTimeout(30.minutes) { + if (checkImageExists(resultImage)) return@withTimeout + + findJob()?.let { deleteJob(it) } + createJob() + + while (true) { + delay(1000) + + if (checkImageExists(resultImage)) break + + if (findJob() == null && !checkImageExists(resultImage)) { + throw IllegalStateException("Job finished without uploading the result image") + } + } + } + return resultImage + } + + private suspend fun createJob() { + suspendCoroutine { + val yamlString = generateJobYaml() + BatchV1Api().createNamespacedJob( + KUBERNETES_NAMESPACE, + Yaml.loadAs(yamlString, V1Job::class.java) + ).executeAsync(ContinuingCallback(it)) + } + } + + private suspend fun findJob(): V1Job? { + return suspendCoroutine { + BatchV1Api().listNamespacedJob(KUBERNETES_NAMESPACE) + .labelSelector("$JOB_ID_LABEL=${id}").executeAsync(ContinuingCallback(it)) + }.items.firstOrNull() + } + + private suspend fun deleteJob(job: V1Job) { + suspendCoroutine { + BatchV1Api().deleteNamespacedJob(job.metadata!!.name, job.metadata!!.namespace) + .executeAsync(ContinuingCallback(it)) + } + } + + @Suppress("ktlint") + fun generateJobYaml(): String { + val jobName = "wsjob-$id" + val mpsVersion = workspaceConfig.mpsVersion?.takeIf { it.isNotEmpty() } ?: DEFAULT_MPS_VERSION + + val containerMemoryBytes = Quantity.fromString(workspaceConfig.memoryLimit).number + 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" + spec: + ttlSecondsAfterFinished: 60 + activeDeadlineSeconds: 3600 + template: + spec: + activeDeadlineSeconds: 3600 + tolerations: + - key: "workspace-client" + operator: "Exists" + effect: "NoExecute" + containers: + - name: wsjob + image: $JOB_IMAGE + env: + - name: TARGET_REGISTRY + value: ${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://${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://${HELM_PREFIX}workspace-manager:28104/ + - name: INITIAL_JWT_TOKEN + value: $jwtToken + - name: BASEIMAGE_CONTEXT_URL + value: http://${HELM_PREFIX}workspace-manager:28104/baseimage/$mpsVersion/context.tar.gz + - name: BASEIMAGE_TARGET + value: ${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() + } +} + +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()}") + } + } } \ No newline at end of file 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 index ba6f46ce..672b66e8 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceInstancesManager.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceInstancesManager.kt @@ -8,21 +8,14 @@ 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.V1DeploymentSpec import io.kubernetes.client.openapi.models.V1EnvVar -import io.kubernetes.client.openapi.models.V1ObjectMeta 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.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch import mu.KotlinLogging import org.modelix.authorization.ModelixJWTUtil import org.modelix.authorization.permissions.AccessControlData @@ -30,18 +23,28 @@ import org.modelix.authorization.permissions.PermissionParts import org.modelix.instancesmanager.DeploymentManager import org.modelix.instancesmanager.InstanceName import org.modelix.model.server.ModelServerPermissionSchema +import org.modelix.services.workspaces.InternalWorkspaceInstanceConfig +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.WorkspaceConfig import org.modelix.services.workspaces.stubs.models.WorkspaceInstance import org.modelix.services.workspaces.stubs.models.WorkspaceInstanceList +import org.modelix.workspaces.InternalWorkspaceConfig import org.modelix.workspaces.WorkspaceAndHash import org.modelix.workspaces.WorkspaceHash import org.modelix.workspaces.WorkspacesPermissionSchema import java.io.File import java.util.Collections -import java.util.function.Consumer import java.util.regex.Pattern private val LOG = KotlinLogging.logger {} +data class InstancesState( + val instances: List = emptyList(), + val images: Map = emptyMap() +) + class WorkspaceInstancesManager( val workspaceManager: WorkspaceManager, val buildManager: WorkspaceBuildManager, @@ -66,34 +69,20 @@ class WorkspaceInstancesManager( private val indexWasReady: MutableSet = Collections.synchronizedSet(HashSet()) private val jwtUtil = ModelixJWTUtil().also { it.loadKeysFromEnvironment() } - private val stateChanges = Channel(capacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) - private val instanceList = SharedMutableState(WorkspaceInstanceList(emptyList())) - .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") - } - } + private val reconciler = Reconciler(coroutinesScope, InstancesState(), ::reconcile) + private val imageUpdateTasks = ReusableTasks() fun dispose() { - reconciliationJob.cancel() + reconciler.dispose() } - fun updateInstancesList(updater: (WorkspaceInstanceList) -> WorkspaceInstanceList) { - instanceList.update(updater) + fun updateInstancesList(updater: (List) -> List) { + reconciler.updateDesiredState { + it.copy(instances = updater(it.instances)) + } } - fun getInstancesList() = instanceList.getValue() + fun getInstancesList(): List = reconciler.getDesiredState().instances private fun getExistingDeployments(): Map { val existingDeployments: MutableMap = HashMap() @@ -109,10 +98,10 @@ class WorkspaceInstancesManager( return existingDeployments } - private fun reconcile(instanceList: WorkspaceInstanceList) { + private suspend fun reconcile(newState: InstancesState) { val appsApi = AppsV1Api() val coreApi = CoreV1Api() - val expectedInstances = instanceList.instances.filter { it.enabled }.associateBy { it.id } + val expectedInstances = newState.instances.filter { it.instanceConfig.enabled }.associateBy { it.instanceConfig.id } val existingInstances = getExistingDeployments() val toAdd = expectedInstances - existingInstances.keys @@ -134,9 +123,19 @@ class WorkspaceInstancesManager( } for (instance in toAdd.values) { try { - createDeployment(instance) + val workspaceConfig = instance.workspaceConfig + val image = newState.images[workspaceConfig.normalizeForBuild()] + if (image == null) { + imageUpdateTasks.getOrCreateTask(workspaceConfig.normalizeForBuild()) { + ImageUpdateTask(buildManager.getOrCreateWorkspaceImageTask(workspaceConfig)) + .also { it.launch() } + } + } else { + createDeployment(instance.instanceConfig, image) + createService(instance.instanceConfig) + } } catch (e: Exception) { - LOG.error("Failed to create deployment for workspace ${instance.config.id}", e) + LOG.error("Failed to create deployment for workspace instance ${instance.instanceConfig.id}", e) } } @@ -221,13 +220,24 @@ class WorkspaceInstancesManager( .filter { (it.involvedObject.name ?: "").contains(deploymentName) } } - fun createDeployment( + suspend fun createDeployment( workspaceInstance: WorkspaceInstance, - ) { + image: ImageNameAndTag, + ): V1Deployment { val instanceName = workspaceInstance.instanceName() val workspaceId = workspaceInstance.config.id val appsApi = AppsV1Api() + + val existingDeployment = appsApi.listNamespacedDeployment(KUBERNETES_NAMESPACE) + .labelSelector("$INSTANCE_ID_LABEL=${workspaceInstance.id}") + .timeoutSeconds(TIMEOUT_SECONDS) + .executeSuspending() + .items + .firstOrNull() + + if (existingDeployment != null) return existingDeployment + val deployment = Yaml.loadAs(File("/workspace-client-templates/deployment"), V1Deployment::class.java) deployment.metadata { name(instanceName.name) @@ -245,8 +255,7 @@ class WorkspaceInstancesManager( } } - var userId: String? = null - var hasWritePermission = workspaceInstance.readonly == false + val hasWritePermission = workspaceInstance.readonly == false val newPermissions = ArrayList() newPermissions += WorkspacesPermissionSchema.workspaces.workspace(workspaceId).config.read newPermissions += ModelServerPermissionSchema.repository("workspace_" + workspaceId) @@ -254,36 +263,40 @@ class WorkspaceInstancesManager( 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) - println("Creating deployment: ") - println(Yaml.dump(deployment)) - appsApi.createNamespacedDeployment(KUBERNETES_NAMESPACE, deployment).execute() + 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 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.spec!!.ports!!.forEach(Consumer { p: V1ServicePort -> p.nodePort(null) }) - service.metadata!!.name(instanceName.name) - service.metadata!!.putLabelsItem(INSTANCE_ID_LABEL, workspaceInstance.id) - service.spec!!.putSelectorItem(INSTANCE_ID_LABEL, workspaceInstance.id) - println("Creating service: ") - println(Yaml.dump(service)) - coreApi.createNamespacedService(KUBERNETES_NAMESPACE, service).execute() - } + val existingService = coreApi.listNamespacedService(KUBERNETES_NAMESPACE) + .labelSelector("$INSTANCE_ID_LABEL=${workspaceInstance.id}") + .timeoutSeconds(TIMEOUT_SECONDS) + .executeSuspending() + .items + .firstOrNull() + 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.name) + 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) { + private fun loadWorkspaceSpecificValues(workspaceInstance: WorkspaceInstance, deployment: V1Deployment, image: ImageNameAndTag) { try { val container = deployment.spec!!.template.spec!!.containers[0] - val imageName = buildManager.getImageName(workspaceInstance.config) - val imageTag = buildManager.getImageTag(workspaceInstance.config) - // 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}/${imageName}:${imageTag}" + container.image = "${INTERNAL_DOCKER_REGISTRY_AUTHORITY}/${image.name}:${image.tag}" val resources = container.resources ?: return val memoryLimit = Quantity.fromString(workspaceInstance.config.memoryLimit) @@ -305,48 +318,17 @@ class WorkspaceInstancesManager( private fun getAccessControlData(): AccessControlData { return workspaceManager.accessControlPersistence.read() } -} - -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) + inner class ImageUpdateTask( + val imageTask: WorkspaceImageTask, + ) : Task(coroutinesScope) { + override suspend fun process() { + val image = imageTask.waitForOutput() + reconciler.updateDesiredState { + it.copy( + images = it.images + (imageTask.workspaceConfig to image) + ) } } } } - -fun V1Deployment.metadata(body: V1ObjectMeta.() -> Unit): V1ObjectMeta { - return (metadata ?: V1ObjectMeta().also { metadata = it }).apply(body) -} - -fun V1Deployment.spec(body: V1DeploymentSpec.() -> Unit): V1DeploymentSpec { - return (spec ?: V1DeploymentSpec().also { spec = it }).apply(body) -} \ No newline at end of file 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 26125a9f..db65de2f 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,27 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import org.modelix.model.persistent.HashUtil -import org.modelix.workspaces.LegacyWorkspace +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: (LegacyWorkspace) -> String) { +class WorkspaceJobQueue(val tokenGenerator: (InternalWorkspaceConfig) -> 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 +66,7 @@ class WorkspaceJobQueue(val tokenGenerator: (LegacyWorkspace) -> 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 +81,22 @@ class WorkspaceJobQueue(val tokenGenerator: (LegacyWorkspace) -> 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) 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 index 675fd6b4..a7508009 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceJobQueueUI.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceJobQueueUI.kt @@ -1,6 +1,5 @@ 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 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 b14b05d4..44d23d48 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 @@ -43,14 +43,14 @@ import org.modelix.model.persistent.SerializationUtil import org.modelix.model.server.ModelServerPermissionSchema import org.modelix.workspaces.ModelServerWorkspacePersistence import org.modelix.workspaces.UploadId -import org.modelix.workspaces.LegacyWorkspace +import org.modelix.workspaces.InternalWorkspaceConfig import org.modelix.workspaces.WorkspaceHash import org.modelix.workspaces.WorkspacePersistence import org.modelix.workspaces.WorkspacesPermissionSchema import org.modelix.workspaces.withHash import java.io.File -class WorkspaceManager(private val credentialsEncryption: CredentialsEncryption) { +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")) @@ -63,7 +63,7 @@ class WorkspaceManager(private val credentialsEncryption: CredentialsEncryption) val workspacesDir = if (parentRepoDir != null) File(parentRepoDir.parent, "modelix-workspaces") else File("modelix-workspaces") workspacesDir.absoluteFile } - val workspaceJobTokenGenerator: (LegacyWorkspace) -> String = { workspace -> + val workspaceJobTokenGenerator: (InternalWorkspaceConfig) -> String = { workspace -> jwtUtil.createAccessToken( "workspace-job@modelix.org", listOf( @@ -100,7 +100,7 @@ class WorkspaceManager(private val credentialsEncryption: CredentialsEncryption) } @Synchronized - fun update(workspace: LegacyWorkspace): WorkspaceHash { + fun update(workspace: InternalWorkspaceConfig): WorkspaceHash { val workspaceWithEncryptedCredentials = credentialsEncryption.copyWithEncryptedCredentials(workspace) val hash = workspacePersistence.update(workspaceWithEncryptedCredentials) synchronized(buildJobs) { @@ -109,7 +109,7 @@ class WorkspaceManager(private val credentialsEncryption: CredentialsEncryption) return hash } - fun getWorkspaceDirectory(workspace: LegacyWorkspace) = File(directory, workspace.id) + fun getWorkspaceDirectory(workspace: InternalWorkspaceConfig) = File(directory, workspace.id) fun newUploadFolder(): File { val existingFolders = getUploadsFolder().listFiles()?.toList() ?: emptyList() @@ -150,7 +150,7 @@ class WorkspaceManager(private val credentialsEncryption: CredentialsEncryption) fun getWorkspaceIds() = workspacePersistence.getWorkspaceIds() fun getWorkspaceForId(workspaceId: String) = workspacePersistence.getWorkspaceForId(workspaceId)?.withHash() fun getWorkspaceForHash(workspaceHash: WorkspaceHash) = workspacePersistence.getWorkspaceForHash(workspaceHash) - fun newWorkspace(owner: String?): LegacyWorkspace { + fun newWorkspace(owner: String?): InternalWorkspaceConfig { val newWorkspace = workspacePersistence.newWorkspace() if (owner != null) { accessControlPersistence.update { data -> @@ -206,7 +206,7 @@ class KestraClient(val jwtUtil: ModelixJWTUtil) { return responseObject["results"]!!.jsonArray.map { it.jsonObject["id"]!!.jsonPrimitive.content } } - suspend fun enqueueGitImport(workspace: LegacyWorkspace): JsonObject { + suspend fun enqueueGitImport(workspace: InternalWorkspaceConfig): JsonObject { val gitRepo = workspace.gitRepositories.first() updateGitImportFlow() 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 68d8db0d..993a7b4d 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 @@ -119,9 +119,6 @@ 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.services.maven_connector.stubs.controllers.ModelixMavenConnectorController @@ -137,7 +134,7 @@ import org.modelix.workspaces.Credentials import org.modelix.workspaces.GitRepository import org.modelix.workspaces.SharedInstance import org.modelix.workspaces.UploadId -import org.modelix.workspaces.LegacyWorkspace +import org.modelix.workspaces.InternalWorkspaceConfig import org.modelix.workspaces.WorkspaceAndHash import org.modelix.workspaces.WorkspaceBuildStatus import org.modelix.workspaces.WorkspaceHash @@ -154,12 +151,12 @@ fun Application.workspaceManagerModule() { val credentialsEncryption = createCredentialEncryption() val manager = WorkspaceManager(credentialsEncryption) //val deploymentManager = DeploymentManager(manager) - val buildManager = WorkspaceBuildManager() + val buildManager = WorkspaceBuildManager(this, manager.workspaceJobTokenGenerator) val instancesManager = WorkspaceInstancesManager(manager, buildManager, coroutinesScope = this) //val deploymentsProxy = DeploymentsProxy(deploymentManager) val maxBodySize = environment.config.property("modelix.maxBodySize").getString() - deploymentsProxy.startServer() + //deploymentsProxy.startServer() install(ModelixAuthorization) { permissionSchema = WorkspacesPermissionSchema.SCHEMA @@ -188,12 +185,12 @@ fun Application.workspaceManagerModule() { routing { staticResources("static/", basePackage = "org.modelix.workspace.static") - route("instances") { - this.adminModule(deploymentManager) - } +// route("instances") { +// this.adminModule(deploymentManager) +// } MavenControllerImpl().install(this) - WorkspacesController(manager, instancesManager).install(this) + WorkspacesController(manager, instancesManager, buildManager).install(this) modelixMavenConnectorRoutes(object : ModelixMavenConnectorController { override suspend fun getMavenConnectorConfig(call: TypedApplicationCall) { @@ -837,7 +834,7 @@ fun Application.workspaceManagerModule() { return@post } val uncheckedWorkspaceConfig = try { - Yaml.default.decodeFromString(yamlText) + Yaml.default.decodeFromString(yamlText) } catch (e: Exception) { call.respond(HttpStatusCode.BadRequest, e.message ?: "Parse error") return@post @@ -1073,7 +1070,7 @@ fun Application.workspaceManagerModule() { 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 + ENV INITIAL_JWT_TOKEN=$jwtToken RUN /etc/cont-init.d/10-init-users.sh && /etc/cont-init.d/99-set-user-home.sh @@ -1356,7 +1353,7 @@ suspend fun ApplicationCall.respondTarGz(body: (TarArchiveOutputStream) -> Unit) } } -fun sanitizeReceivedWorkspaceConfig(receivedWorkspaceConfig: LegacyWorkspace, existingWorkspaceConfig: LegacyWorkspace): LegacyWorkspace = +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 @@ -1366,7 +1363,7 @@ fun sanitizeReceivedWorkspaceConfig(receivedWorkspaceConfig: LegacyWorkspace, ex const val MASKED_CREDENTIAL_VALUE = "••••••••" -fun LegacyWorkspace.maskCredentials(): LegacyWorkspace { +fun InternalWorkspaceConfig.maskCredentials(): InternalWorkspaceConfig { val gitRepositories = this.gitRepositories.map { repository -> repository.copy( credentials = repository.credentials?.copy( @@ -1379,9 +1376,9 @@ fun LegacyWorkspace.maskCredentials(): LegacyWorkspace { } fun mergeMaskedCredentialsWithPreviousCredentials( - receivedWorkspaceConfig: LegacyWorkspace, - existingWorkspaceConfig: LegacyWorkspace, -): LegacyWorkspace { + receivedWorkspaceConfig: InternalWorkspaceConfig, + existingWorkspaceConfig: InternalWorkspaceConfig, +): InternalWorkspaceConfig { val gitRepositories = receivedWorkspaceConfig.gitRepositories.mapIndexed { i, receivedRepository -> // Credentials will be reused, when: // * When the URL is the same, @@ -1442,7 +1439,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/main/kotlin/org/modelix/workspace/manager/WorkspacesController.kt b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspacesController.kt index 3c6081fb..7cef89e1 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspacesController.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspacesController.kt @@ -6,34 +6,51 @@ 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.NoPermissionException +import org.modelix.authorization.checkPermission +import org.modelix.authorization.getUnverifiedJwt import org.modelix.authorization.getUserName +import org.modelix.services.workspaces.InternalWorkspaceInstanceConfig import org.modelix.services.workspaces.stubs.controllers.ModelixWorkspacesDraftsController import org.modelix.services.workspaces.stubs.controllers.ModelixWorkspacesDraftsController.Companion.modelixWorkspacesDraftsRoutes 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.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.GitChangeDraft import org.modelix.services.workspaces.stubs.models.GitChangeDraftList +import org.modelix.services.workspaces.stubs.models.GitCredentials import org.modelix.services.workspaces.stubs.models.GitRepository +import org.modelix.services.workspaces.stubs.models.MavenArtifact 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.WorkspaceList +import org.modelix.workspace.manager.WorkspaceJobQueue.Companion.HELM_PREFIX +import org.modelix.workspaces.Credentials import org.modelix.workspaces.DEFAULT_MPS_VERSION -import org.modelix.workspaces.LegacyWorkspace +import org.modelix.workspaces.GenerationDependency +import org.modelix.workspaces.InternalWorkspaceConfig import org.modelix.workspaces.MavenRepository +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, ) { private val drafts: GitChangeDraftList = GitChangeDraftList(emptyList()) @@ -101,22 +118,22 @@ class WorkspacesController( instanceId: String, call: TypedApplicationCall, ) { - val instance = instancesManager.getInstancesList().instances.find { it.id == instanceId } + val instance = instancesManager.getInstancesList().find { it.instanceConfig.id == instanceId } if (instance == null) { call.respond(HttpStatusCode.NotFound) } else { - call.respondTyped(instance) + call.respondTyped(instance.instanceConfig) } } override suspend fun listInstances(workspaceId: String?, call: TypedApplicationCall) { val allInstances = instancesManager.getInstancesList() val filteredInstances = if (workspaceId != null) { - allInstances.copy(instances = allInstances.instances.filter { it.config.id == workspaceId }) + allInstances.filter { it.workspaceConfig.id == workspaceId } } else { allInstances } - call.respondTyped(filteredInstances) + call.respondTyped(WorkspaceInstanceList(instances = filteredInstances.map { it.instanceConfig })) } override suspend fun createInstance( @@ -137,15 +154,22 @@ class WorkspacesController( } } + val workspaceConfig = manager.getWorkspaceForId(workspaceInstance.config.id)?.workspace + if (workspaceConfig == null) { + call.respond(HttpStatusCode.NotFound, "Workspace ${workspaceInstance.config.id} not found") + return + } + instancesManager.updateInstancesList { list -> - list.copy( - instances = list.instances + workspaceInstance.copy( + list.filter { it.instanceId != workspaceInstance.id } + InternalWorkspaceInstanceConfig( + instanceConfig = workspaceInstance.copy( id = UUID.randomUUID().toString(), drafts = emptyList(), owner = call.getUserName(), state = WorkspaceInstanceState.CREATED, readonly = readonly - ) + ), + workspaceConfig = workspaceConfig.merge(workspaceInstance.config) ) } } @@ -155,9 +179,7 @@ class WorkspacesController( call: ApplicationCall ) { instancesManager.updateInstancesList { list -> - list.copy( - instances = list.instances.filter { it.id != instanceId } - ) + list.filter { it.instanceId != instanceId } } } }) @@ -169,15 +191,17 @@ class WorkspacesController( call: ApplicationCall ) { instancesManager.updateInstancesList { list -> - list.copy( - instances = list.instances.map { - if (it.id == instanceId) { - it.copy(enabled = workspaceInstanceEnabled.enabled) - } else { - it - } + list.map { + if (it.instanceId == instanceId) { + it.copy( + instanceConfig = it.instanceConfig.copy( + enabled = workspaceInstanceEnabled.enabled + ) + ) + } else { + it } - ) + } } call.respond(HttpStatusCode.OK) } @@ -200,14 +224,213 @@ class WorkspacesController( call.respondTyped(drafts) } }) + + 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) + } + } + }) + } + + + private suspend fun respondBuildContext(call: ApplicationCall, workspace: InternalWorkspaceConfig, 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 ?: DEFAULT_MPS_VERSION + val jwtToken = manager.workspaceJobTokenGenerator(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_task_id=${taskId} + 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 { + manager.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()) + } } } -fun LegacyWorkspace.convert() = WorkspaceConfig( + +fun InternalWorkspaceConfig.convert() = WorkspaceConfig( id = id, name = name ?: "", mpsVersion = mpsVersion ?: DEFAULT_MPS_VERSION, memoryLimit = memoryLimit, gitRepositories = gitRepositories.map { GitRepository(it.url, null) }, mavenRepositories = mavenRepositories.map { org.modelix.services.workspaces.stubs.models.MavenRepository(it.url) }, + mavenArtifacts = mavenDependencies.map { + val parts = it.split(":") + MavenArtifact( + groupId = parts[0], + artifactId = parts[1], + version = parts.getOrNull(2) + ) + } +) + +fun WorkspaceConfig.convert() = InternalWorkspaceConfig( + id = id, + name = name, + mpsVersion = mpsVersion, + memoryLimit = memoryLimit, + gitRepositories = gitRepositories.map { org.modelix.workspaces.GitRepository(it.url, null) }, + mavenRepositories = mavenRepositories?.map { MavenRepository(it.url) } ?: emptyList(), + mavenDependencies = mavenArtifacts?.map { "${it.groupId}:${it.artifactId}:${it.version ?: "*"}" } ?: emptyList(), +) + +fun InternalWorkspaceConfig.merge(other: WorkspaceConfig) = copy( + name = other.name.takeIf { it.isNotEmpty() } ?: name, + mpsVersion = other.mpsVersion.takeIf { it.isNotEmpty() } ?: mpsVersion, + memoryLimit = other.memoryLimit.takeIf { it.isNotEmpty() } ?: memoryLimit, + gitRepositories = gitRepositories.merge(other.gitRepositories), + mavenRepositories = (mavenRepositories.map { it.url } + other.mavenRepositories.orEmpty().map { it.url }).distinct().map { MavenRepository(it) }, + mavenDependencies = (mavenDependencies + (other.mavenArtifacts ?: emptyList()).map { "${it.groupId}:${it.artifactId}:${it.version ?: "*"}" }).distinct(), + ignoredModules = (ignoredModules + other.buildConfig?.ignoredModules.orEmpty()).distinct(), + additionalGenerationDependencies = (additionalGenerationDependencies + other.buildConfig?.additionalGenerationDependencies.orEmpty().map { it.convert() }).distinct(), + loadUsedModulesOnly = other.runConfig?.loadUsedModulesOnly ?: loadUsedModulesOnly, +) + +fun List.merge(other: List): List { + val oldEntries = associateBy { it.url } + val newEntries = other.associateBy { it.url } + + return (oldEntries.keys + newEntries.keys).map { url -> + val oldEntry = oldEntries[url] + val newEntry = newEntries[url] + org.modelix.workspaces.GitRepository( + url = url, + name = oldEntry?.name, + branch = oldEntry?.branch ?: "master", + commitHash = oldEntry?.commitHash, + paths = oldEntry?.paths ?: emptyList(), + credentials = newEntry?.credentials?.convert() ?: oldEntry?.credentials + ) + } +} + +fun GitCredentials.convert() = Credentials( + user = username, + password = password +) + +fun org.modelix.services.workspaces.stubs.models.GenerationDependency.convert() = GenerationDependency( + from = from, + to = to ) 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 84% rename from workspaces/src/main/kotlin/org/modelix/workspaces/Workspace.kt rename to workspaces/src/main/kotlin/org/modelix/workspaces/InternalWorkspaceConfig.kt index 7c1ed3d5..816739e2 100644 --- a/workspaces/src/main/kotlin/org/modelix/workspaces/Workspace.kt +++ b/workspaces/src/main/kotlin/org/modelix/workspaces/InternalWorkspaceConfig.kt @@ -22,7 +22,7 @@ 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, @@ -42,6 +42,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 +62,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 +84,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/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'." From 11389ea9a8ba8cffad64c6d574fff3ba063846fc Mon Sep 17 00:00:00 2001 From: Sascha Lisson Date: Tue, 6 May 2025 11:32:02 +0200 Subject: [PATCH 08/16] feat: workspace instance state --- .../workspace/job/WorkspaceBuildJob.kt | 3 +- .../instancesmanager/AssignmentData.kt | 2 +- .../InternalWorkspaceInstanceConfig.kt | 1 - .../workspaces/KubernetesApiExtensions.kt | 4 +- .../workspaces/WorkspaceConfigExtensions.kt | 2 +- .../manager/FileSystemWorkspacePersistence.kt | 2 +- .../modelix/workspace/manager/Reconciler.kt | 9 +- .../workspace/manager/SharedMutableState.kt | 2 +- .../manager/{Task.kt => TaskInstance.kt} | 14 +- .../manager/WorkspaceBuildManager.kt | 39 +++-- .../manager/WorkspaceInstancesManager.kt | 144 ++++++++++++------ .../workspace/manager/WorkspaceManager.kt | 2 +- .../manager/WorkspaceManagerModule.kt | 8 +- .../workspace/manager/WorkspacesController.kt | 62 +++++--- .../workspaces/InternalWorkspaceConfig.kt | 2 +- 15 files changed, 199 insertions(+), 97 deletions(-) rename workspace-manager/src/main/kotlin/org/modelix/workspace/manager/{Task.kt => TaskInstance.kt} (74%) 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 5d8c87ab..a747eae8 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 @@ -39,9 +39,8 @@ 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.InternalWorkspaceConfig -import org.modelix.workspaces.WorkspaceAndHash +import org.modelix.workspaces.UploadId import org.modelix.workspaces.WorkspaceBuildStatus import org.modelix.workspaces.WorkspaceProgressItems import org.w3c.dom.Document diff --git a/workspace-manager/src/main/kotlin/org/modelix/instancesmanager/AssignmentData.kt b/workspace-manager/src/main/kotlin/org/modelix/instancesmanager/AssignmentData.kt index 9e98acf2..80af0b99 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/instancesmanager/AssignmentData.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/instancesmanager/AssignmentData.kt @@ -19,5 +19,5 @@ class AssignmentData( val workspace: WorkspaceAndHash, val unassignedInstances: Int, val instances: List, - val isLatest: Boolean + val isLatest: Boolean, ) 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 index fff4690c..81806765 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/services/workspaces/InternalWorkspaceInstanceConfig.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/services/workspaces/InternalWorkspaceInstanceConfig.kt @@ -1,7 +1,6 @@ package org.modelix.services.workspaces import org.modelix.services.workspaces.stubs.models.WorkspaceInstance -import org.modelix.workspaces.DEFAULT_MPS_VERSION import org.modelix.workspaces.InternalWorkspaceConfig data class InternalWorkspaceInstanceConfig( 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 index 2d30a1fb..8fe2ce7a 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/services/workspaces/KubernetesApiExtensions.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/services/workspaces/KubernetesApiExtensions.kt @@ -136,7 +136,7 @@ class ContinuingCallback(val continuation: Continuation) : ApiCallback override fun onFailure( ex: ApiException, p1: Int, - p2: Map?>? + p2: Map?>?, ) { continuation.resumeWith(Result.failure(ex)) } @@ -144,7 +144,7 @@ class ContinuingCallback(val continuation: Continuation) : ApiCallback override fun onSuccess( returnedValue: T, p1: Int, - p2: Map?>? + p2: Map?>?, ) { continuation.resumeWith(Result.success(returnedValue)) } 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 index 45101a33..e0ce5395 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/services/workspaces/WorkspaceConfigExtensions.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/services/workspaces/WorkspaceConfigExtensions.kt @@ -10,7 +10,7 @@ fun WorkspaceConfig.normalizeForBuild() = copy( name = "", memoryLimit = "", gitRepositories = gitRepositories.map { it.copy(credentials = null) }, - runConfig = null + runConfig = null, ) fun WorkspaceConfig.hashForBuild(): String = normalizeForBuild().hash() 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 c91cb935..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 @@ -3,8 +3,8 @@ package org.modelix.workspace.manager import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import org.modelix.model.persistent.SerializationUtil -import org.modelix.workspaces.ModelRepository import org.modelix.workspaces.InternalWorkspaceConfig +import org.modelix.workspaces.ModelRepository import org.modelix.workspaces.WorkspaceAndHash import org.modelix.workspaces.WorkspaceHash import org.modelix.workspaces.WorkspacePersistence 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 index 039b216b..a454afc7 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/Reconciler.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/Reconciler.kt @@ -42,4 +42,11 @@ class Reconciler(val coroutinesScope: CoroutineScope, initialState: E, reconc } fun getDesiredState(): E = desiredState.getValue() -} \ No newline at end of file + + 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 index fa84bf2b..58d27d82 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/SharedMutableState.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/SharedMutableState.kt @@ -36,4 +36,4 @@ class SharedMutableState(initialValue: E) { } } } -} \ No newline at end of file +} diff --git a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/Task.kt b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/TaskInstance.kt similarity index 74% rename from workspace-manager/src/main/kotlin/org/modelix/workspace/manager/Task.kt rename to workspace-manager/src/main/kotlin/org/modelix/workspace/manager/TaskInstance.kt index bfc07737..1fcf373d 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/Task.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/TaskInstance.kt @@ -3,19 +3,23 @@ package org.modelix.workspace.manager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred import kotlinx.coroutines.async -import org.modelix.workspaces.InternalWorkspaceConfig import java.util.UUID -abstract class Task(val scope: CoroutineScope) { +abstract class TaskInstance(val scope: CoroutineScope) { val id: UUID = UUID.randomUUID() private var job: Deferred? = null + private var result: Result? = null protected abstract suspend fun process(): R @Synchronized fun launch(): Deferred { - return job ?: scope.async { process() }.also { job = it } + return job ?: scope.async { + runCatching { process() }.also { result = it }.getOrThrow() + }.also { job = it } } + fun getOutput(): Result? = result + suspend fun waitForOutput(): R { return launch().await() } @@ -31,7 +35,7 @@ abstract class Task(val scope: CoroutineScope) { } } -class ReusableTasks> { +class ReusableTasks> { private val tasks = LinkedHashMap() fun getOrCreateTask(key: K, factory: (K) -> V): V { @@ -44,5 +48,7 @@ class ReusableTasks> { } } + 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 index 251a45da..401f1352 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceBuildManager.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceBuildManager.kt @@ -29,7 +29,7 @@ private val LOG = mu.KotlinLogging.logger { } class WorkspaceBuildManager( val coroutinesScope: CoroutineScope, - val tokenGenerator: (InternalWorkspaceConfig) -> String + val tokenGenerator: (InternalWorkspaceConfig) -> String, ) { private val workspaceImageTasks = ReusableTasks() @@ -50,16 +50,15 @@ enum class TaskState { ACTIVE, CANCELLED, COMPLETED, - UNKNOWN + UNKNOWN, } -class WorkspaceBaseImageTask(val mpsVersion: String, scope: CoroutineScope) : Task(scope) { +class WorkspaceBaseImageTask(val mpsVersion: String, scope: CoroutineScope) : TaskInstance(scope) { private val resultImage = ImageNameAndTag( "modelix/workspace-client-baseimage", - "${System.getenv("MPS_BASEIMAGE_VERSION")}-mps$mpsVersion" + "${System.getenv("MPS_BASEIMAGE_VERSION")}-mps$mpsVersion", ) override suspend fun process(): ImageNameAndTag { - return resultImage } } @@ -71,15 +70,15 @@ data class ImageNameAndTag(val name: String, val tag: String) { class WorkspaceImageTask( val workspaceConfig: InternalWorkspaceConfig, val tokenGenerator: (InternalWorkspaceConfig) -> String, - scope: CoroutineScope -) : Task(scope) { + scope: CoroutineScope, +) : TaskInstance(scope) { companion object { const val JOB_ID_LABEL = "modelix.workspace.job.id" } private val resultImage = ImageNameAndTag( "modelix-workspaces/ws${workspaceConfig.id}", - workspaceConfig.withHash().hash().toValidImageTag() + workspaceConfig.withHash().hash().toValidImageTag(), ) override suspend fun process(): ImageNameAndTag { @@ -89,12 +88,18 @@ class WorkspaceImageTask( findJob()?.let { deleteJob(it) } createJob() + var jobFailureConfirmations = 0 while (true) { delay(1000) if (checkImageExists(resultImage)) break - if (findJob() == null && !checkImageExists(resultImage)) { + if (findJob() == null) { + jobFailureConfirmations++ + } else { + jobFailureConfirmations = 0 + } + if (jobFailureConfirmations > 10 && !checkImageExists(resultImage)) { throw IllegalStateException("Job finished without uploading the result image") } } @@ -107,16 +112,17 @@ class WorkspaceImageTask( val yamlString = generateJobYaml() BatchV1Api().createNamespacedJob( KUBERNETES_NAMESPACE, - Yaml.loadAs(yamlString, V1Job::class.java) + Yaml.loadAs(yamlString, V1Job::class.java), ).executeAsync(ContinuingCallback(it)) } } private suspend fun findJob(): V1Job? { - return suspendCoroutine { + val jobs = suspendCoroutine { BatchV1Api().listNamespacedJob(KUBERNETES_NAMESPACE) - .labelSelector("$JOB_ID_LABEL=${id}").executeAsync(ContinuingCallback(it)) - }.items.firstOrNull() + .executeAsync(ContinuingCallback(it)) + } + return jobs.items.firstOrNull { it.metadata.labels?.get(JOB_ID_LABEL) == id.toString() } } private suspend fun deleteJob(job: V1Job) { @@ -148,10 +154,15 @@ class WorkspaceImageTask( 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: @@ -241,4 +252,4 @@ private suspend fun checkImageExists(image: ImageNameAndTag): Boolean { throw IllegalStateException("Unexpected response: ${response.status}\n${response.bodyAsText()}") } } -} \ No newline at end of file +} 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 index 672b66e8..c80ced8b 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceInstancesManager.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceInstancesManager.kt @@ -16,45 +16,68 @@ 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.instancesmanager.DeploymentManager import org.modelix.instancesmanager.InstanceName import org.modelix.model.server.ModelServerPermissionSchema +import org.modelix.services.workspaces.ContinuingCallback import org.modelix.services.workspaces.InternalWorkspaceInstanceConfig 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.WorkspaceConfig import org.modelix.services.workspaces.stubs.models.WorkspaceInstance -import org.modelix.services.workspaces.stubs.models.WorkspaceInstanceList -import org.modelix.workspaces.InternalWorkspaceConfig +import org.modelix.services.workspaces.stubs.models.WorkspaceInstanceState import org.modelix.workspaces.WorkspaceAndHash import org.modelix.workspaces.WorkspaceHash import org.modelix.workspaces.WorkspacesPermissionSchema import java.io.File import java.util.Collections -import java.util.regex.Pattern +import kotlin.coroutines.suspendCoroutine private val LOG = KotlinLogging.logger {} -data class InstancesState( +private data class InstancesManagerState( val instances: List = emptyList(), - val images: Map = emptyMap() ) +class WorkspaceInstanceStateValues( + var imageTaskState: TaskState? = null, + var image: Result? = null, + var deployment: V1Deployment? = null, + var pod: V1Pod? = null, +) { + fun deriveState(): WorkspaceInstanceState { + return when { + (deployment?.status?.readyReplicas ?: 0) >= 1 -> WorkspaceInstanceState.RUNNING + deployment != null -> WorkspaceInstanceState.LAUNCHING + image?.isFailure == true -> WorkspaceInstanceState.BUILD_FAILED + image?.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 + } + } + } +} + class WorkspaceInstancesManager( val workspaceManager: WorkspaceManager, val buildManager: WorkspaceBuildManager, - val coroutinesScope: CoroutineScope = CoroutineScope(Dispatchers.Default) + 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 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")) const val TIMEOUT_SECONDS = 10 const val INSTANCE_ID_LABEL = "modelix.workspace.instance.id" @@ -69,11 +92,17 @@ class WorkspaceInstancesManager( private val indexWasReady: MutableSet = Collections.synchronizedSet(HashSet()) private val jwtUtil = ModelixJWTUtil().also { it.loadKeysFromEnvironment() } - private val reconciler = Reconciler(coroutinesScope, InstancesState(), ::reconcile) - private val imageUpdateTasks = ReusableTasks() + 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 updateInstancesList(updater: (List) -> List) { @@ -84,13 +113,40 @@ class WorkspaceInstancesManager( fun getInstancesList(): List = reconciler.getDesiredState().instances - private fun getExistingDeployments(): Map { + suspend fun getInstanceStates(): Map { + val managerState = reconciler.getDesiredState() + + val instances: Map = + managerState.instances.associateBy { it.instanceId } + 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 imageTask = buildManager.getOrCreateWorkspaceImageTask(config.workspaceConfig.normalizeForBuild()) + val values = stateValues[config.instanceId] ?: continue + values.imageTaskState = imageTask.getState() + values.image = imageTask.getOutput() + } + + return stateValues + } + + private suspend fun getExistingDeployments(): Map { val existingDeployments: MutableMap = HashMap() val appsApi = AppsV1Api() - val deployments = appsApi - .listNamespacedDeployment(KUBERNETES_NAMESPACE) - .timeoutSeconds(TIMEOUT_SECONDS) - .execute() + 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 @@ -98,7 +154,23 @@ class WorkspaceInstancesManager( return existingDeployments } - private suspend fun reconcile(newState: InstancesState) { + 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.instanceConfig.enabled }.associateBy { it.instanceConfig.id } @@ -109,13 +181,13 @@ class WorkspaceInstancesManager( for (deployment in toRemove.values) { val name = deployment.metadata.name try { - appsApi.deleteNamespacedDeployment(name, DeploymentManager.Companion.KUBERNETES_NAMESPACE) + appsApi.deleteNamespacedDeployment(name, KUBERNETES_NAMESPACE) .execute() } catch (e: Exception) { LOG.error("Failed to delete deployment $deployment", e) } try { - coreApi.deleteNamespacedService(name, DeploymentManager.Companion.KUBERNETES_NAMESPACE) + coreApi.deleteNamespacedService(name, KUBERNETES_NAMESPACE) .execute() } catch (e: Exception) { LOG.error("Failed to delete service $deployment", e) @@ -124,13 +196,10 @@ class WorkspaceInstancesManager( for (instance in toAdd.values) { try { val workspaceConfig = instance.workspaceConfig - val image = newState.images[workspaceConfig.normalizeForBuild()] - if (image == null) { - imageUpdateTasks.getOrCreateTask(workspaceConfig.normalizeForBuild()) { - ImageUpdateTask(buildManager.getOrCreateWorkspaceImageTask(workspaceConfig)) - .also { it.launch() } - } - } else { + buildManager.getOrCreateWorkspaceImageTask(workspaceConfig.normalizeForBuild()) + val imageTask = buildManager.getOrCreateWorkspaceImageTask(workspaceConfig.normalizeForBuild()).also { it.launch() } + val image = imageTask.getOutput()?.getOrNull() + if (image != null) { createDeployment(instance.instanceConfig, image) createService(instance.instanceConfig) } @@ -230,11 +299,10 @@ class WorkspaceInstancesManager( val appsApi = AppsV1Api() val existingDeployment = appsApi.listNamespacedDeployment(KUBERNETES_NAMESPACE) - .labelSelector("$INSTANCE_ID_LABEL=${workspaceInstance.id}") .timeoutSeconds(TIMEOUT_SECONDS) .executeSuspending() .items - .firstOrNull() + .firstOrNull { it.metadata.labels?.get(INSTANCE_ID_LABEL) == workspaceInstance.id } if (existingDeployment != null) return existingDeployment @@ -250,7 +318,7 @@ class WorkspaceInstancesManager( template.spec!!.containers[0].apply { addEnvItem(V1EnvVar().name("modelix_workspace_id").value(workspaceId)) addEnvItem(V1EnvVar().name("REPOSITORY_ID").value("workspace_$workspaceId")) - //addEnvItem(V1EnvVar().name("modelix_workspace_hash").value(workspace.hash().hash)) + // addEnvItem(V1EnvVar().name("modelix_workspace_hash").value(workspace.hash().hash)) addEnvItem(V1EnvVar().name("WORKSPACE_MODEL_SYNC_ENABLED").value(false.toString())) } } @@ -273,11 +341,10 @@ class WorkspaceInstancesManager( val instanceName = workspaceInstance.instanceName() val coreApi = CoreV1Api() val existingService = coreApi.listNamespacedService(KUBERNETES_NAMESPACE) - .labelSelector("$INSTANCE_ID_LABEL=${workspaceInstance.id}") .timeoutSeconds(TIMEOUT_SECONDS) .executeSuspending() .items - .firstOrNull() + .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) @@ -318,17 +385,4 @@ class WorkspaceInstancesManager( private fun getAccessControlData(): AccessControlData { return workspaceManager.accessControlPersistence.read() } - - inner class ImageUpdateTask( - val imageTask: WorkspaceImageTask, - ) : Task(coroutinesScope) { - override suspend fun process() { - val image = imageTask.waitForOutput() - reconciler.updateDesiredState { - it.copy( - images = it.images + (imageTask.workspaceConfig to image) - ) - } - } - } } 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 44d23d48..536fe27b 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 @@ -41,9 +41,9 @@ 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.InternalWorkspaceConfig import org.modelix.workspaces.ModelServerWorkspacePersistence import org.modelix.workspaces.UploadId -import org.modelix.workspaces.InternalWorkspaceConfig import org.modelix.workspaces.WorkspaceHash import org.modelix.workspaces.WorkspacePersistence import org.modelix.workspaces.WorkspacesPermissionSchema 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 993a7b4d..40819c14 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 @@ -132,9 +132,9 @@ import org.modelix.services.maven_connector.stubs.models.MavenRepositoryList import org.modelix.workspace.manager.WorkspaceJobQueue.Companion.HELM_PREFIX 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.InternalWorkspaceConfig import org.modelix.workspaces.WorkspaceAndHash import org.modelix.workspaces.WorkspaceBuildStatus import org.modelix.workspaces.WorkspaceHash @@ -150,13 +150,13 @@ import java.util.zip.ZipOutputStream fun Application.workspaceManagerModule() { val credentialsEncryption = createCredentialEncryption() val manager = WorkspaceManager(credentialsEncryption) - //val deploymentManager = DeploymentManager(manager) + // val deploymentManager = DeploymentManager(manager) val buildManager = WorkspaceBuildManager(this, manager.workspaceJobTokenGenerator) val instancesManager = WorkspaceInstancesManager(manager, buildManager, coroutinesScope = this) - //val deploymentsProxy = DeploymentsProxy(deploymentManager) + // val deploymentsProxy = DeploymentsProxy(deploymentManager) val maxBodySize = environment.config.property("modelix.maxBodySize").getString() - //deploymentsProxy.startServer() + // deploymentsProxy.startServer() install(ModelixAuthorization) { permissionSchema = WorkspacesPermissionSchema.SCHEMA diff --git a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspacesController.kt b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspacesController.kt index 7cef89e1..293df8b3 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspacesController.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspacesController.kt @@ -19,6 +19,8 @@ import org.modelix.services.workspaces.stubs.controllers.ModelixWorkspacesInstan 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 @@ -36,6 +38,7 @@ 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.WorkspaceJobQueue.Companion.HELM_PREFIX import org.modelix.workspaces.Credentials @@ -106,7 +109,7 @@ class WorkspacesController( }, mavenRepositories = (legacyWorkspaceConfig.mavenRepositories ?: emptyList()).map { MavenRepository(it.url) - } + }, ), ) call.respond(HttpStatusCode.OK) @@ -133,12 +136,19 @@ class WorkspacesController( } else { allInstances } - call.respondTyped(WorkspaceInstanceList(instances = filteredInstances.map { it.instanceConfig })) + val states = instancesManager.getInstanceStates() + call.respondTyped( + WorkspaceInstanceList( + instances = filteredInstances.map { + it.instanceConfig.copy(state = states[it.instanceConfig.id]?.deriveState() ?: WorkspaceInstanceState.UNKNOWN) + }, + ), + ) } override suspend fun createInstance( workspaceInstance: WorkspaceInstance, - call: TypedApplicationCall + call: TypedApplicationCall, ) { var readonly = workspaceInstance.readonly ?: false if (readonly == false) { @@ -167,16 +177,16 @@ class WorkspacesController( drafts = emptyList(), owner = call.getUserName(), state = WorkspaceInstanceState.CREATED, - readonly = readonly + readonly = readonly, ), - workspaceConfig = workspaceConfig.merge(workspaceInstance.config) + workspaceConfig = workspaceConfig.merge(workspaceInstance.config), ) } } override suspend fun deleteInstance( instanceId: String, - call: ApplicationCall + call: ApplicationCall, ) { instancesManager.updateInstancesList { list -> list.filter { it.instanceId != instanceId } @@ -188,15 +198,15 @@ class WorkspacesController( override suspend fun enableInstance( instanceId: String, workspaceInstanceEnabled: WorkspaceInstanceEnabled, - call: ApplicationCall + call: ApplicationCall, ) { instancesManager.updateInstancesList { list -> list.map { if (it.instanceId == instanceId) { it.copy( instanceConfig = it.instanceConfig.copy( - enabled = workspaceInstanceEnabled.enabled - ) + enabled = workspaceInstanceEnabled.enabled, + ), ) } else { it @@ -228,7 +238,7 @@ class WorkspacesController( modelixWorkspacesTasksConfigRoutes(object : ModelixWorkspacesTasksConfigController { override suspend fun getWorkspaceByTaskId( taskId: String, - call: TypedApplicationCall + call: TypedApplicationCall, ) { val config = buildManager.getWorkspaceConfigByTaskId(UUID.fromString(taskId)) if (config == null) { @@ -242,7 +252,7 @@ class WorkspacesController( modelixWorkspacesTasksContextTarGzRoutes(object : ModelixWorkspacesTasksContextTarGzController { override suspend fun getContextForTaskId( taskId: String, - call: TypedApplicationCall + call: TypedApplicationCall, ) { val taskUUID = UUID.fromString(taskId) val config = buildManager.getWorkspaceConfigByTaskId(taskUUID) @@ -253,8 +263,25 @@ class WorkspacesController( } } }) - } + 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: InternalWorkspaceConfig, taskId: UUID) { val httpProxy: String? = System.getenv("MODELIX_HTTP_PROXY")?.takeIf { it.isNotEmpty() } @@ -367,7 +394,6 @@ class WorkspacesController( } } - fun InternalWorkspaceConfig.convert() = WorkspaceConfig( id = id, name = name ?: "", @@ -380,9 +406,9 @@ fun InternalWorkspaceConfig.convert() = WorkspaceConfig( MavenArtifact( groupId = parts[0], artifactId = parts[1], - version = parts.getOrNull(2) + version = parts.getOrNull(2), ) - } + }, ) fun WorkspaceConfig.convert() = InternalWorkspaceConfig( @@ -420,17 +446,17 @@ fun List.merge(other: List) branch = oldEntry?.branch ?: "master", commitHash = oldEntry?.commitHash, paths = oldEntry?.paths ?: emptyList(), - credentials = newEntry?.credentials?.convert() ?: oldEntry?.credentials + credentials = newEntry?.credentials?.convert() ?: oldEntry?.credentials, ) } } fun GitCredentials.convert() = Credentials( user = username, - password = password + password = password, ) fun org.modelix.services.workspaces.stubs.models.GenerationDependency.convert() = GenerationDependency( from = from, - to = to + to = to, ) diff --git a/workspaces/src/main/kotlin/org/modelix/workspaces/InternalWorkspaceConfig.kt b/workspaces/src/main/kotlin/org/modelix/workspaces/InternalWorkspaceConfig.kt index 816739e2..d3159feb 100644 --- a/workspaces/src/main/kotlin/org/modelix/workspaces/InternalWorkspaceConfig.kt +++ b/workspaces/src/main/kotlin/org/modelix/workspaces/InternalWorkspaceConfig.kt @@ -52,7 +52,7 @@ data class InternalWorkspaceConfig( loadUsedModulesOnly = false, sharedInstances = emptyList(), waitForIndexer = true, - modelSyncEnabled = false + modelSyncEnabled = false, ) } From a5cc4edd6a819f0be7ef37452fdd9c1d36bab170 Mon Sep 17 00:00:00 2001 From: Sascha Lisson Date: Wed, 7 May 2025 08:51:59 +0200 Subject: [PATCH 09/16] feat: proxy to workspace instance --- gradle/libs.versions.toml | 2 +- .../modelix/instancesmanager/AdminModule.kt | 262 -------- .../instancesmanager/AssignmentData.kt | 23 - .../instancesmanager/DeploymentManager.kt | 633 ------------------ .../DeploymentManagingHandler.kt | 149 ----- .../instancesmanager/DeploymentTimeouts.kt | 42 -- .../instancesmanager/DeploymentsProxy.kt | 81 +-- .../instancesmanager/InstanceStatus.kt | 20 - .../modelix/instancesmanager/RedirectedURL.kt | 62 +- .../manager/WorkspaceInstancesManager.kt | 26 +- .../manager/WorkspaceManagerModule.kt | 5 +- .../instancesmanager/RedirectedURLTest.kt | 42 -- 12 files changed, 59 insertions(+), 1288 deletions(-) delete mode 100644 workspace-manager/src/main/kotlin/org/modelix/instancesmanager/AdminModule.kt delete mode 100644 workspace-manager/src/main/kotlin/org/modelix/instancesmanager/AssignmentData.kt delete mode 100644 workspace-manager/src/main/kotlin/org/modelix/instancesmanager/DeploymentManager.kt delete mode 100644 workspace-manager/src/main/kotlin/org/modelix/instancesmanager/DeploymentManagingHandler.kt delete mode 100644 workspace-manager/src/main/kotlin/org/modelix/instancesmanager/DeploymentTimeouts.kt delete mode 100644 workspace-manager/src/main/kotlin/org/modelix/instancesmanager/InstanceStatus.kt delete mode 100644 workspace-manager/src/test/kotlin/org/modelix/instancesmanager/RedirectedURLTest.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f66eec49..56f6fff0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -56,7 +56,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 = "1.0.0" } +modelix-api-server-stubs = { group = "org.modelix", name = "api-server-stubs-ktor", version = "1.1.0" } [bundles] ktor-client = [ 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 80af0b99..00000000 --- a/workspace-manager/src/main/kotlin/org/modelix/instancesmanager/AssignmentData.kt +++ /dev/null @@ -1,23 +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/workspace/manager/WorkspaceInstancesManager.kt b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceInstancesManager.kt index c80ced8b..0b1b3552 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceInstancesManager.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceInstancesManager.kt @@ -24,7 +24,6 @@ import mu.KotlinLogging import org.modelix.authorization.ModelixJWTUtil import org.modelix.authorization.permissions.AccessControlData import org.modelix.authorization.permissions.PermissionParts -import org.modelix.instancesmanager.InstanceName import org.modelix.model.server.ModelServerPermissionSchema import org.modelix.services.workspaces.ContinuingCallback import org.modelix.services.workspaces.InternalWorkspaceInstanceConfig @@ -38,6 +37,7 @@ import org.modelix.workspaces.WorkspaceHash 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 {} @@ -51,9 +51,11 @@ class WorkspaceInstanceStateValues( var image: Result? = null, var deployment: V1Deployment? = null, var pod: V1Pod? = null, + var enabled: Boolean = false, ) { fun deriveState(): WorkspaceInstanceState { return when { + !enabled -> WorkspaceInstanceState.DISABLED (deployment?.status?.readyReplicas ?: 0) >= 1 -> WorkspaceInstanceState.RUNNING deployment != null -> WorkspaceInstanceState.LAUNCHING image?.isFailure == true -> WorkspaceInstanceState.BUILD_FAILED @@ -82,7 +84,7 @@ class WorkspaceInstancesManager( const val TIMEOUT_SECONDS = 10 const val INSTANCE_ID_LABEL = "modelix.workspace.instance.id" - fun WorkspaceInstance.instanceName() = InstanceName(INSTANCE_PREFIX + id) + fun WorkspaceInstance.instanceName() = INSTANCE_PREFIX + id } init { @@ -129,8 +131,10 @@ class WorkspaceInstancesManager( } for (config in instances.values) { - val imageTask = buildManager.getOrCreateWorkspaceImageTask(config.workspaceConfig.normalizeForBuild()) val values = stateValues[config.instanceId] ?: continue + values.enabled = config.instanceConfig.enabled + + val imageTask = buildManager.getOrCreateWorkspaceImageTask(config.workspaceConfig.normalizeForBuild()) values.imageTaskState = imageTask.getState() values.image = imageTask.getOutput() } @@ -213,12 +217,16 @@ class WorkspaceInstancesManager( } } - fun getDeployment(name: InstanceName, attempts: Int): V1Deployment? { + 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.name, KUBERNETES_NAMESPACE).execute() + deployment = appsApi.readNamespacedDeployment(name, KUBERNETES_NAMESPACE).execute() } catch (ex: ApiException) { LOG.error("Failed to read deployment: $name", ex) } @@ -232,12 +240,12 @@ class WorkspaceInstancesManager( return deployment } - fun getPod(deploymentName: InstanceName): V1Pod? { + 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.name)) continue + if (!pod.metadata!!.name!!.startsWith(deploymentName)) continue return pod } } catch (e: Exception) { @@ -308,7 +316,7 @@ class WorkspaceInstancesManager( val deployment = Yaml.loadAs(File("/workspace-client-templates/deployment"), V1Deployment::class.java) deployment.metadata { - name(instanceName.name) + name(instanceName) putLabelsItem(INSTANCE_ID_LABEL, workspaceInstance.id) } deployment.spec { @@ -349,7 +357,7 @@ class WorkspaceInstancesManager( 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.name) + service.metadata!!.name(instanceName) service.metadata!!.putLabelsItem(INSTANCE_ID_LABEL, workspaceInstance.id) service.spec!!.putSelectorItem(INSTANCE_ID_LABEL, workspaceInstance.id) println("Creating service: ") 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 40819c14..35c19233 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 @@ -119,6 +119,7 @@ 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.DeploymentsProxy import org.modelix.model.persistent.HashUtil import org.modelix.model.server.ModelServerPermissionSchema import org.modelix.services.maven_connector.stubs.controllers.ModelixMavenConnectorController @@ -153,10 +154,10 @@ fun Application.workspaceManagerModule() { // val deploymentManager = DeploymentManager(manager) val buildManager = WorkspaceBuildManager(this, manager.workspaceJobTokenGenerator) val instancesManager = WorkspaceInstancesManager(manager, buildManager, coroutinesScope = this) - // val deploymentsProxy = DeploymentsProxy(deploymentManager) + val deploymentsProxy = DeploymentsProxy(instancesManager) val maxBodySize = environment.config.property("modelix.maxBodySize").getString() - // deploymentsProxy.startServer() + deploymentsProxy.startServer() install(ModelixAuthorization) { permissionSchema = WorkspacesPermissionSchema.SCHEMA 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)) - } -} From 17f47ab5a3f15a4c8f861c9e503ed5cc713f56db Mon Sep 17 00:00:00 2001 From: Sascha Lisson Date: Sat, 10 May 2025 13:27:17 +0200 Subject: [PATCH 10/16] feat: git connector --- .../gitconnector/GitConnectorController.kt | 205 ++++++++++++++++++ .../services/workspaces/IPersistedState.kt | 44 ++++ .../manager/WorkspaceManagerModule.kt | 20 +- 3 files changed, 261 insertions(+), 8 deletions(-) create mode 100644 workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/GitConnectorController.kt create mode 100644 workspace-manager/src/main/kotlin/org/modelix/services/workspaces/IPersistedState.kt 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..6dc69ab9 --- /dev/null +++ b/workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/GitConnectorController.kt @@ -0,0 +1,205 @@ +package org.modelix.services.gitconnector + +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.ApplicationCall +import io.ktor.server.application.createRouteScopedPlugin +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.routing +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.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.TypedApplicationCall +import org.modelix.services.gitconnector.stubs.models.DraftConfig +import org.modelix.services.gitconnector.stubs.models.DraftConfigList +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.workspaces.FileSystemPersistence +import org.modelix.services.workspaces.PersistedState +import java.io.File +import java.util.UUID + +class GitConnectorConfig { + val data: PersistedState = PersistedState( + persistence = FileSystemPersistence( + file = File("/workspace-manager/config/git-connector.json"), + serializer = GitConnectorData.serializer(), + ), + defaultState = { GitConnectorData() }, + ) + + fun getState(): GitConnectorData = data.state.getValue() + fun updateState(updater: (GitConnectorData) -> GitConnectorData) = data.state.update(updater) +} + +@Serializable +data class GitConnectorData( + val repositories: Map = emptyMap(), + val drafts: Map = emptyMap(), +) + +val GitConnectorPlugin = createRouteScopedPlugin(name = "gitConnector", createConfiguration = ::GitConnectorConfig) { + (route ?: application.routing({})).installControllers(pluginConfig) +} + +private fun Route.installControllers(pluginConfig: GitConnectorConfig) { + modelixGitConnectorRepositoriesRoutes(object : ModelixGitConnectorRepositoriesController { + override suspend fun listGitRepositories(call: TypedApplicationCall) { + call.respondTyped(GitRepositoryConfigList(pluginConfig.getState().repositories.values.toList()).maskCredentials()) + } + + override suspend fun createGitRepository( + gitRepositoryConfig: GitRepositoryConfig, + call: TypedApplicationCall, + ) { + val newId = UUID.randomUUID().toString() + val newRepository = gitRepositoryConfig.copy( + id = newId, + status = null, + modelixRepository = newId, + ) + + pluginConfig.updateState { + it.copy( + repositories = it.repositories + (newRepository.id to newRepository), + ) + } + + call.respondTyped(newRepository.maskCredentials()) + } + + override suspend fun getGitRepository( + repositoryId: String, + call: TypedApplicationCall, + ) { + val repo = pluginConfig.getState().repositories[repositoryId] + if (repo == null) { + call.respond(HttpStatusCode.NotFound) + } else { + call.respondTyped(repo.maskCredentials()) + } + } + + override suspend fun updateGitRepository( + repositoryId: String, + gitRepositoryConfig: GitRepositoryConfig, + call: ApplicationCall, + ) { + pluginConfig.updateState { + 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, + ) { + pluginConfig.updateState { + it.copy( + repositories = it.repositories - repositoryId, + ) + } + + call.respond(HttpStatusCode.OK) + } + }) + + modelixGitConnectorRepositoriesDraftsRoutes(object : ModelixGitConnectorRepositoriesDraftsController { + override suspend fun listDraftsInRepository( + repositoryId: String, + call: TypedApplicationCall, + ) { + call.respondTyped( + DraftConfigList( + pluginConfig.getState().drafts.values.filter { it.gitRepositoryId == repositoryId }, + ), + ) + } + + override suspend fun createDraftInRepository( + repositoryId: String, + draftConfig: DraftConfig, + call: TypedApplicationCall, + ) { + val draftId = UUID.randomUUID().toString() + val newDraft = draftConfig.copy( + id = draftId, + gitRepositoryId = repositoryId, + modelixBranchName = "drafts/$draftId", + ) + pluginConfig.updateState { + it.copy( + drafts = it.drafts + (draftId to newDraft), + ) + } + call.respondTyped(newDraft) + } + }) + + modelixGitConnectorDraftsRoutes(object : ModelixGitConnectorDraftsController { + override suspend fun deleteDraft(draftId: String, call: ApplicationCall) { + pluginConfig.updateState { + it.copy( + drafts = it.drafts - draftId, + ) + } + call.respond(HttpStatusCode.OK) + } + + override suspend fun getDraft( + draftId: String, + call: TypedApplicationCall, + ) { + val draft = pluginConfig.getState().drafts[draftId] + if (draft == null) { + call.respond(HttpStatusCode.NotFound) + } else { + call.respondTyped(draft) + } + } + }) +} + +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, + ) + } +} 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/workspace/manager/WorkspaceManagerModule.kt b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceManagerModule.kt index 35c19233..eb4b7542 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 @@ -122,14 +122,15 @@ import org.modelix.gitui.gitui import org.modelix.instancesmanager.DeploymentsProxy import org.modelix.model.persistent.HashUtil import org.modelix.model.server.ModelServerPermissionSchema -import org.modelix.services.maven_connector.stubs.controllers.ModelixMavenConnectorController -import org.modelix.services.maven_connector.stubs.controllers.ModelixMavenConnectorController.Companion.modelixMavenConnectorRoutes -import org.modelix.services.maven_connector.stubs.controllers.ModelixMavenConnectorRepositoriesController -import org.modelix.services.maven_connector.stubs.controllers.ModelixMavenConnectorRepositoriesController.Companion.modelixMavenConnectorRepositoriesRoutes -import org.modelix.services.maven_connector.stubs.controllers.TypedApplicationCall -import org.modelix.services.maven_connector.stubs.models.MavenConnectorConfig -import org.modelix.services.maven_connector.stubs.models.MavenRepository -import org.modelix.services.maven_connector.stubs.models.MavenRepositoryList +import org.modelix.services.gitconnector.GitConnectorPlugin +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.workspace.manager.WorkspaceJobQueue.Companion.HELM_PREFIX import org.modelix.workspaces.Credentials import org.modelix.workspaces.GitRepository @@ -183,6 +184,9 @@ fun Application.workspaceManagerModule() { } } + install(GitConnectorPlugin) { + } + routing { staticResources("static/", basePackage = "org.modelix.workspace.static") From a629c9436e99e7f84c623f01ca4bd08041e5e46e Mon Sep 17 00:00:00 2001 From: Sascha Lisson Date: Sun, 11 May 2025 07:02:02 +0200 Subject: [PATCH 11/16] feat: git branches list --- .../gitconnector/GitConnectorController.kt | 32 +++++++- .../gitconnector/GitConnectorManager.kt | 22 +++++ .../services/gitconnector/GitFetchTask.kt | 82 +++++++++++++++++++ .../services/gitconnector/JGitExtensions.kt | 65 +++++++++++++++ .../workspace/manager/MavenControllerImpl.kt | 24 +++--- 5 files changed, 210 insertions(+), 15 deletions(-) create mode 100644 workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/GitConnectorManager.kt create mode 100644 workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/GitFetchTask.kt create mode 100644 workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/JGitExtensions.kt 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 index 6dc69ab9..53e49111 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/GitConnectorController.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/GitConnectorController.kt @@ -13,12 +13,17 @@ import org.modelix.services.gitconnector.stubs.controllers.ModelixGitConnectorRe 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.ModelixGitConnectorRepositoriesFetchController +import org.modelix.services.gitconnector.stubs.controllers.ModelixGitConnectorRepositoriesFetchController.Companion.modelixGitConnectorRepositoriesFetchRoutes +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.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.services.workspaces.FileSystemPersistence import org.modelix.services.workspaces.PersistedState import java.io.File @@ -44,10 +49,11 @@ data class GitConnectorData( ) val GitConnectorPlugin = createRouteScopedPlugin(name = "gitConnector", createConfiguration = ::GitConnectorConfig) { - (route ?: application.routing({})).installControllers(pluginConfig) + val manager = GitConnectorManager(application, pluginConfig.data.state) + (route ?: application.routing({})).installControllers(manager, pluginConfig) } -private fun Route.installControllers(pluginConfig: GitConnectorConfig) { +private fun Route.installControllers(manager: GitConnectorManager, pluginConfig: GitConnectorConfig) { modelixGitConnectorRepositoriesRoutes(object : ModelixGitConnectorRepositoriesController { override suspend fun listGitRepositories(call: TypedApplicationCall) { call.respondTyped(GitRepositoryConfigList(pluginConfig.getState().repositories.values.toList()).maskCredentials()) @@ -75,13 +81,14 @@ private fun Route.installControllers(pluginConfig: GitConnectorConfig) { override suspend fun getGitRepository( repositoryId: String, + includeStatus: Boolean?, call: TypedApplicationCall, ) { val repo = pluginConfig.getState().repositories[repositoryId] if (repo == null) { call.respond(HttpStatusCode.NotFound) } else { - call.respondTyped(repo.maskCredentials()) + call.respondTyped(repo.maskCredentials().copy(status = if (includeStatus == true) repo.status else null)) } } @@ -168,6 +175,25 @@ private fun Route.installControllers(pluginConfig: GitConnectorConfig) { } } }) + + modelixGitConnectorRepositoriesFetchRoutes(object : ModelixGitConnectorRepositoriesFetchController { + override suspend fun triggerGitFetch( + repositoryId: String, + call: ApplicationCall, + ) { + manager.triggerGitFetch(repositoryId) + call.respond(HttpStatusCode.OK) + } + }) + + modelixGitConnectorRepositoriesStatusRoutes(object : ModelixGitConnectorRepositoriesStatusController { + override suspend fun getGitRepositoryStatus( + repositoryId: String, + call: TypedApplicationCall, + ) { + call.respondTyped(pluginConfig.getState().repositories[repositoryId]?.status ?: GitRepositoryStatusData()) + } + }) } fun GitRepositoryConfigList.maskCredentials() = copy( 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..d5a8a9a2 --- /dev/null +++ b/workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/GitConnectorManager.kt @@ -0,0 +1,22 @@ +package org.modelix.services.gitconnector + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.modelix.workspace.manager.SharedMutableState + +class GitConnectorManager( + val scope: CoroutineScope, + val connectorData: SharedMutableState, +) { + fun triggerGitFetch(repositoryId: String) { + val fetchTask = GitFetchTask(scope, connectorData.getValue().repositories[repositoryId]!!) + scope.launch { + val resultResult = fetchTask.waitForOutput() + connectorData.update { + val oldRepositoryData = it.repositories[repositoryId] ?: return@update it + val newRepositoryData = oldRepositoryData.merge(resultResult.remoteRefs) + it.copy(repositories = it.repositories + (repositoryId to newRepositoryData)) + } + } + } +} 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..e8a5b3bf --- /dev/null +++ b/workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/GitFetchTask.kt @@ -0,0 +1,82 @@ +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 oldBranchStatusMap: Map, GitBranchStatusData> = (repo.status?.branches ?: emptyList()).associateBy { it.key() } + val newBranchStatusMap = mutableMapOf, GitBranchStatusData>() + + 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/") + val branchKey = remoteConfig.name to branchName + val branchStatus = oldBranchStatusMap[branchKey] ?: GitBranchStatusData() + newBranchStatusMap[branchKey] = branchStatus.copy( + gitCommitHash = ref.objectId.name, + ) + } + } + + return FetchResult( + remoteRefs = newBranchStatusMap.map { + FetchedBranch( + remoteName = it.key.first ?: "", + branchName = it.key.second ?: "", + commitHash = it.value.gitCommitHash ?: "", + ) + }, + ) + } +} + +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().sortedBy { it.name }, + ), + ) +} 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/workspace/manager/MavenControllerImpl.kt b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/MavenControllerImpl.kt index bff2de12..7fbefa9b 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/MavenControllerImpl.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/MavenControllerImpl.kt @@ -4,18 +4,18 @@ 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.maven_connector.stubs.controllers.ModelixMavenConnectorArtifactsController -import org.modelix.services.maven_connector.stubs.controllers.ModelixMavenConnectorArtifactsController.Companion.modelixMavenConnectorArtifactsRoutes -import org.modelix.services.maven_connector.stubs.controllers.ModelixMavenConnectorController -import org.modelix.services.maven_connector.stubs.controllers.ModelixMavenConnectorController.Companion.modelixMavenConnectorRoutes -import org.modelix.services.maven_connector.stubs.controllers.ModelixMavenConnectorRepositoriesController -import org.modelix.services.maven_connector.stubs.controllers.ModelixMavenConnectorRepositoriesController.Companion.modelixMavenConnectorRepositoriesRoutes -import org.modelix.services.maven_connector.stubs.controllers.TypedApplicationCall -import org.modelix.services.maven_connector.stubs.models.MavenArtifact -import org.modelix.services.maven_connector.stubs.models.MavenArtifactList -import org.modelix.services.maven_connector.stubs.models.MavenConnectorConfig -import org.modelix.services.maven_connector.stubs.models.MavenRepository -import org.modelix.services.maven_connector.stubs.models.MavenRepositoryList +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, From 37d028f7fcafceae273402545d5ccb9736b2fe40 Mon Sep 17 00:00:00 2001 From: Sascha Lisson Date: Mon, 12 May 2025 16:21:03 +0200 Subject: [PATCH 12/16] feat: workspace launch button --- .../kotlin/org/modelix/workspace/job/Main.kt | 4 +- .../modelix/workspace/job/MavenDownloader.kt | 6 +- .../workspace/job/WorkspaceBuildJob.kt | 49 +- .../gitconnector/GitConnectorController.kt | 332 +-- .../gitconnector/GitConnectorManager.kt | 39 +- .../services/gitconnector/GitFetchTask.kt | 27 +- .../workspaces/WorkspaceConfigExtensions.kt | 16 +- .../WorkspaceConfigForBuildExtensions.kt | 37 + .../modelix/workspace/manager/KestraClient.kt | 197 ++ .../manager/WorkspaceBuildManager.kt | 26 +- .../manager/WorkspaceInstancesManager.kt | 41 +- .../workspace/manager/WorkspaceJobQueue.kt | 5 +- .../workspace/manager/WorkspaceJobQueueUI.kt | 160 -- .../workspace/manager/WorkspaceManager.kt | 331 +-- .../manager/WorkspaceManagerModule.kt | 2037 +++++++++-------- .../workspace/manager/WorkspacesController.kt | 249 +- .../workspaces/InternalWorkspaceConfig.kt | 1 + .../workspaces/WorkspaceConfigForBuild.kt | 36 + 18 files changed, 1707 insertions(+), 1886 deletions(-) create mode 100644 workspace-manager/src/main/kotlin/org/modelix/services/workspaces/WorkspaceConfigForBuildExtensions.kt create mode 100644 workspace-manager/src/main/kotlin/org/modelix/workspace/manager/KestraClient.kt delete mode 100644 workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceJobQueueUI.kt create mode 100644 workspaces/src/main/kotlin/org/modelix/workspaces/WorkspaceConfigForBuild.kt 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 5acfb7fc..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,8 +27,8 @@ 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.InternalWorkspaceConfig import org.modelix.workspaces.WorkspaceBuildStatus +import org.modelix.workspaces.WorkspaceConfigForBuild import java.util.UUID import kotlin.time.Duration.Companion.minutes @@ -58,7 +58,7 @@ fun main(args: Array) { runBlocking { printNewJobStatus(WorkspaceBuildStatus.Running) - val workspace: InternalWorkspaceConfig = httpClient.get { + val workspace: WorkspaceConfigForBuild = httpClient.get { url { takeFrom(serverUrl) appendPathSegments("modelix", "workspaces", "tasks", buildTaskId.toString(), "config") 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 d34fd924..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.InternalWorkspaceConfig +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: InternalWorkspaceConfig, 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 a747eae8..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 @@ -40,11 +36,10 @@ import org.modelix.buildtools.SourceModuleOwner import org.modelix.buildtools.newChild import org.modelix.buildtools.xmlToString import org.modelix.workspaces.InternalWorkspaceConfig -import org.modelix.workspaces.UploadId 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 @@ -53,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: InternalWorkspaceConfig, 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() @@ -70,21 +64,22 @@ class WorkspaceBuildJob(val workspace: InternalWorkspaceConfig, val httpClient: } 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 { @@ -97,7 +92,7 @@ class WorkspaceBuildJob(val workspace: InternalWorkspaceConfig, val httpClient: } private fun copyMavenDependencies(): List { - return workspace.mavenDependencies.map { mavenDep -> + return workspace.mavenArtifacts.map { mavenDep -> LOG.info { "Resolving $mavenDep" } MavenDownloader(workspace, workspaceDir).downloadAndCopyFromMaven(mavenDep) { println(it) } } @@ -258,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/src/main/kotlin/org/modelix/services/gitconnector/GitConnectorController.kt b/workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/GitConnectorController.kt index 53e49111..1b7554c3 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/GitConnectorController.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/GitConnectorController.kt @@ -2,198 +2,216 @@ package org.modelix.services.gitconnector import io.ktor.http.HttpStatusCode import io.ktor.server.application.ApplicationCall -import io.ktor.server.application.createRouteScopedPlugin import io.ktor.server.response.respond import io.ktor.server.routing.Route -import io.ktor.server.routing.routing 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.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.ModelixGitConnectorRepositoriesFetchController -import org.modelix.services.gitconnector.stubs.controllers.ModelixGitConnectorRepositoriesFetchController.Companion.modelixGitConnectorRepositoriesFetchRoutes 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.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.services.workspaces.FileSystemPersistence -import org.modelix.services.workspaces.PersistedState -import java.io.File +import org.modelix.workspace.manager.SharedMutableState import java.util.UUID -class GitConnectorConfig { - val data: PersistedState = PersistedState( - persistence = FileSystemPersistence( - file = File("/workspace-manager/config/git-connector.json"), - serializer = GitConnectorData.serializer(), - ), - defaultState = { GitConnectorData() }, - ) - - fun getState(): GitConnectorData = data.state.getValue() - fun updateState(updater: (GitConnectorData) -> GitConnectorData) = data.state.update(updater) -} - @Serializable data class GitConnectorData( val repositories: Map = emptyMap(), val drafts: Map = emptyMap(), ) -val GitConnectorPlugin = createRouteScopedPlugin(name = "gitConnector", createConfiguration = ::GitConnectorConfig) { - val manager = GitConnectorManager(application, pluginConfig.data.state) - (route ?: application.routing({})).installControllers(manager, pluginConfig) -} +class GitConnectorController(val manager: GitConnectorManager) { -private fun Route.installControllers(manager: GitConnectorManager, pluginConfig: GitConnectorConfig) { - modelixGitConnectorRepositoriesRoutes(object : ModelixGitConnectorRepositoriesController { - override suspend fun listGitRepositories(call: TypedApplicationCall) { - call.respondTyped(GitRepositoryConfigList(pluginConfig.getState().repositories.values.toList()).maskCredentials()) - } - - override suspend fun createGitRepository( - gitRepositoryConfig: GitRepositoryConfig, - call: TypedApplicationCall, - ) { - val newId = UUID.randomUUID().toString() - val newRepository = gitRepositoryConfig.copy( - id = newId, - status = null, - modelixRepository = newId, - ) - - pluginConfig.updateState { - it.copy( - repositories = it.repositories + (newRepository.id to newRepository), + 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), ) } - call.respondTyped(newRepository.maskCredentials()) - } - - override suspend fun getGitRepository( - repositoryId: String, - includeStatus: Boolean?, - call: TypedApplicationCall, - ) { - val repo = pluginConfig.getState().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, - ) { - pluginConfig.updateState { - val mergedConfig = it.repositories[repositoryId].merge(gitRepositoryConfig) - it.copy( - repositories = it.repositories + (repositoryId to mergedConfig), + 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()) } - call.respond(HttpStatusCode.OK) - } + 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 deleteGitRepository( - repositoryId: String, - call: ApplicationCall, - ) { - pluginConfig.updateState { - it.copy( - repositories = it.repositories - repositoryId, - ) + 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) } + }) - call.respond(HttpStatusCode.OK) - } - }) - - modelixGitConnectorRepositoriesDraftsRoutes(object : ModelixGitConnectorRepositoriesDraftsController { - override suspend fun listDraftsInRepository( - repositoryId: String, - call: TypedApplicationCall, - ) { - call.respondTyped( - DraftConfigList( - pluginConfig.getState().drafts.values.filter { it.gitRepositoryId == repositoryId }, - ), - ) - } - - override suspend fun createDraftInRepository( - repositoryId: String, - draftConfig: DraftConfig, - call: TypedApplicationCall, - ) { - val draftId = UUID.randomUUID().toString() - val newDraft = draftConfig.copy( - id = draftId, - gitRepositoryId = repositoryId, - modelixBranchName = "drafts/$draftId", - ) - pluginConfig.updateState { - it.copy( - drafts = it.drafts + (draftId to newDraft), + modelixGitConnectorRepositoriesDraftsRoutes(object : ModelixGitConnectorRepositoriesDraftsController { + override suspend fun listDraftsInRepository( + repositoryId: String, + call: TypedApplicationCall, + ) { + call.respondTyped( + DraftConfigList( + data.getValue().drafts.values.filter { it.gitRepositoryId == repositoryId }, + ), ) } - call.respondTyped(newDraft) - } - }) - - modelixGitConnectorDraftsRoutes(object : ModelixGitConnectorDraftsController { - override suspend fun deleteDraft(draftId: String, call: ApplicationCall) { - pluginConfig.updateState { - it.copy( - drafts = it.drafts - draftId, + + 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) } - call.respond(HttpStatusCode.OK) - } - - override suspend fun getDraft( - draftId: String, - call: TypedApplicationCall, - ) { - val draft = pluginConfig.getState().drafts[draftId] - if (draft == null) { - call.respond(HttpStatusCode.NotFound) - } else { - call.respondTyped(draft) + }) + + modelixGitConnectorDraftsRoutes(object : ModelixGitConnectorDraftsController { + override suspend fun deleteDraft(draftId: String, call: ApplicationCall) { + data.update { + it.copy( + drafts = it.drafts - draftId, + ) + } + call.respond(HttpStatusCode.OK) } - } - }) - - modelixGitConnectorRepositoriesFetchRoutes(object : ModelixGitConnectorRepositoriesFetchController { - override suspend fun triggerGitFetch( - repositoryId: String, - call: ApplicationCall, - ) { - manager.triggerGitFetch(repositoryId) - call.respond(HttpStatusCode.OK) - } - }) - - modelixGitConnectorRepositoriesStatusRoutes(object : ModelixGitConnectorRepositoriesStatusController { - override suspend fun getGitRepositoryStatus( - repositoryId: String, - call: TypedApplicationCall, - ) { - call.respondTyped(pluginConfig.getState().repositories[repositoryId]?.status ?: GitRepositoryStatusData()) - } - }) + + 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()) + } + }) + } } fun GitRepositoryConfigList.maskCredentials() = copy( @@ -229,3 +247,15 @@ fun List.merge(newData: List): List, + val connectorData: SharedMutableState = PersistedState( + persistence = FileSystemPersistence( + file = File("/workspace-manager/config/git-connector.json"), + serializer = GitConnectorData.serializer(), + ), + defaultState = { GitConnectorData() }, + ).state, ) { - fun triggerGitFetch(repositoryId: String) { - val fetchTask = GitFetchTask(scope, connectorData.getValue().repositories[repositoryId]!!) - scope.launch { - val resultResult = fetchTask.waitForOutput() - connectorData.update { - val oldRepositoryData = it.repositories[repositoryId] ?: return@update it - val newRepositoryData = oldRepositoryData.merge(resultResult.remoteRefs) - it.copy(repositories = it.repositories + (repositoryId to newRepositoryData)) - } - } + suspend fun updateRemoteBranches(repository: GitRepositoryConfig): List { + val repositoryId = repository.id + val fetchTask = GitFetchTask(scope, repository) + val resultResult = fetchTask.waitForOutput() + return connectorData.update { + val oldRepositoryData = it.repositories[repositoryId] ?: return@update it + val newRepositoryData = oldRepositoryData.merge(resultResult.remoteRefs) + it.copy(repositories = it.repositories + (repositoryId to newRepositoryData)) + }.repositories[repositoryId]?.status?.branches ?: emptyList() } + + fun getRepository(id: String): GitRepositoryConfig? { + return connectorData.getValue().repositories[id] + } + + fun getDraft(id: String) = connectorData.getValue().drafts[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 index e8a5b3bf..58a04a8f 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/GitFetchTask.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/GitFetchTask.kt @@ -11,8 +11,7 @@ import org.modelix.workspace.manager.TaskInstance class GitFetchTask(scope: CoroutineScope, val repo: GitRepositoryConfig) : TaskInstance(scope) { override suspend fun process(): FetchResult { - val oldBranchStatusMap: Map, GitBranchStatusData> = (repo.status?.branches ?: emptyList()).associateBy { it.key() } - val newBranchStatusMap = mutableMapOf, GitBranchStatusData>() + val fetchedBranches = ArrayList() for (remoteConfig in (repo.remotes ?: emptyList())) { val cmd = Git.lsRemoteRepository() @@ -31,23 +30,17 @@ class GitFetchTask(scope: CoroutineScope, val repo: GitRepositoryConfig) : TaskI for (ref in refs) { if (!ref.name.startsWith("refs/heads/")) continue val branchName = ref.name.removePrefix("refs/heads/") - val branchKey = remoteConfig.name to branchName - val branchStatus = oldBranchStatusMap[branchKey] ?: GitBranchStatusData() - newBranchStatusMap[branchKey] = branchStatus.copy( - gitCommitHash = ref.objectId.name, + fetchedBranches.add( + FetchedBranch( + remoteName = remoteConfig.name, + branchName = branchName, + commitHash = ref.objectId.name, + ), ) } } - return FetchResult( - remoteRefs = newBranchStatusMap.map { - FetchedBranch( - remoteName = it.key.first ?: "", - branchName = it.key.second ?: "", - commitHash = it.value.gitCommitHash ?: "", - ) - }, - ) + return FetchResult(remoteRefs = fetchedBranches) } } @@ -65,7 +58,7 @@ 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>() + val newBranchStatusMap = mutableMapOf, GitBranchStatusData>() for (ref in newRefs) { val branchKey = ref.remoteName to ref.branchName val branchStatus = oldBranchStatusMap[branchKey] @@ -76,7 +69,7 @@ fun GitRepositoryConfig.merge(newRefs: List): GitRepositoryConfig } return copy( status = (status ?: GitRepositoryStatusData()).copy( - branches = newBranchStatusMap.values.toList().sortedBy { it.name }, + 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/workspaces/WorkspaceConfigExtensions.kt b/workspace-manager/src/main/kotlin/org/modelix/services/workspaces/WorkspaceConfigExtensions.kt index e0ce5395..33dcbba4 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/services/workspaces/WorkspaceConfigExtensions.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/services/workspaces/WorkspaceConfigExtensions.kt @@ -1,5 +1,6 @@ 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 @@ -9,10 +10,23 @@ fun WorkspaceConfig.hash(): String = HashUtil.sha256(Json.encodeToString(this)) fun WorkspaceConfig.normalizeForBuild() = copy( name = "", memoryLimit = "", - gitRepositories = gitRepositories.map { it.copy(credentials = null) }, 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..9aca5b8f --- /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/workspace/manager/KestraClient.kt b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/KestraClient.kt new file mode 100644 index 00000000..9093e18a --- /dev/null +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/KestraClient.kt @@ -0,0 +1,197 @@ +package org.modelix.workspace.manager + +import io.ktor.client.HttpClient +import io.ktor.client.HttpClientConfig +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.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 { + 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: InternalWorkspaceConfig): 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.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}") + } + } + } +} \ No newline at end of file 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 index 401f1352..5ed0e4f4 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceBuildManager.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceBuildManager.kt @@ -15,12 +15,12 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.withTimeout import org.modelix.services.workspaces.ContinuingCallback +import org.modelix.services.workspaces.toValidImageTag import org.modelix.workspace.manager.WorkspaceJobQueue.Companion.HELM_PREFIX import org.modelix.workspace.manager.WorkspaceJobQueue.Companion.JOB_IMAGE import org.modelix.workspace.manager.WorkspaceJobQueue.Companion.KUBERNETES_NAMESPACE -import org.modelix.workspaces.DEFAULT_MPS_VERSION -import org.modelix.workspaces.InternalWorkspaceConfig -import org.modelix.workspaces.withHash +import org.modelix.workspaces.WorkspaceConfigForBuild +import org.modelix.workspaces.hash import java.util.UUID import kotlin.coroutines.suspendCoroutine import kotlin.time.Duration.Companion.minutes @@ -29,18 +29,18 @@ private val LOG = mu.KotlinLogging.logger { } class WorkspaceBuildManager( val coroutinesScope: CoroutineScope, - val tokenGenerator: (InternalWorkspaceConfig) -> String, + val tokenGenerator: (WorkspaceConfigForBuild) -> String, ) { - private val workspaceImageTasks = ReusableTasks() + private val workspaceImageTasks = ReusableTasks() - fun getOrCreateWorkspaceImageTask(workspaceConfig: InternalWorkspaceConfig): WorkspaceImageTask { - return workspaceImageTasks.getOrCreateTask(workspaceConfig.normalizeForBuild()) { + fun getOrCreateWorkspaceImageTask(workspaceConfig: WorkspaceConfigForBuild): WorkspaceImageTask { + return workspaceImageTasks.getOrCreateTask(workspaceConfig) { WorkspaceImageTask(workspaceConfig, tokenGenerator, coroutinesScope) } } - fun getWorkspaceConfigByTaskId(taskId: UUID): InternalWorkspaceConfig? { + fun getWorkspaceConfigByTaskId(taskId: UUID): WorkspaceConfigForBuild? { return workspaceImageTasks.getAll().find { it.id == taskId }?.workspaceConfig } } @@ -68,8 +68,8 @@ data class ImageNameAndTag(val name: String, val tag: String) { } class WorkspaceImageTask( - val workspaceConfig: InternalWorkspaceConfig, - val tokenGenerator: (InternalWorkspaceConfig) -> String, + val workspaceConfig: WorkspaceConfigForBuild, + val tokenGenerator: (WorkspaceConfigForBuild) -> String, scope: CoroutineScope, ) : TaskInstance(scope) { companion object { @@ -78,7 +78,7 @@ class WorkspaceImageTask( private val resultImage = ImageNameAndTag( "modelix-workspaces/ws${workspaceConfig.id}", - workspaceConfig.withHash().hash().toValidImageTag(), + workspaceConfig.hash().toValidImageTag(), ) override suspend fun process(): ImageNameAndTag { @@ -135,9 +135,9 @@ class WorkspaceImageTask( @Suppress("ktlint") fun generateJobYaml(): String { val jobName = "wsjob-$id" - val mpsVersion = workspaceConfig.mpsVersion?.takeIf { it.isNotEmpty() } ?: DEFAULT_MPS_VERSION + val mpsVersion = workspaceConfig.mpsVersion - val containerMemoryBytes = Quantity.fromString(workspaceConfig.memoryLimit).number + 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 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 index 0b1b3552..6208e298 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceInstancesManager.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceInstancesManager.kt @@ -25,15 +25,14 @@ import org.modelix.authorization.ModelixJWTUtil import org.modelix.authorization.permissions.AccessControlData import org.modelix.authorization.permissions.PermissionParts import org.modelix.model.server.ModelServerPermissionSchema +import org.modelix.services.gitconnector.GitConnectorManager import org.modelix.services.workspaces.ContinuingCallback -import org.modelix.services.workspaces.InternalWorkspaceInstanceConfig +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.WorkspaceAndHash -import org.modelix.workspaces.WorkspaceHash import org.modelix.workspaces.WorkspacesPermissionSchema import java.io.File import java.util.Collections @@ -43,7 +42,7 @@ import kotlin.coroutines.suspendCoroutine private val LOG = KotlinLogging.logger {} private data class InstancesManagerState( - val instances: List = emptyList(), + val instances: Map = emptyMap(), ) class WorkspaceInstanceStateValues( @@ -75,6 +74,7 @@ class WorkspaceInstanceStateValues( class WorkspaceInstancesManager( val workspaceManager: WorkspaceManager, val buildManager: WorkspaceBuildManager, + val gitManager: GitConnectorManager, val coroutinesScope: CoroutineScope = CoroutineScope(Dispatchers.Default), ) { companion object { @@ -107,19 +107,18 @@ class WorkspaceInstancesManager( reconcileJob.cancel("disposed") } - fun updateInstancesList(updater: (List) -> List) { + fun updateInstancesMap(updater: (Map) -> Map) { reconciler.updateDesiredState { it.copy(instances = updater(it.instances)) } } - fun getInstancesList(): List = reconciler.getDesiredState().instances + fun getInstancesMap(): Map = reconciler.getDesiredState().instances suspend fun getInstanceStates(): Map { val managerState = reconciler.getDesiredState() - val instances: Map = - managerState.instances.associateBy { it.instanceId } + val instances: Map = managerState.instances val stateValues = instances.keys.associateWith { WorkspaceInstanceStateValues() } for ((instanceId, deployment) in getExistingDeployments()) { @@ -131,10 +130,10 @@ class WorkspaceInstancesManager( } for (config in instances.values) { - val values = stateValues[config.instanceId] ?: continue - values.enabled = config.instanceConfig.enabled + val values = stateValues[config.id] ?: continue + values.enabled = config.enabled - val imageTask = buildManager.getOrCreateWorkspaceImageTask(config.workspaceConfig.normalizeForBuild()) + val imageTask = buildManager.getOrCreateWorkspaceImageTask(config.configForBuild(gitManager)) values.imageTaskState = imageTask.getState() values.image = imageTask.getOutput() } @@ -177,7 +176,7 @@ class WorkspaceInstancesManager( private suspend fun reconcile(newState: InstancesManagerState) { val appsApi = AppsV1Api() val coreApi = CoreV1Api() - val expectedInstances = newState.instances.filter { it.instanceConfig.enabled }.associateBy { it.instanceConfig.id } + val expectedInstances = newState.instances.filter { it.value.enabled } val existingInstances = getExistingDeployments() val toAdd = expectedInstances - existingInstances.keys @@ -199,16 +198,16 @@ class WorkspaceInstancesManager( } for (instance in toAdd.values) { try { - val workspaceConfig = instance.workspaceConfig - buildManager.getOrCreateWorkspaceImageTask(workspaceConfig.normalizeForBuild()) - val imageTask = buildManager.getOrCreateWorkspaceImageTask(workspaceConfig.normalizeForBuild()).also { it.launch() } + val workspaceConfig = instance.configForBuild(gitManager) + buildManager.getOrCreateWorkspaceImageTask(workspaceConfig) + val imageTask = buildManager.getOrCreateWorkspaceImageTask(workspaceConfig).also { it.launch() } val image = imageTask.getOutput()?.getOrNull() if (image != null) { - createDeployment(instance.instanceConfig, image) - createService(instance.instanceConfig) + createDeployment(instance, image) + createService(instance) } } catch (e: Exception) { - LOG.error("Failed to create deployment for workspace instance ${instance.instanceConfig.id}", e) + LOG.error("Failed to create deployment for workspace instance ${instance.id}", e) } } @@ -384,12 +383,6 @@ class WorkspaceInstancesManager( } } - private fun getWorkspaceByHash(hash: WorkspaceHash): WorkspaceAndHash { - return requireNotNull(workspaceManager.getWorkspaceForHash(hash)) { - "Workspace not found: $hash" - } - } - 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 db65de2f..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,13 +34,14 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import org.modelix.model.persistent.HashUtil +import org.modelix.services.workspaces.stubs.models.WorkspaceConfig import org.modelix.workspaces.InternalWorkspaceConfig import org.modelix.workspaces.WorkspaceAndHash import org.modelix.workspaces.WorkspaceBuildStatus import java.util.Locale import kotlin.time.Duration.Companion.seconds -class WorkspaceJobQueue(val tokenGenerator: (InternalWorkspaceConfig) -> String) { +class WorkspaceJobQueue(val tokenGenerator: (WorkspaceConfig) -> String) { private val workspaceConfig2job: MutableMap = LinkedHashMap() private val coroutinesScope = CoroutineScope(Dispatchers.Default) @@ -193,7 +194,7 @@ class WorkspaceJobQueue(val tokenGenerator: (InternalWorkspaceConfig) -> 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 a7508009..00000000 --- a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceJobQueueUI.kt +++ /dev/null @@ -1,160 +0,0 @@ -package org.modelix.workspace.manager - -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 536fe27b..8dfb60ea 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.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.ModelServerWorkspacePersistence import org.modelix.workspaces.UploadId -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 +@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,50 +34,32 @@ class WorkspaceManager(val credentialsEncryption: CredentialsEncryption) { val workspacesDir = if (parentRepoDir != null) File(parentRepoDir.parent, "modelix-workspaces") else File("modelix-workspaces") workspacesDir.absoluteFile } - val workspaceJobTokenGenerator: (InternalWorkspaceConfig) -> 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 buildJobs = WorkspaceJobQueue(tokenGenerator = workspaceJobTokenGenerator) val kestraClient = KestraClient(jwtUtil) - init { - println("workspaces directory: $directory") - - // 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: InternalWorkspaceConfig): 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: InternalWorkspaceConfig) = File(directory, workspace.id) @@ -133,208 +86,38 @@ class WorkspaceManager(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() = 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) } } - 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?): InternalWorkspaceConfig { - val newWorkspace = workspacePersistence.newWorkspace() - if (owner != null) { - accessControlPersistence.update { data -> - data.withGrantToUser(owner, WorkspacesPermissionSchema.workspaces.workspace(newWorkspace.id).owner.fullId) - } - } - return newWorkspace + fun removeWorkspace(workspaceId: String) { + data.update { it.copy(workspaces = it.workspaces - workspaceId) } } - 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) } - } +// 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") - } - - 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: InternalWorkspaceConfig): 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}") - } - } - } -} 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 eb4b7542..6ea6aed9 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 @@ -122,7 +122,8 @@ import org.modelix.gitui.gitui import org.modelix.instancesmanager.DeploymentsProxy import org.modelix.model.persistent.HashUtil import org.modelix.model.server.ModelServerPermissionSchema -import org.modelix.services.gitconnector.GitConnectorPlugin +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 @@ -154,9 +155,11 @@ fun Application.workspaceManagerModule() { val manager = WorkspaceManager(credentialsEncryption) // val deploymentManager = DeploymentManager(manager) val buildManager = WorkspaceBuildManager(this, manager.workspaceJobTokenGenerator) - val instancesManager = WorkspaceInstancesManager(manager, buildManager, coroutinesScope = this) - val deploymentsProxy = DeploymentsProxy(instancesManager) val maxBodySize = environment.config.property("modelix.maxBodySize").getString() + val gitManager = GitConnectorManager(this) + val gitController = GitConnectorController(gitManager) + val instancesManager = WorkspaceInstancesManager(manager, buildManager, coroutinesScope = this, gitManager = gitManager) + val deploymentsProxy = DeploymentsProxy(instancesManager) deploymentsProxy.startServer() @@ -184,9 +187,6 @@ fun Application.workspaceManagerModule() { } } - install(GitConnectorPlugin) { - } - routing { staticResources("static/", basePackage = "org.modelix.workspace.static") @@ -195,7 +195,8 @@ fun Application.workspaceManagerModule() { // } MavenControllerImpl().install(this) - WorkspacesController(manager, instancesManager, buildManager).install(this) + WorkspacesController(manager, instancesManager, buildManager, gitManager).install(this) + gitController.install(this) modelixMavenConnectorRoutes(object : ModelixMavenConnectorController { override suspend fun getMavenConnectorConfig(call: TypedApplicationCall) { @@ -230,1025 +231,1025 @@ fun Application.workspaceManagerModule() { TODO("Not yet implemented") } }) - - 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 +// +// 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)}/project" -// text("Open Web Interface") +// 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)}/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("../") { +"Workspace List" } - } - div("menuItem") { - a("../${workspaceAndHash.hash().hash}/buildlog") { +"Build Log" } - } - // Shadow models based UI was removed +// 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"]!! diff --git a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspacesController.kt b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspacesController.kt index 293df8b3..20438a3a 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspacesController.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspacesController.kt @@ -8,13 +8,8 @@ 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.NoPermissionException -import org.modelix.authorization.checkPermission -import org.modelix.authorization.getUnverifiedJwt import org.modelix.authorization.getUserName -import org.modelix.services.workspaces.InternalWorkspaceInstanceConfig -import org.modelix.services.workspaces.stubs.controllers.ModelixWorkspacesDraftsController -import org.modelix.services.workspaces.stubs.controllers.ModelixWorkspacesDraftsController.Companion.modelixWorkspacesDraftsRoutes +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 @@ -28,11 +23,6 @@ import org.modelix.services.workspaces.stubs.controllers.ModelixWorkspacesTasksC 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.GitChangeDraft -import org.modelix.services.workspaces.stubs.models.GitChangeDraftList -import org.modelix.services.workspaces.stubs.models.GitCredentials -import org.modelix.services.workspaces.stubs.models.GitRepository -import org.modelix.services.workspaces.stubs.models.MavenArtifact import org.modelix.services.workspaces.stubs.models.WorkspaceConfig import org.modelix.services.workspaces.stubs.models.WorkspaceInstance import org.modelix.services.workspaces.stubs.models.WorkspaceInstanceEnabled @@ -41,11 +31,8 @@ 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.WorkspaceJobQueue.Companion.HELM_PREFIX -import org.modelix.workspaces.Credentials import org.modelix.workspaces.DEFAULT_MPS_VERSION -import org.modelix.workspaces.GenerationDependency -import org.modelix.workspaces.InternalWorkspaceConfig -import org.modelix.workspaces.MavenRepository +import org.modelix.workspaces.WorkspaceConfigForBuild import org.modelix.workspaces.WorkspaceProgressItems import org.modelix.workspaces.WorkspacesPermissionSchema import java.util.UUID @@ -54,10 +41,9 @@ class WorkspacesController( val manager: WorkspaceManager, val instancesManager: WorkspaceInstancesManager, val buildManager: WorkspaceBuildManager, + val gitConnectorManager: GitConnectorManager, ) { - private val drafts: GitChangeDraftList = GitChangeDraftList(emptyList()) - fun install(route: Route) { route.install_() } @@ -68,20 +54,16 @@ class WorkspacesController( workspaceId: String, call: TypedApplicationCall, ) { - val workspace = manager.getWorkspaceForId(workspaceId) + val workspace = manager.getWorkspace(workspaceId) if (workspace == null) { call.respond(HttpStatusCode.NotFound) } else { - call.respondTyped(workspace.workspace.convert()) + call.respondTyped(workspace) } } override suspend fun listWorkspaces(call: TypedApplicationCall) { - call.respondTyped( - WorkspaceList( - workspaces = manager.getAllWorkspaces().map { it.convert() }, - ), - ) + call.respondTyped(WorkspaceList(workspaces = manager.getAllWorkspaces())) } override suspend fun deleteWorkspace( @@ -94,26 +76,28 @@ class WorkspacesController( override suspend fun updateWorkspace( workspaceId: String, - legacyWorkspaceConfig: WorkspaceConfig, + workspaceConfig: WorkspaceConfig, call: ApplicationCall, ) { - val oldConfig = manager.getWorkspaceForId(workspaceId)?.workspace - ?: manager.newWorkspace(owner = call.getUserName()) - manager.update( - oldConfig.copy( - name = legacyWorkspaceConfig.name, - mpsVersion = legacyWorkspaceConfig.mpsVersion, - memoryLimit = legacyWorkspaceConfig.memoryLimit, - gitRepositories = legacyWorkspaceConfig.gitRepositories.map { - org.modelix.workspaces.GitRepository(it.url, null) - }, - mavenRepositories = (legacyWorkspaceConfig.mavenRepositories ?: emptyList()).map { - MavenRepository(it.url) - }, - ), - ) + 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 { @@ -121,18 +105,18 @@ class WorkspacesController( instanceId: String, call: TypedApplicationCall, ) { - val instance = instancesManager.getInstancesList().find { it.instanceConfig.id == instanceId } + val instance = instancesManager.getInstancesMap()[instanceId] if (instance == null) { call.respond(HttpStatusCode.NotFound) } else { - call.respondTyped(instance.instanceConfig) + call.respondTyped(instance) } } override suspend fun listInstances(workspaceId: String?, call: TypedApplicationCall) { - val allInstances = instancesManager.getInstancesList() + val allInstances = instancesManager.getInstancesMap().values val filteredInstances = if (workspaceId != null) { - allInstances.filter { it.workspaceConfig.id == workspaceId } + allInstances.filter { it.config.id == workspaceId } } else { allInstances } @@ -140,7 +124,7 @@ class WorkspacesController( call.respondTyped( WorkspaceInstanceList( instances = filteredInstances.map { - it.instanceConfig.copy(state = states[it.instanceConfig.id]?.deriveState() ?: WorkspaceInstanceState.UNKNOWN) + it.copy(state = states[it.id]?.deriveState() ?: WorkspaceInstanceState.UNKNOWN) }, ), ) @@ -164,33 +148,24 @@ class WorkspacesController( } } - val workspaceConfig = manager.getWorkspaceForId(workspaceInstance.config.id)?.workspace - if (workspaceConfig == null) { - call.respond(HttpStatusCode.NotFound, "Workspace ${workspaceInstance.config.id} not found") - return - } - - instancesManager.updateInstancesList { list -> - list.filter { it.instanceId != workspaceInstance.id } + InternalWorkspaceInstanceConfig( - instanceConfig = workspaceInstance.copy( - id = UUID.randomUUID().toString(), - drafts = emptyList(), - owner = call.getUserName(), - state = WorkspaceInstanceState.CREATED, - readonly = readonly, - ), - workspaceConfig = workspaceConfig.merge(workspaceInstance.config), - ) + 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.updateInstancesList { list -> - list.filter { it.instanceId != instanceId } - } + instancesManager.updateInstancesMap { it - instanceId } + call.respond(HttpStatusCode.OK) } }) @@ -200,41 +175,13 @@ class WorkspacesController( workspaceInstanceEnabled: WorkspaceInstanceEnabled, call: ApplicationCall, ) { - instancesManager.updateInstancesList { list -> - list.map { - if (it.instanceId == instanceId) { - it.copy( - instanceConfig = it.instanceConfig.copy( - enabled = workspaceInstanceEnabled.enabled, - ), - ) - } else { - it - } - } + instancesManager.updateInstancesMap { instances -> + instances.plus(instanceId to instances.getValue(instanceId).copy(enabled = workspaceInstanceEnabled.enabled)) } call.respond(HttpStatusCode.OK) } }) - modelixWorkspacesDraftsRoutes(object : ModelixWorkspacesDraftsController { - override suspend fun getDraft( - draftId: String, - call: TypedApplicationCall, - ) { - val draft = drafts.drafts.find { it.id == draftId } - if (draft == null) { - call.respond(HttpStatusCode.NotFound) - } else { - call.respondTyped(draft) - } - } - - override suspend fun listDrafts(call: TypedApplicationCall) { - call.respondTyped(drafts) - } - }) - modelixWorkspacesTasksConfigRoutes(object : ModelixWorkspacesTasksConfigController { override suspend fun getWorkspaceByTaskId( taskId: String, @@ -283,26 +230,26 @@ class WorkspacesController( }) } - private suspend fun respondBuildContext(call: ApplicationCall, workspace: InternalWorkspaceConfig, taskId: UUID) { + 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 ?: DEFAULT_MPS_VERSION +// 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 = Quantity.fromString(workspace.memoryLimit).number + val containerMemoryBytes = workspace.memoryLimit.toBigDecimal() var maxHeapSizeBytes = heapSizeFromContainerLimit(containerMemoryBytes) val maxHeapSizeMega = (maxHeapSizeBytes / 1024.toBigDecimal() / 1024.toBigDecimal()).toBigInteger() @@ -369,20 +316,19 @@ class WorkspacesController( 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 { - manager.credentialsEncryption.decrypt(it) - }?.let { - """ -c http.extraheader="Authorization: Basic ${(it.user + ":" + it.password).encodeBase64()}"""" + 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}", + "git$authHeader clone \"${git.url}\"", "cd *", - "git checkout " + (git.commitHash ?: ("origin/" + git.branch)), + "git checkout -b \"${git.branch}\" \"${git.commitHash}\"", + "git branch --set-upstream-to=\"origin/${git.branch}\"", ) - }.joinToString(" && ") + }.ifEmpty { listOf("true") }.joinToString(" && ") } then echo "### DONE build-gitClone ###" @@ -393,70 +339,3 @@ class WorkspacesController( } } } - -fun InternalWorkspaceConfig.convert() = WorkspaceConfig( - id = id, - name = name ?: "", - mpsVersion = mpsVersion ?: DEFAULT_MPS_VERSION, - memoryLimit = memoryLimit, - gitRepositories = gitRepositories.map { GitRepository(it.url, null) }, - mavenRepositories = mavenRepositories.map { org.modelix.services.workspaces.stubs.models.MavenRepository(it.url) }, - mavenArtifacts = mavenDependencies.map { - val parts = it.split(":") - MavenArtifact( - groupId = parts[0], - artifactId = parts[1], - version = parts.getOrNull(2), - ) - }, -) - -fun WorkspaceConfig.convert() = InternalWorkspaceConfig( - id = id, - name = name, - mpsVersion = mpsVersion, - memoryLimit = memoryLimit, - gitRepositories = gitRepositories.map { org.modelix.workspaces.GitRepository(it.url, null) }, - mavenRepositories = mavenRepositories?.map { MavenRepository(it.url) } ?: emptyList(), - mavenDependencies = mavenArtifacts?.map { "${it.groupId}:${it.artifactId}:${it.version ?: "*"}" } ?: emptyList(), -) - -fun InternalWorkspaceConfig.merge(other: WorkspaceConfig) = copy( - name = other.name.takeIf { it.isNotEmpty() } ?: name, - mpsVersion = other.mpsVersion.takeIf { it.isNotEmpty() } ?: mpsVersion, - memoryLimit = other.memoryLimit.takeIf { it.isNotEmpty() } ?: memoryLimit, - gitRepositories = gitRepositories.merge(other.gitRepositories), - mavenRepositories = (mavenRepositories.map { it.url } + other.mavenRepositories.orEmpty().map { it.url }).distinct().map { MavenRepository(it) }, - mavenDependencies = (mavenDependencies + (other.mavenArtifacts ?: emptyList()).map { "${it.groupId}:${it.artifactId}:${it.version ?: "*"}" }).distinct(), - ignoredModules = (ignoredModules + other.buildConfig?.ignoredModules.orEmpty()).distinct(), - additionalGenerationDependencies = (additionalGenerationDependencies + other.buildConfig?.additionalGenerationDependencies.orEmpty().map { it.convert() }).distinct(), - loadUsedModulesOnly = other.runConfig?.loadUsedModulesOnly ?: loadUsedModulesOnly, -) - -fun List.merge(other: List): List { - val oldEntries = associateBy { it.url } - val newEntries = other.associateBy { it.url } - - return (oldEntries.keys + newEntries.keys).map { url -> - val oldEntry = oldEntries[url] - val newEntry = newEntries[url] - org.modelix.workspaces.GitRepository( - url = url, - name = oldEntry?.name, - branch = oldEntry?.branch ?: "master", - commitHash = oldEntry?.commitHash, - paths = oldEntry?.paths ?: emptyList(), - credentials = newEntry?.credentials?.convert() ?: oldEntry?.credentials, - ) - } -} - -fun GitCredentials.convert() = Credentials( - user = username, - password = password, -) - -fun org.modelix.services.workspaces.stubs.models.GenerationDependency.convert() = GenerationDependency( - from = from, - to = to, -) diff --git a/workspaces/src/main/kotlin/org/modelix/workspaces/InternalWorkspaceConfig.kt b/workspaces/src/main/kotlin/org/modelix/workspaces/InternalWorkspaceConfig.kt index d3159feb..a4b7c301 100644 --- a/workspaces/src/main/kotlin/org/modelix/workspaces/InternalWorkspaceConfig.kt +++ b/workspaces/src/main/kotlin/org/modelix/workspaces/InternalWorkspaceConfig.kt @@ -28,6 +28,7 @@ data class InternalWorkspaceConfig( val mpsVersion: String? = null, val memoryLimit: String = "2Gi", val modelRepositories: List = listOf(), + val gitRepositoryIds: List? = null, val gitRepositories: List = listOf(), val mavenRepositories: List = listOf(), val mavenDependencies: List = listOf(), 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)) From dac03da7b443e0491a7d76e0b4f3b315331e9e0c Mon Sep 17 00:00:00 2001 From: Sascha Lisson Date: Mon, 19 May 2025 13:16:12 +0200 Subject: [PATCH 13/16] feat: maven artifacts editor for workspaces --- .../WorkspaceConfigForBuildExtensions.kt | 2 +- .../modelix/workspace/manager/KestraClient.kt | 3 +- .../workspace/manager/WorkspaceManager.kt | 9 +- .../manager/WorkspaceManagerModule.kt | 97 +++---------------- .../workspace/manager/WorkspacesController.kt | 14 +-- 5 files changed, 26 insertions(+), 99 deletions(-) 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 index 9aca5b8f..8972c5dd 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/services/workspaces/WorkspaceConfigForBuildExtensions.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/services/workspaces/WorkspaceConfigForBuildExtensions.kt @@ -27,7 +27,7 @@ fun WorkspaceInstance.configForBuild(gitManager: GitConnectorManager) = Workspac MavenRepositoryForBuild( url = it.url, username = null, - password = null + password = null, ) }.toSet(), mavenArtifacts = config.mavenArtifacts.orEmpty().map { "${it.groupId}:${it.artifactId}:${it.version ?: "*"}" }.toSet(), 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 index 9093e18a..df39559e 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/KestraClient.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/KestraClient.kt @@ -1,7 +1,6 @@ package org.modelix.workspace.manager import io.ktor.client.HttpClient -import io.ktor.client.HttpClientConfig import io.ktor.client.call.body import io.ktor.client.engine.cio.CIO import io.ktor.client.plugins.contentnegotiation.ContentNegotiation @@ -194,4 +193,4 @@ class KestraClient(val jwtUtil: ModelixJWTUtil) { } } } -} \ No newline at end of file +} 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 8dfb60ea..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 @@ -41,9 +41,10 @@ class WorkspaceManager(val credentialsEncryption: CredentialsEncryption) { 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) @@ -86,9 +87,10 @@ class WorkspaceManager(val credentialsEncryption: CredentialsEncryption) { } } - fun getAllWorkspaces() = data.getValue().workspaces.values.toList() + fun getAllWorkspaces() = data.getValue().workspaces.values.toList() + // fun getWorkspaceIds() = workspacePersistence.getWorkspaceIds() - fun getWorkspace(workspaceId: String) = data.getValue().workspaces[workspaceId] + fun getWorkspace(workspaceId: String) = data.getValue().workspaces[workspaceId] // fun getWorkspaceForHash(workspaceHash: WorkspaceHash) = workspacePersistence.getWorkspaceForHash(workspaceHash) // fun newWorkspace(owner: String?): InternalWorkspaceConfig { // val newWorkspace = workspacePersistence.newWorkspace() @@ -120,4 +122,3 @@ class WorkspaceManager(val credentialsEncryption: CredentialsEncryption) { // 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 6ea6aed9..d0f11363 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,29 +14,16 @@ 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.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 @@ -45,83 +32,28 @@ import io.ktor.server.plugins.cors.routing.CORS import io.ktor.server.plugins.origin import io.ktor.server.request.httpMethod import io.ktor.server.request.path -import io.ktor.server.request.receiveMultipart -import io.ktor.server.request.receiveParameters -import io.ktor.server.response.respond 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.DeploymentsProxy -import org.modelix.model.persistent.HashUtil -import org.modelix.model.server.ModelServerPermissionSchema import org.modelix.services.gitconnector.GitConnectorController import org.modelix.services.gitconnector.GitConnectorManager import org.modelix.services.mavenconnector.stubs.controllers.ModelixMavenConnectorController @@ -132,23 +64,16 @@ import org.modelix.services.mavenconnector.stubs.controllers.TypedApplicationCal 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.workspace.manager.WorkspaceJobQueue.Companion.HELM_PREFIX 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.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() @@ -301,14 +226,14 @@ fun Application.workspaceManagerModule() { // } // } // // 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)}/project" +// // text("Open Web Interface") +// // } +// // } +// // } // td { // if (canRead) { // a { @@ -550,9 +475,9 @@ fun Application.workspaceManagerModule() { // 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)}/project") { +"Open Web Interface" } +// // } // div("menuItem") { // a("../../${workspaceInstanceUrl(workspaceAndHash)}/ide/?waitForIndexer=true") { +"Open MPS" } // } diff --git a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspacesController.kt b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspacesController.kt index 20438a3a..7356b319 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspacesController.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspacesController.kt @@ -150,12 +150,14 @@ class WorkspacesController( val id = UUID.randomUUID().toString() instancesManager.updateInstancesMap { instances -> - instances.plus(id to workspaceInstance.copy( - id = id, - owner = call.getUserName(), - state = WorkspaceInstanceState.CREATED, - readonly = readonly, - )) + instances.plus( + id to workspaceInstance.copy( + id = id, + owner = call.getUserName(), + state = WorkspaceInstanceState.CREATED, + readonly = readonly, + ), + ) } call.respondTyped(instancesManager.getInstancesMap().getValue(id)) } From 528e94804f9dac4d0114decae3f412d7895ad89f Mon Sep 17 00:00:00 2001 From: Sascha Lisson Date: Wed, 11 Jun 2025 11:23:54 +0200 Subject: [PATCH 14/16] feat: prepare draft branch before starting a workspace instance --- gradle/libs.versions.toml | 2 +- .../client/WorkspaceClientStartupActivity.kt | 16 ++--- .../gitconnector/DraftPreparationTask.kt | 33 ++++++++++ .../gitconnector/GitConnectorManager.kt | 60 ++++++++++++++++-- .../services/gitconnector/GitImportTask.kt | 63 +++++++++++++++++++ .../workspaces}/WorkspacesController.kt | 24 +++++-- .../modelix/workspace/manager/KestraClient.kt | 48 ++++++++++---- .../manager/WorkspaceInstancesManager.kt | 34 +++++++--- .../manager/WorkspaceManagerModule.kt | 17 ++++- 9 files changed, 258 insertions(+), 39 deletions(-) create mode 100644 workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/DraftPreparationTask.kt create mode 100644 workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/GitImportTask.kt rename workspace-manager/src/main/kotlin/org/modelix/{workspace/manager => services/workspaces}/WorkspacesController.kt (93%) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 56f6fff0..a3394e37 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -56,7 +56,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 = "1.1.0" } +modelix-api-server-stubs = { group = "org.modelix", name = "api-server-stubs-ktor", version = "1.2.0" } [bundles] ktor-client = [ 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-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/GitConnectorManager.kt b/workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/GitConnectorManager.kt index 443346a4..5a025a0c 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/GitConnectorManager.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/GitConnectorManager.kt @@ -1,10 +1,14 @@ 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.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 @@ -17,16 +21,62 @@ class GitConnectorManager( ), defaultState = { GitConnectorData() }, ).state, + val modelClient: IModelClientV2, + val kestraClient: KestraClient, ) { + + private val importTasks = ReusableTasks() + private val draftTasks = 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" + } + val key = GitImportTask.Key( + repo = repo.copy(status = null), + gitBranchName = gitBranchName, + gitRevision = gitRevision, + modelixBranchName = "git-import/$gitBranchName", + ) + return importTasks.getOrCreateTask(key) { + GitImportTask( + key = key, + scope = scope, + kestraClient = kestraClient, + modelClient = modelClient, + ) + } + } + + fun getOrCreateDraftPreparationTask(draftId: String): DraftPreparationTask { + val key = DraftPreparationTask.Key( + draftId = draftId, + ) + + return draftTasks.getOrCreateTask(key) { + DraftPreparationTask( + scope = scope, + key = key, + gitManager = this, + modelClient = modelClient, + ) + } + } + suspend fun updateRemoteBranches(repository: GitRepositoryConfig): List { - val repositoryId = repository.id + val gitRepositoryId = repository.id val fetchTask = GitFetchTask(scope, repository) val resultResult = fetchTask.waitForOutput() return connectorData.update { - val oldRepositoryData = it.repositories[repositoryId] ?: return@update it + val oldRepositoryData = it.repositories[gitRepositoryId] ?: return@update it val newRepositoryData = oldRepositoryData.merge(resultResult.remoteRefs) - it.copy(repositories = it.repositories + (repositoryId to newRepositoryData)) - }.repositories[repositoryId]?.status?.branches ?: emptyList() + it.copy(repositories = it.repositories + (gitRepositoryId to newRepositoryData)) + }.repositories[gitRepositoryId]?.status?.branches.orEmpty() } fun getRepository(id: String): GitRepositoryConfig? { @@ -35,3 +85,5 @@ class GitConnectorManager( fun getDraft(id: String) = connectorData.getValue().drafts[id] } + +fun GitRepositoryConfig.getModelixRepositoryId() = RepositoryId((modelixRepository ?: id)) 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..8464574a --- /dev/null +++ b/workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/GitImportTask.kt @@ -0,0 +1,63 @@ +package org.modelix.services.gitconnector + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import org.modelix.model.IVersion +import org.modelix.model.client2.IModelClientV2 +import org.modelix.model.lazy.RepositoryId +import org.modelix.services.gitconnector.stubs.models.GitRepositoryConfig +import org.modelix.workspace.manager.KestraClient +import org.modelix.workspace.manager.TaskInstance +import kotlin.time.Duration.Companion.seconds + +class GitImportTask( + val key: Key, + scope: CoroutineScope, + val kestraClient: KestraClient, + val modelClient: IModelClientV2, +) : 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) + } + } + + data class Key( + val repo: GitRepositoryConfig, + val gitBranchName: String, + val gitRevision: String, + val modelixBranchName: String, + ) +} + +val IVersion.gitCommit: String? get() = getAttributes()["git-commit"] diff --git a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspacesController.kt b/workspace-manager/src/main/kotlin/org/modelix/services/workspaces/WorkspacesController.kt similarity index 93% rename from workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspacesController.kt rename to workspace-manager/src/main/kotlin/org/modelix/services/workspaces/WorkspacesController.kt index 7356b319..05daad1c 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspacesController.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/services/workspaces/WorkspacesController.kt @@ -1,4 +1,4 @@ -package org.modelix.workspace.manager +package org.modelix.services.workspaces import io.ktor.http.HttpStatusCode import io.ktor.server.application.ApplicationCall @@ -30,7 +30,13 @@ 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.WorkspaceJobQueue.Companion.HELM_PREFIX +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 @@ -92,7 +98,13 @@ class WorkspacesController( 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", + memoryLimit = workspaceConfig.memoryLimit?.takeIf { it.isNotEmpty() }?.let { + runCatching { + Quantity( + it, + ).toSuffixedString() + }.getOrNull() + } ?: "2Gi", ) manager.putWorkspace(newWorkspace) call.getUserName()?.let { manager.assignOwner(newWorkspace.id, it) } @@ -258,11 +270,11 @@ class WorkspacesController( 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 + 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://${HELM_PREFIX}workspace-manager:28104/ + 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 @@ -284,7 +296,7 @@ class WorkspacesController( RUN mkdir /config/home/job \ && cd /config/home/job \ - && wget -q "http://${HELM_PREFIX}workspace-manager:28104/static/workspace-job.tar" \ + && 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 \ 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 index df39559e..59226721 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/KestraClient.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/KestraClient.kt @@ -24,6 +24,7 @@ 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 @@ -42,13 +43,17 @@ class KestraClient(val jwtUtil: ModelixJWTUtil) { } 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.append("labels", "workspace:$workspaceId") + parameters.appendAll("labels", labels.map { "${it.key}:${it.value}" }) parameters.append("state", "CREATED") parameters.append("state", "QUEUED") parameters.append("state", "RUNNING") @@ -64,15 +69,31 @@ class KestraClient(val jwtUtil: ModelixJWTUtil) { 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 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, + ModelServerPermissionSchema.repository(modelixBranch.repositoryId).create.fullId, + ModelServerPermissionSchema.branch(modelixBranch).rewrite.fullId, ), ) @@ -80,20 +101,21 @@ class KestraClient(val jwtUtil: ModelixJWTUtil) { url { takeFrom(kestraApiEndpoint) appendPathSegments("executions", "modelix", "git_import") - parameters["labels"] = "workspace:${workspace.id}" + if (labels.isNotEmpty()) { + parameters.appendAll("labels", labels.map { "${it.key}:${it.value}" }) + } } 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("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) - gitRepo.credentials?.also { credentials -> - append("git_user", credentials.user) - append("git_pw", credentials.password) - } + gitUser?.let { append("git_user", it) } + gitPassword?.let { append("git_pw", it) } }, ), ) 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 index 6208e298..f69a917f 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceInstancesManager.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceInstancesManager.kt @@ -24,6 +24,7 @@ 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 @@ -48,6 +49,7 @@ private data class InstancesManagerState( class WorkspaceInstanceStateValues( var imageTaskState: TaskState? = null, var image: Result? = null, + var draftBranches: List?> = emptyList(), var deployment: V1Deployment? = null, var pod: V1Pod? = null, var enabled: Boolean = false, @@ -58,7 +60,7 @@ class WorkspaceInstanceStateValues( (deployment?.status?.readyReplicas ?: 0) >= 1 -> WorkspaceInstanceState.RUNNING deployment != null -> WorkspaceInstanceState.LAUNCHING image?.isFailure == true -> WorkspaceInstanceState.BUILD_FAILED - image?.getOrNull() != null -> WorkspaceInstanceState.LAUNCHING + image?.getOrNull() != null && draftBranches.all { it?.getOrNull() != null } -> WorkspaceInstanceState.LAUNCHING else -> when (imageTaskState) { null -> WorkspaceInstanceState.CREATED TaskState.CANCELLED -> WorkspaceInstanceState.BUILD_FAILED @@ -136,6 +138,10 @@ class WorkspaceInstancesManager( val imageTask = buildManager.getOrCreateWorkspaceImageTask(config.configForBuild(gitManager)) values.imageTaskState = imageTask.getState() values.image = imageTask.getOutput() + + values.draftBranches = config.drafts.orEmpty().map { draftId -> + gitManager.getOrCreateDraftPreparationTask(draftId).also { it.launch() } + }.map { it.getOutput() } } return stateValues @@ -201,9 +207,15 @@ class WorkspaceInstancesManager( 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) { - createDeployment(instance, image) + if (image != null && draftBranches.all { it != null }) { + createDeployment(instance, image, draftBranches.map { it!! }) createService(instance) } } catch (e: Exception) { @@ -299,6 +311,7 @@ class WorkspaceInstancesManager( suspend fun createDeployment( workspaceInstance: WorkspaceInstance, image: ImageNameAndTag, + draftBranches: List, ): V1Deployment { val instanceName = workspaceInstance.instanceName() val workspaceId = workspaceInstance.config.id @@ -324,17 +337,24 @@ class WorkspaceInstancesManager( replicas(1) template.spec!!.containers[0].apply { addEnvItem(V1EnvVar().name("modelix_workspace_id").value(workspaceId)) - addEnvItem(V1EnvVar().name("REPOSITORY_ID").value("workspace_$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(false.toString())) + 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 - newPermissions += ModelServerPermissionSchema.repository("workspace_" + workspaceId) - .let { if (hasWritePermission) it.write else it.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)) 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 d0f11363..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 @@ -53,7 +53,9 @@ import org.apache.commons.compress.archivers.tar.TarArchiveEntry import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream import org.modelix.authorization.ModelixAuthorization import org.modelix.authorization.permissions.PermissionParts +import org.modelix.authorization.permissions.PermissionSchemaBase import org.modelix.instancesmanager.DeploymentsProxy +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 @@ -64,6 +66,7 @@ import org.modelix.services.mavenconnector.stubs.controllers.TypedApplicationCal 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 @@ -81,7 +84,19 @@ fun Application.workspaceManagerModule() { // val deploymentManager = DeploymentManager(manager) val buildManager = WorkspaceBuildManager(this, manager.workspaceJobTokenGenerator) val maxBodySize = environment.config.property("modelix.maxBodySize").getString() - val gitManager = GitConnectorManager(this) + 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) From 965b25ad85eff7fe07317b0f8379a1c43229c7a7 Mon Sep 17 00:00:00 2001 From: Sascha Lisson Date: Fri, 27 Jun 2025 10:49:48 +0200 Subject: [PATCH 15/16] feat: more detailed status text for workspace instances --- .../workspaces/WorkspacesController.kt | 5 ++- .../manager/WorkspaceInstancesManager.kt | 35 ++++++++++++++++--- 2 files changed, 35 insertions(+), 5 deletions(-) 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 index 05daad1c..c4dde043 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/services/workspaces/WorkspacesController.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/services/workspaces/WorkspacesController.kt @@ -136,7 +136,10 @@ class WorkspacesController( call.respondTyped( WorkspaceInstanceList( instances = filteredInstances.map { - it.copy(state = states[it.id]?.deriveState() ?: WorkspaceInstanceState.UNKNOWN) + it.copy( + state = states[it.id]?.deriveState() ?: WorkspaceInstanceState.UNKNOWN, + statusText = states[it.id]?.statusText(), + ) }, ), ) 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 index f69a917f..047803bf 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceInstancesManager.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceInstancesManager.kt @@ -47,14 +47,15 @@ private data class InstancesManagerState( ) class WorkspaceInstanceStateValues( - var imageTaskState: TaskState? = null, - var image: Result? = null, + 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 @@ -71,6 +72,33 @@ class WorkspaceInstanceStateValues( } } } + + 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( @@ -136,8 +164,7 @@ class WorkspaceInstancesManager( values.enabled = config.enabled val imageTask = buildManager.getOrCreateWorkspaceImageTask(config.configForBuild(gitManager)) - values.imageTaskState = imageTask.getState() - values.image = imageTask.getOutput() + values.imageTask = imageTask values.draftBranches = config.drafts.orEmpty().map { draftId -> gitManager.getOrCreateDraftPreparationTask(draftId).also { it.launch() } From 776e60501127f3e67933338e6c19a554167c8063 Mon Sep 17 00:00:00 2001 From: Sascha Lisson Date: Wed, 2 Jul 2025 09:20:41 +0200 Subject: [PATCH 16/16] feat: draft branch can be merged with changes from git --- gradle/libs.versions.toml | 3 +- workspace-client-plugin/build.gradle.kts | 2 +- .../services/gitconnector/DraftRebaseTask.kt | 38 +++ .../gitconnector/GitConnectorController.kt | 93 ++++++++ .../gitconnector/GitConnectorManager.kt | 63 ++++- .../services/gitconnector/GitImportTask.kt | 102 +++++++- .../workspaces/KubernetesApiExtensions.kt | 3 + .../workspace/manager/KubernetesJobTask.kt | 129 ++++++++++ .../modelix/workspace/manager/TaskInstance.kt | 19 +- .../manager/WorkspaceBuildManager.kt | 222 ------------------ .../workspace/manager/WorkspaceImageTask.kt | 161 +++++++++++++ 11 files changed, 588 insertions(+), 247 deletions(-) create mode 100644 workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/DraftRebaseTask.kt create mode 100644 workspace-manager/src/main/kotlin/org/modelix/workspace/manager/KubernetesJobTask.kt create mode 100644 workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceImageTask.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a3394e37..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" } @@ -56,7 +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 = "1.2.0" } +modelix-api-server-stubs = { group = "org.modelix", name = "api-server-stubs-ktor", version.ref = "modelix-openapi" } [bundles] ktor-client = [ 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-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 index 1b7554c3..c8168336 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/GitConnectorController.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/GitConnectorController.kt @@ -3,10 +3,15 @@ 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 @@ -20,12 +25,15 @@ import org.modelix.services.gitconnector.stubs.controllers.ModelixGitConnectorRe 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 @@ -211,6 +219,91 @@ class GitConnectorController(val manager: GitConnectorManager) { 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) + } + }) } } 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 index 5a025a0c..98539575 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/GitConnectorManager.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/GitConnectorManager.kt @@ -3,6 +3,7 @@ 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 @@ -26,7 +27,8 @@ class GitConnectorManager( ) { private val importTasks = ReusableTasks() - private val draftTasks = ReusableTasks() + val draftPreparationTasks = ReusableTasks() + private val draftRebaseTasks = ReusableTasks() fun getOrCreateImportTask(gitRepoId: String, gitBranchName: String): GitImportTask { val data = connectorData.getValue() @@ -37,18 +39,26 @@ class GitConnectorManager( 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 = repo.copy(status = null), + repo = gitRepo.copy(status = null), gitBranchName = gitBranchName, gitRevision = gitRevision, modelixBranchName = "git-import/$gitBranchName", ) - return importTasks.getOrCreateTask(key) { - GitImportTask( - key = key, + return getOrCreateImportTask(key) + } + + fun getOrCreateImportTask(taskKey: GitImportTask.Key): GitImportTask { + return importTasks.getOrCreateTask(taskKey) { + GitImportTaskUsingKubernetesJob( + key = taskKey, scope = scope, - kestraClient = kestraClient, modelClient = modelClient, + jwtUtil = kestraClient.jwtUtil, ) } } @@ -58,7 +68,7 @@ class GitConnectorManager( draftId = draftId, ) - return draftTasks.getOrCreateTask(key) { + return draftPreparationTasks.getOrCreateTask(key) { DraftPreparationTask( scope = scope, key = key, @@ -84,6 +94,45 @@ class GitConnectorManager( } 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/GitImportTask.kt b/workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/GitImportTask.kt index 8464574a..af95040b 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/GitImportTask.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/services/gitconnector/GitImportTask.kt @@ -1,21 +1,110 @@ 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 -class GitImportTask( - val key: Key, +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, -) : TaskInstance(scope) { +) : GitImportTask, TaskInstance(scope) { private val repoId = requireNotNull(key.repo.modelixRepository?.let { RepositoryId(it) }) { "Repository ID missing" } private val branchRef = repoId.getBranchReference(key.modelixBranchName) @@ -51,13 +140,6 @@ class GitImportTask( delay(3.seconds) } } - - data class Key( - val repo: GitRepositoryConfig, - val gitBranchName: String, - val gitRevision: String, - val modelixBranchName: String, - ) } val IVersion.gitCommit: String? get() = getAttributes()["git-commit"] 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 index 8fe2ce7a..a848a088 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/services/workspaces/KubernetesApiExtensions.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/services/workspaces/KubernetesApiExtensions.kt @@ -20,6 +20,9 @@ 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) { 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/TaskInstance.kt b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/TaskInstance.kt index 1fcf373d..365a74d9 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/TaskInstance.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/TaskInstance.kt @@ -5,26 +5,33 @@ import kotlinx.coroutines.Deferred import kotlinx.coroutines.async import java.util.UUID -abstract class TaskInstance(val scope: CoroutineScope) { +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 - fun launch(): Deferred { + override fun launch(): Deferred { return job ?: scope.async { runCatching { process() }.also { result = it }.getOrThrow() }.also { job = it } } - fun getOutput(): Result? = result + override fun getOutput(): Result? = result - suspend fun waitForOutput(): R { + override suspend fun waitForOutput(): R { return launch().await() } - fun getState() = job.let { + override fun getState(): TaskState = job.let { when { it == null -> TaskState.CREATED it.isCompleted -> TaskState.COMPLETED @@ -35,7 +42,7 @@ abstract class TaskInstance(val scope: CoroutineScope) { } } -class ReusableTasks> { +class ReusableTasks> { private val tasks = LinkedHashMap() fun getOrCreateTask(key: K, factory: (K) -> V): V { 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 index 5ed0e4f4..40b21817 100644 --- a/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceBuildManager.kt +++ b/workspace-manager/src/main/kotlin/org/modelix/workspace/manager/WorkspaceBuildManager.kt @@ -1,29 +1,8 @@ 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.apis.BatchV1Api -import io.kubernetes.client.openapi.models.V1Job -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.toValidImageTag -import org.modelix.workspace.manager.WorkspaceJobQueue.Companion.HELM_PREFIX -import org.modelix.workspace.manager.WorkspaceJobQueue.Companion.JOB_IMAGE -import org.modelix.workspace.manager.WorkspaceJobQueue.Companion.KUBERNETES_NAMESPACE import org.modelix.workspaces.WorkspaceConfigForBuild -import org.modelix.workspaces.hash import java.util.UUID -import kotlin.coroutines.suspendCoroutine -import kotlin.time.Duration.Companion.minutes private val LOG = mu.KotlinLogging.logger { } @@ -52,204 +31,3 @@ enum class TaskState { COMPLETED, UNKNOWN, } - -class WorkspaceBaseImageTask(val mpsVersion: String, scope: CoroutineScope) : TaskInstance(scope) { - private val resultImage = ImageNameAndTag( - "modelix/workspace-client-baseimage", - "${System.getenv("MPS_BASEIMAGE_VERSION")}-mps$mpsVersion", - ) - override suspend fun process(): ImageNameAndTag { - return resultImage - } -} - -data class ImageNameAndTag(val name: String, val tag: String) { - override fun toString(): String = "$name:$tag" -} - -class WorkspaceImageTask( - val workspaceConfig: WorkspaceConfigForBuild, - val tokenGenerator: (WorkspaceConfigForBuild) -> String, - scope: CoroutineScope, -) : TaskInstance(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 process(): ImageNameAndTag { - withTimeout(30.minutes) { - if (checkImageExists(resultImage)) return@withTimeout - - findJob()?.let { deleteJob(it) } - createJob() - - var jobFailureConfirmations = 0 - while (true) { - delay(1000) - - if (checkImageExists(resultImage)) break - - if (findJob() == null) { - jobFailureConfirmations++ - } else { - jobFailureConfirmations = 0 - } - if (jobFailureConfirmations > 10 && !checkImageExists(resultImage)) { - throw IllegalStateException("Job finished without uploading the result image") - } - } - } - return resultImage - } - - private suspend fun createJob() { - suspendCoroutine { - val yamlString = generateJobYaml() - BatchV1Api().createNamespacedJob( - KUBERNETES_NAMESPACE, - Yaml.loadAs(yamlString, V1Job::class.java), - ).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)) - } - } - - @Suppress("ktlint") - fun generateJobYaml(): String { - 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: $JOB_IMAGE - env: - - name: TARGET_REGISTRY - value: ${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://${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://${HELM_PREFIX}workspace-manager:28104/ - - name: INITIAL_JWT_TOKEN - value: $jwtToken - - name: BASEIMAGE_CONTEXT_URL - value: http://${HELM_PREFIX}workspace-manager:28104/baseimage/$mpsVersion/context.tar.gz - - name: BASEIMAGE_TARGET - value: ${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() - } -} - -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/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()}") + } + } +}