Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
b24e1a6
CONTNM-81: added continuum-cluster-manager service
projectcontinuum Apr 2, 2026
ab752b9
CONTNM-81: add more tests
projectcontinuum Apr 2, 2026
0d003fe
CONTNM-81: add continuum-cluster-manager to build
projectcontinuum Apr 2, 2026
60e55f1
CONTNM-81: add suspend and resume endpoints
projectcontinuum Apr 3, 2026
2baa328
CONTNM-81: Transactional db and k8s with rollback
roushan65 Apr 3, 2026
a698214
CONTNM-81: namespace and image should be passed as env/config
roushan65 Apr 3, 2026
f662b52
CONTNM-81: add audit logs
roushan65 Apr 3, 2026
79163b2
CONTNM-81: fix unit-tests
roushan65 Apr 3, 2026
ef0103d
CONTNM-81: update release workflow to build and push cluster-manager
roushan65 Apr 3, 2026
141e8a9
CONTNM-81: added langing-page module for SSO
roushan65 Apr 3, 2026
bd59c58
CONTNM-81: make shadow follow the mouse pointer
roushan65 Apr 3, 2026
9d54d5f
CONTNM-81: added Landing page module
roushan65 Apr 3, 2026
e26ad5f
CONTNM-81: use project.name to name image
roushan65 Apr 3, 2026
ea27157
CONTNM-81: use project.name to name image
roushan65 Apr 3, 2026
0f1f663
CONTNM-81: Fix nginx permissions
roushan65 Apr 3, 2026
7fd2805
redirect to keycloak page
roushan65 Apr 4, 2026
4f17260
CONTNM-81: serving landing page with express
roushan65 Apr 4, 2026
a0e125a
CONTNM-81: showing userInfo on login
roushan65 Apr 4, 2026
6eae410
CONTNM-81: add reverse proxy support workbench
roushan65 Apr 5, 2026
d882bb3
CONTNM-81: explicit io.fabric8:mockwebserver:7.1.0 test dependency
roushan65 Apr 5, 2026
bfa1963
CONTNM-81: use HTTP_1_1 for reverse proxy
roushan65 Apr 5, 2026
3c7bb40
CONTNM-81: move ui inside cluster-manager module
roushan65 Apr 5, 2026
62c2bba
CONTNM-81: server frontend from cluster-manager statically
roushan65 Apr 5, 2026
2ac49b7
CONTNM-81: open button should point to /open endpoint
roushan65 Apr 5, 2026
5eae214
CONTNM-81: make resource config more intuitive
roushan65 Apr 5, 2026
747a1ea
CONTNM-81: hide extra details of instance under "Mode Details"
roushan65 Apr 5, 2026
87469be
CONTNM-81: disable open button untill workbench is ready
roushan65 Apr 5, 2026
90363f8
CONTNM-81: ask user to type workspace name in text box
roushan65 Apr 5, 2026
0dadb1a
CONTNM-81: add continuum-cloud-gateway for reverse proxy
projectcontinuum Apr 8, 2026
6f01d57
CONTNM-81: handle reverse proxy for api-server and cluster-manager fr…
projectcontinuum Apr 8, 2026
f0782dc
CONTNM-81: add the cloud-gateway to the github-action build pipeline
projectcontinuum Apr 8, 2026
20eca57
CONTNM-81: route all /ui to cluster-manager
projectcontinuum Apr 8, 2026
70508a6
CONTNM-81: push all the apis of cluster-manager under /cluster-manage…
projectcontinuum Apr 8, 2026
914f3df
CONTNM-81: fix workbench liveness check path
projectcontinuum Apr 8, 2026
fb57642
CONTNM-81: route all the request to /api-server/** to api server for …
projectcontinuum Apr 9, 2026
331a9dd
CONTNM-81: forward X-Forwarded-Prefix header
projectcontinuum Apr 9, 2026
1b6aa3a
CONTNM-81: fix routing
projectcontinuum Apr 9, 2026
4334cbd
CONTNM-81: Created OpenApiCustomizer beans in both services:
projectcontinuum Apr 10, 2026
13d1c50
CONTNM-81: cluster-manager frontend should open the workspace folder …
projectcontinuum Apr 10, 2026
e9d0c1b
CONTNM-81: preserveRawQueryString
projectcontinuum Apr 10, 2026
bd32122
CONTNM-81: Fixed filter order
projectcontinuum Apr 10, 2026
f31431a
CONTNM-82: eplaced MvcUtils.getRequestUrl(request) with MvcUtils.getA…
projectcontinuum Apr 10, 2026
556d70c
CONTNM-81: Uses a custom proxy handler instead of HandlerFunctions.ht…
projectcontinuum Apr 10, 2026
77a6a87
CONTNM-81: bump the platform to 0.0.11
projectcontinuum Apr 10, 2026
35a3f1e
CONTNM-81: added docker-hub to list workbench-images
projectcontinuum Apr 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/build-module.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,15 @@ on:
- all
- continuum-api-server
- continuum-avro-schemas
- continuum-cloud-gateway
- continuum-commons
- continuum-knime-base
- continuum-message-bridge
- continuum-worker-springboot-starter
- continuum-gradle-plugin
- continuum-orchestration-service
- continuum-cluster-manager
- landing-page
publish_to_maven:
description: 'Publish artifacts to Maven Central'
required: true
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/on-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,21 @@ jobs:
- module: continuum-api-server
publish_to_maven: false
publish_to_docker_hub: true
- module: continuum-cloud-gateway
publish_to_maven: false
publish_to_docker_hub: true
- module: continuum-orchestration-service
publish_to_maven: false
publish_to_docker_hub: true
- module: continuum-message-bridge
publish_to_maven: false
publish_to_docker_hub: true
- module: continuum-cluster-manager
publish_to_maven: false
publish_to_docker_hub: true
- module: landing-page
publish_to_maven: false
publish_to_docker_hub: true
steps:
- name: Checkout repository
uses: actions/checkout@v4
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ out/
### Node.js ###
**/.turbo/
**/node_modules/
**/dist/
**/continuum-workbench/lib/
**/continuum-workbench-thin/lib/
**/workflow-editor-extension/lib/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package org.projectcontinuum.core.api.server.config

import io.swagger.v3.oas.models.servers.Server
import org.springdoc.core.customizers.OpenApiCustomizer
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

@Configuration
class OpenApiConfig {
@Bean
fun relativeServerCustomizer() = OpenApiCustomizer { openApi ->
// Use a relative server URL so Swagger UI resolves API calls correctly
// whether accessed directly or through the cloud-gateway reverse proxy.
openApi.servers = listOf(Server().apply { url = "../" })
}
}
5 changes: 5 additions & 0 deletions continuum-api-server/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,8 @@ continuum.core:
endpoint: ${CONTINUUM_MINIO_ENDPOINT:http://localhost:39000}
access-key: ${CONTINUUM_MINIO_USERNAME:minioadmin}
secret-key: ${CONTINUUM_MINIO_PASSWORD:minioadmin}

springdoc:
swagger-ui:
config-url: ../v3/api-docs/swagger-config
url: ../v3/api-docs
75 changes: 75 additions & 0 deletions continuum-cloud-gateway/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
plugins {
kotlin("jvm") version "2.1.0"
kotlin("plugin.spring") version "1.9.25"
id("org.springframework.boot") version "3.4.0"
id("io.spring.dependency-management") version "1.1.6"
id("com.google.cloud.tools.jib") version "3.4.1"
}

group = "org.projectcontinuum.core"
description = "Continuum Cloud Gateway — reverse proxy for workbench instances, scalable independently"
version = property("platformVersion").toString()

java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}

repositories {
mavenCentral()
}

dependencies {
implementation("org.springframework.boot:spring-boot-starter")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-websocket")
implementation("org.springframework.boot:spring-boot-starter-actuator")

// JSON
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")

// Spring Cloud Gateway Server MVC (servlet-based reverse proxy)
implementation("org.springframework.cloud:spring-cloud-gateway-server-mvc")

// Database (read-only access to workbench_instances for endpoint resolution)
implementation("org.springframework.boot:spring-boot-starter-data-jdbc")
implementation("org.postgresql:postgresql")

// Test dependencies
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testImplementation("com.h2database:h2")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

dependencyManagement {
imports {
mavenBom("org.springframework.cloud:spring-cloud-dependencies:2024.0.1")
}
}

kotlin {
compilerOptions {
freeCompilerArgs.addAll("-Xjsr305=strict")
}
}

tasks.withType<Test> {
useJUnitPlatform()
}

jib {
from {
image = "eclipse-temurin:21-jre"
}

to {
image = "docker.io/projectcontinuum/${project.name.lowercase()}:${project.version}"
auth {
username = System.getenv("DOCKER_REPO_USERNAME") ?: ""
password = System.getenv("DOCKER_REPO_PASSWORD") ?: ""
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package org.projectcontinuum.core.cloud.gateway

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication

@SpringBootApplication
class App

fun main(args: Array<String>) {
runApplication<App>(*args)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package org.projectcontinuum.core.cloud.gateway.config

import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.servlet.config.annotation.CorsRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer

@Configuration
class CorsConfig {
@Bean
fun corsConfigurer(): WebMvcConfigurer {
return object : WebMvcConfigurer {
override fun addCorsMappings(registry: CorsRegistry) {
registry.addMapping("/**")
.allowedOrigins("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package org.projectcontinuum.core.cloud.gateway.config

import jakarta.servlet.http.HttpServletRequest
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.cloud.gateway.server.mvc.handler.GatewayRouterFunctions.route
import org.springframework.cloud.gateway.server.mvc.predicate.GatewayRequestPredicates.path
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.servlet.function.RouterFunction
import org.springframework.web.servlet.function.ServerRequest
import org.springframework.web.servlet.function.ServerResponse
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.time.Duration

// Spring Cloud Gateway Server MVC configuration.
//
// Uses a custom proxy handler instead of HandlerFunctions.http() to work around
// a Spring Cloud Gateway Server MVC bug where percent-encoded query parameters
// are decoded before proxying (e.g., %26 becomes &), which corrupts parameters
// containing special characters like "Aggregation & Grouping".
// See: https://github.com/spring-cloud/spring-cloud-gateway/issues/3082
//
// The custom proxy() function reads the raw URI and query string directly from the
// servlet request (which preserves percent-encoding) and forwards them as-is to
// the backend using Java's HttpClient.
//
// The dynamic workbench proxy routing (/workbench/{instanceName}/open)
// is handled by WorkbenchProxyController and WorkbenchWebSocketProxyHandler.
@Configuration
class GatewayConfig(
@Value("\${CONTINUUM_API_SERVER_URL:http://localhost:8081}")
private val apiServerUrl: String,

@Value("\${CONTINUUM_CLUSTER_MANAGER_URL:http://localhost:8082}")
private val clusterManagerUrl: String,
) {

private val logger = LoggerFactory.getLogger(GatewayConfig::class.java)

private val httpClient: HttpClient = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.connectTimeout(Duration.ofSeconds(30))
.followRedirects(HttpClient.Redirect.NEVER)
.build()

private val HOP_BY_HOP_HEADERS = setOf(
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
"te", "trailers", "transfer-encoding", "upgrade", "host", "content-length"
)

@Bean
fun apiServerRoute(): RouterFunction<ServerResponse> =
route("api-server")
.route(path("/api-server/**")) { request -> proxy(request, apiServerUrl, "/api-server") }
.build()

@Bean
fun clusterManagerRoute(): RouterFunction<ServerResponse> =
route("cluster-manager")
.route(path("/cluster-manager/**")) { request -> proxy(request, clusterManagerUrl, "/cluster-manager") }
.build()

private fun proxy(request: ServerRequest, backendUrl: String, prefixToStrip: String): ServerResponse {
val servletRequest: HttpServletRequest = request.servletRequest()

// Strip the prefix from the raw request URI to preserve percent-encoding
val rawUri = servletRequest.requestURI
val remainingPath = rawUri.removePrefix(prefixToStrip).ifEmpty { "/" }
val rawQuery = servletRequest.queryString?.let { "?$it" } ?: ""
val targetUrl = "$backendUrl$remainingPath$rawQuery"

logger.debug("Proxying {} {} -> {}", servletRequest.method, rawUri, targetUrl)

// Build the proxy request
val proxyBuilder = HttpRequest.newBuilder()
.uri(URI(targetUrl))
.timeout(Duration.ofMinutes(5))

// Forward headers
val headerNames = servletRequest.headerNames
while (headerNames.hasMoreElements()) {
val name = headerNames.nextElement()
if (name.lowercase() in HOP_BY_HOP_HEADERS) continue
val values = servletRequest.getHeaders(name)
while (values.hasMoreElements()) {
proxyBuilder.header(name, values.nextElement())
}
}

// Set method and body
val bodyPublisher = when (servletRequest.method.uppercase()) {
"GET", "HEAD", "OPTIONS", "TRACE" -> HttpRequest.BodyPublishers.noBody()
else -> HttpRequest.BodyPublishers.ofByteArray(servletRequest.inputStream.readAllBytes())
}
proxyBuilder.method(servletRequest.method.uppercase(), bodyPublisher)

// Execute
val proxyResponse = httpClient.send(proxyBuilder.build(), HttpResponse.BodyHandlers.ofByteArray())

// Build the response
val responseBuilder = ServerResponse.status(proxyResponse.statusCode())
proxyResponse.headers().map().forEach { (name, values) ->
if (name.lowercase() !in HOP_BY_HOP_HEADERS) {
values.forEach { value -> responseBuilder.header(name, value) }
}
}
return responseBuilder.body(proxyResponse.body())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package org.projectcontinuum.core.cloud.gateway.config

import org.projectcontinuum.core.cloud.gateway.service.WorkbenchWebSocketProxyHandler
import org.springframework.context.annotation.Configuration
import org.springframework.web.socket.config.annotation.EnableWebSocket
import org.springframework.web.socket.config.annotation.WebSocketConfigurer
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry

@Configuration
@EnableWebSocket
class WebSocketProxyConfig(
private val webSocketProxyHandler: WorkbenchWebSocketProxyHandler
) : WebSocketConfigurer {

override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) {
registry
.addHandler(webSocketProxyHandler, "/workbench/*/open/**")
.setAllowedOrigins("*")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package org.projectcontinuum.core.cloud.gateway.controller

import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.projectcontinuum.core.cloud.gateway.service.WorkbenchProxyService
import org.slf4j.LoggerFactory
import org.springframework.web.bind.annotation.*

// Reverse proxy controller that routes requests from
// /workbench/{instanceName}/open to the actual workbench service endpoint.
//
// This controller:
// - Resolves the workbench instance's K8s service endpoint from the database
// - Validates the requesting user owns the workbench instance
// - Proxies HTTP requests (GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS)
// - Strips the /workbench/{instanceName}/open prefix from the path
//
// WebSocket connections are handled separately by WorkbenchWebSocketProxyHandler.
@RestController
@RequestMapping("/workbench/{instanceName}/open")
class WorkbenchProxyController(
private val workbenchProxyService: WorkbenchProxyService
) {

private val logger = LoggerFactory.getLogger(WorkbenchProxyController::class.java)

// Catch-all handler for all HTTP methods on /workbench/{instanceName}/open
@RequestMapping(value = ["", "/**"])
fun proxyRequest(
@PathVariable instanceName: String,
@RequestHeader("x-continuum-user-id", required = false, defaultValue = "anonymous") userId: String,
request: HttpServletRequest,
response: HttpServletResponse
) {
workbenchProxyService.proxyRequest(instanceName, userId, request, response)
}
}
Loading
Loading