From f550c48099dfb8b851720fb723564fe56b10238d Mon Sep 17 00:00:00 2001 From: denazi Date: Tue, 16 Dec 2025 11:03:37 +0200 Subject: [PATCH 1/3] BE Service #2: Implement authorization BE Service controller and Chat UI --- pom.xml | 8 + .../etsi/osl/mcp/backend/AuthController.java | 115 +++++++ .../backend/WebSecurityConfigKeycloak.java | 55 +++- src/main/resources/application.yaml | 18 +- src/main/resources/public/chat.html | 60 ++++ src/main/resources/public/css/chat.css | 307 ++++++++++++++++++ src/main/resources/public/css/welcome.css | 110 +++++++ .../resources/public/images/logo_clear.png | Bin 0 -> 16566 bytes src/main/resources/public/index.html | 182 +++++++---- src/main/resources/public/js/chat.js | 186 +++++++++++ src/main/resources/public/js/welcome.js | 29 ++ src/main/resources/public/welcome.html | 95 ++++++ 12 files changed, 1094 insertions(+), 71 deletions(-) create mode 100644 src/main/java/org/etsi/osl/mcp/backend/AuthController.java create mode 100644 src/main/resources/public/chat.html create mode 100644 src/main/resources/public/css/chat.css create mode 100644 src/main/resources/public/css/welcome.css create mode 100644 src/main/resources/public/images/logo_clear.png create mode 100644 src/main/resources/public/js/chat.js create mode 100644 src/main/resources/public/js/welcome.js create mode 100644 src/main/resources/public/welcome.html diff --git a/pom.xml b/pom.xml index 49ba05b..dd21939 100644 --- a/pom.xml +++ b/pom.xml @@ -51,6 +51,14 @@ + + org.springframework.boot + spring-boot-starter-oauth2-client + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + org.springframework.boot spring-boot-starter-web diff --git a/src/main/java/org/etsi/osl/mcp/backend/AuthController.java b/src/main/java/org/etsi/osl/mcp/backend/AuthController.java new file mode 100644 index 0000000..1905527 --- /dev/null +++ b/src/main/java/org/etsi/osl/mcp/backend/AuthController.java @@ -0,0 +1,115 @@ +package org.etsi.osl.mcp.backend; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.annotation.RegisteredOAuth2AuthorizedClient; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +@RestController +public class AuthController { + + @Value("${spring.security.oauth2.client.provider.keycloak.issuer-uri:}") + private String issuerUri; + + @GetMapping("/api/user") + public Map getUserInfo(Authentication authentication, + @RegisteredOAuth2AuthorizedClient("keycloak") OAuth2AuthorizedClient authorizedClient) { + Map userInfo = new HashMap<>(); + + if (authentication != null && authentication.isAuthenticated()) { + userInfo.put("authenticated", true); + userInfo.put("name", authentication.getName()); + + String username = authentication.getName(); + if (authentication.getPrincipal() instanceof OidcUser oidcUser) { + userInfo.put("email", oidcUser.getEmail()); + if (oidcUser.getPreferredUsername() != null) { + username = oidcUser.getPreferredUsername(); + userInfo.put("preferredUsername", oidcUser.getPreferredUsername()); + } + } + userInfo.put("username", username); + + if (authorizedClient != null && authorizedClient.getAccessToken() != null) { + userInfo.put("access_token", authorizedClient.getAccessToken().getTokenValue()); + userInfo.put("token", authorizedClient.getAccessToken().getTokenValue()); + } + } else { + userInfo.put("authenticated", false); + } + + return userInfo; + } + + @PostMapping("/api/logout") + public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { + performLogout(request, response, authentication); + } + + @GetMapping("/api/logout") + public void logoutGet(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { + performLogout(request, response, authentication); + } + + private void performLogout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { + if (authentication != null) { + new SecurityContextLogoutHandler().logout(request, null, authentication); + } + + if (request.getSession(false) != null) { + request.getSession().invalidate(); + } + + String logoutUrl = buildKeycloakLogoutUrl(request, authentication); + response.sendRedirect(logoutUrl); + } + + private String buildKeycloakLogoutUrl(HttpServletRequest request, Authentication authentication) { + String keycloakIssuer = issuerUri; + + if (authentication != null && authentication.getPrincipal() instanceof OidcUser oidcUser) { + if (oidcUser.getIssuer() != null) { + keycloakIssuer = oidcUser.getIssuer().toString(); + } + } + + if (keycloakIssuer == null || keycloakIssuer.isEmpty()) { + return "/welcome.html"; + } + + String baseUrl = request.getScheme() + "://" + request.getServerName(); + if ((request.getScheme().equals("http") && request.getServerPort() != 80) || + (request.getScheme().equals("https") && request.getServerPort() != 443)) { + baseUrl += ":" + request.getServerPort(); + } + + String postLogoutRedirectUri = baseUrl + "/welcome.html"; + + String keycloakLogoutUrl = keycloakIssuer + "/protocol/openid-connect/logout"; + + return UriComponentsBuilder + .fromUriString(keycloakLogoutUrl) + .queryParam("post_logout_redirect_uri", postLogoutRedirectUri) + .build() + .toUriString(); + } + + @GetMapping("/api/health") + public Map health() { + Map response = new HashMap<>(); + response.put("status", "UP"); + return response; + } +} diff --git a/src/main/java/org/etsi/osl/mcp/backend/WebSecurityConfigKeycloak.java b/src/main/java/org/etsi/osl/mcp/backend/WebSecurityConfigKeycloak.java index eb92b78..744cd36 100644 --- a/src/main/java/org/etsi/osl/mcp/backend/WebSecurityConfigKeycloak.java +++ b/src/main/java/org/etsi/osl/mcp/backend/WebSecurityConfigKeycloak.java @@ -7,19 +7,70 @@ import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; +import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.HttpStatusEntryPoint; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.http.HttpStatus; @Configuration @EnableWebSecurity @Profile("!testing") public class WebSecurityConfigKeycloak { - @Bean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - return http.authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) + return http + .authorizeHttpRequests(auth -> auth + // Public static resources + .requestMatchers("/", "/index.html", "/styles.css", "/robot.svg", "/favicon.ico").permitAll() + .requestMatchers("/api/health").permitAll() + .requestMatchers("/api/user").permitAll() + .requestMatchers("/api/logout").permitAll() + .requestMatchers("/logout").permitAll() + .requestMatchers("/ask", "/api/**").authenticated() + .anyRequest().permitAll() + ) + // OAuth2 Login for browser-based authentication + .oauth2Login(oauth2 -> oauth2 + .defaultSuccessUrl("/", true) + ) + // Logout configuration + .logout(logout -> logout + .logoutUrl("/logout") + .logoutSuccessUrl("/") + .invalidateHttpSession(true) + .deleteCookies("JSESSIONID") + ) .oauth2Client(Customizer.withDefaults()) + .oauth2ResourceServer(oauth2 -> oauth2 + .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())) + .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)) + ) + .exceptionHandling(exception -> exception + .defaultAuthenticationEntryPointFor( + new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED), + new AntPathRequestMatcher("/ask") + ) + ) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) + ) .csrf(CsrfConfigurer::disable) .build(); } + + @Bean + public JwtAuthenticationConverter jwtAuthenticationConverter() { + JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter(); + grantedAuthoritiesConverter.setAuthoritiesClaimName("roles"); + grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_"); + + JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter(); + jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter); + return jwtAuthenticationConverter; + } } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index d2d75f4..8333f7b 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -22,16 +22,26 @@ spring: url: http://localhost:13015 security: oauth2: + resourceserver: + jwt: + issuer-uri: http://keycloak:8080/auth/realms/openslice client: registration: - authserver: + keycloak: client-id: osapiWebClientId - client-secret: secret authorization-grant-type: authorization_code - provider: authserver + redirect-uri: "{baseUrl}/login/oauth2/code/keycloak" + scope: + - openid + - profile + provider: keycloak provider: - authserver: + keycloak: issuer-uri: http://keycloak:8080/auth/realms/openslice + authorization-uri: http://keycloak:8080/auth/realms/openslice/protocol/openid-connect/auth + token-uri: http://keycloak:8080/auth/realms/openslice/protocol/openid-connect/token + user-info-uri: http://keycloak:8080/auth/realms/openslice/protocol/openid-connect/userinfo + jwk-set-uri: http://keycloak:8080/auth/realms/openslice/protocol/openid-connect/certs activemq: brokerUrl: tcp://localhost:61616?jms.watchTopicAdvisories=false diff --git a/src/main/resources/public/chat.html b/src/main/resources/public/chat.html new file mode 100644 index 0000000..944c0fd --- /dev/null +++ b/src/main/resources/public/chat.html @@ -0,0 +1,60 @@ + + + + + + OpenSlice Chat Assistant + + + + + + + + +
+
+
+

OpenSlice AI Assistant

+ Ask me anything about your OpenSlice platform +
+
+
+
AI
+
+
Hi there! How can I help you today?
+
+
+
+
+
+
+ + +
+
+
+
+ + + diff --git a/src/main/resources/public/css/chat.css b/src/main/resources/public/css/chat.css new file mode 100644 index 0000000..df841f1 --- /dev/null +++ b/src/main/resources/public/css/chat.css @@ -0,0 +1,307 @@ +/* OpenSlice Chat Assistant - Chat Page Styles */ + +body { + font-family: "Open Sans", sans-serif; + padding-top: 60px; + padding-bottom: 20px; + background-color: white; +} + +/* Navbar */ +.navbar-default { + background-color: rgba(245, 245, 245, 0.93); + border-bottom: 1px solid #e7e7e7; +} + +.navbar-brand img { + height: 25px; +} + +/* Chat Container */ +.chat-container { + max-width: 900px; + margin: 20px auto; + background: white; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + overflow: hidden; +} + +/* Chat Header */ +.chat-header { + background-color: #7EADDB; + color: white; + padding: 20px; + border-bottom: 1px solid #6589AB; +} + +.chat-header h4 { + margin: 0; + font-weight: 600; +} + +.chat-header .online-indicator { + display: inline-block; + width: 8px; + height: 8px; + background-color: #5cb85c; + border-radius: 50%; + margin-right: 5px; +} + +/* Chat Messages */ +.chat-messages { + height: 500px; + overflow-y: auto; + padding: 20px; + background-color: #f9f9f9; +} + +.message { + margin-bottom: 20px; + display: flex; + align-items: flex-start; + clear: both; +} + +.message.incoming { + flex-direction: row; + justify-content: flex-start; + padding-right: 80px; +} + +.message.outgoing { + flex-direction: row-reverse; + justify-content: flex-start; + padding-left: 80px; +} + +.message .avatar { + width: 40px; + height: 40px; + border-radius: 50%; + background-color: #7EADDB; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: bold; + flex-shrink: 0; +} + +.message.incoming .avatar { + margin-right: 12px; +} + +.message.outgoing .avatar { + margin-left: 12px; + background-color: #6589AB; +} + +.message-wrapper { + display: flex; + flex-direction: column; + max-width: 66%; + min-width: fit-content; +} + +.message.incoming .message-wrapper { + align-items: flex-start; +} + +.message.outgoing .message-wrapper { + align-items: flex-end; +} + +.message-content { + max-width: 100%; + padding: 12px 16px; + border-radius: 8px; + word-wrap: break-word; + white-space: pre-wrap; + line-height: 1.5; +} + +.message-content p { + margin: 0; +} + +.message-content p:last-child { + margin-bottom: 0; +} + +.message-content p + p { + margin-top: 10px; +} + +.message-content code { + background-color: rgba(0, 0, 0, 0.05); + padding: 2px 4px; + border-radius: 3px; + font-family: monospace; + font-size: 13px; +} + +.message-content pre { + background-color: rgba(0, 0, 0, 0.05); + padding: 10px; + border-radius: 4px; + overflow-x: auto; + margin: 10px 0; +} + +.message-content pre code { + background-color: transparent; + padding: 0; +} + +.message-content ul, .message-content ol { + margin: 10px 0; + padding-left: 20px; +} + +.message-content li { + margin: 5px 0; +} + +.message.incoming .message-content { + background-color: white; + border: 1px solid #e0e0e0; + color: #333; +} + +.message.outgoing .message-content { + background-color: #7EADDB; + color: white; +} + +.message.outgoing .message-content code { + background-color: rgba(255, 255, 255, 0.2); +} + +.message.outgoing .message-content pre { + background-color: rgba(255, 255, 255, 0.2); +} + +.message-time { + font-size: 11px; + color: #999; + margin-top: 5px; + text-align: right; +} + +.message.incoming .message-time { + text-align: left; +} + +/* Chat Input */ +.chat-input { + padding: 20px; + background-color: white; + border-top: 1px solid #e0e0e0; +} + +.chat-input .input-group { + display: flex; +} + +.chat-input textarea { + flex: 1; + resize: none; + border: 1px solid #ddd; + border-radius: 4px 0 0 4px; + padding: 10px; + font-size: 14px; + min-height: 45px; +} + +.chat-input button { + background-color: #7EADDB; + border: none; + color: white; + padding: 0 25px; + border-radius: 0 4px 4px 0; + font-weight: 600; + transition: background-color 0.3s; +} + +.chat-input button:hover { + background-color: #6589AB; +} + +/* Tool Execution */ +.tool-execution { + background-color: #f0f8ff; + border-left: 3px solid #7EADDB; + padding: 10px; + margin-top: 8px; + border-radius: 4px; + font-family: monospace; + font-size: 12px; +} + +.tool-name { + font-weight: bold; + color: #6589AB; +} + +/* Typing Indicator */ +.typing-indicator { + display: none; + margin-bottom: 20px; +} + +.typing-indicator.active { + display: flex; +} + +.typing-indicator .avatar { + width: 40px; + height: 40px; + border-radius: 50%; + background-color: #7EADDB; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: bold; + flex-shrink: 0; + margin-right: 12px; +} + +.typing-indicator .dots { + background-color: white; + border: 1px solid #e0e0e0; + padding: 12px 20px; + border-radius: 8px; + display: flex; + align-items: center; + gap: 6px; +} + +.typing-indicator .dot { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: #7EADDB; + animation: typing 1.4s infinite; +} + +.typing-indicator .dot:nth-child(2) { + animation-delay: 0.2s; +} + +.typing-indicator .dot:nth-child(3) { + animation-delay: 0.4s; +} + +@keyframes typing { + 0%, 60%, 100% { + transform: translateY(0); + opacity: 0.4; + } + 30% { + transform: translateY(-10px); + opacity: 1; + } +} diff --git a/src/main/resources/public/css/welcome.css b/src/main/resources/public/css/welcome.css new file mode 100644 index 0000000..c27aa16 --- /dev/null +++ b/src/main/resources/public/css/welcome.css @@ -0,0 +1,110 @@ +/* OpenSlice Chat Assistant - Welcome Page Styles */ + +body { + font-family: 'Open Sans', sans-serif; + margin: 0; + padding: 60px 0 40px 0; + min-height: 100vh; + background-color: white; +} + +/* Navbar */ +.navbar-default { + background-color: rgba(245, 245, 245, 0.93); + border-bottom: 1px solid #e7e7e7; +} + +.navbar-brand img { + height: 25px; +} + +/* Welcome Section */ +.welcome-section { + background: linear-gradient(135deg, #989DC3 0%, #7EADDB 100%); + padding: 80px 0; + text-align: center; + color: #FCFCFF; + margin-top: 0; + min-height: 500px; +} + +.welcome-section h1 { + font-size: 48px; + font-weight: 300; + margin-bottom: 20px; + text-shadow: 2px 2px 4px rgba(0,0,0,0.3); +} + +.welcome-section .subtitle { + font-size: 22px; + margin-bottom: 30px; + font-weight: 300; +} + +.login-message { + background: rgba(255, 255, 255, 0.95); + color: #d9534f; + padding: 15px 30px; + border-radius: 4px; + display: inline-block; + margin-bottom: 30px; + font-weight: 600; + font-size: 16px; +} + +.btn-login { + background-color: #7EADDB; + color: white; + border: none; + padding: 15px 40px; + font-size: 18px; + font-weight: 600; + border-radius: 4px; + transition: all 0.3s; +} + +.btn-login:hover { + background-color: #6589AB; + color: white; + box-shadow: 0 4px 8px rgba(0,0,0,0.2); + transform: translateY(-2px); +} + +/* Features Section */ +.features-section { + background-color: white; + padding: 60px 0; +} + +.features-section h2 { + text-align: center; + font-size: 36px; + font-weight: 300; + margin-bottom: 50px; + color: #333; +} + +.feature-box { + text-align: center; + padding: 30px 20px; + margin-bottom: 30px; +} + +.feature-icon { + font-size: 60px; + margin-bottom: 20px; + color: #7EADDB; +} + +.feature-box h3 { + font-size: 22px; + font-weight: 600; + margin-bottom: 15px; + color: #333; +} + +.feature-box p { + font-size: 15px; + color: #666; + line-height: 1.6; +} diff --git a/src/main/resources/public/images/logo_clear.png b/src/main/resources/public/images/logo_clear.png new file mode 100644 index 0000000000000000000000000000000000000000..2fa5efc135209ce66cee07ea7a76dc0a4cff9244 GIT binary patch literal 16566 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 T_gx)z3S^9@tDnm{r-UW|I%voO literal 0 HcmV?d00001 diff --git a/src/main/resources/public/index.html b/src/main/resources/public/index.html index 79a08c2..688ce83 100644 --- a/src/main/resources/public/index.html +++ b/src/main/resources/public/index.html @@ -3,65 +3,102 @@ - AI Chat - - - + OpenSlice Chat Assistant + + -
-
-
-
- -
-
- Robot Avatar -
-
-
ChatBot
-

Online

-
-
- - -
- -
-
- Robot Avatar -
-
-
- Hi there! How can I help you today? -
-
10:03 AM
-
-
- -
- - -
-
- - -
-
-
-
-
-
+ + + + + + // Login button handler + $('#loginBtn').click(function() { + window.location.href = '/oauth2/authorization/keycloak'; + }); + + // Logout button handler + $('#logoutBtn').click(function() { + $.ajax({ + type: "POST", + url: "/api/logout", + dataType: 'json' + }) + .done(function(response) { + console.log("Logged out:", response); + accessToken = null; + currentUser = null; + updateAuthUI(); + addMessage("You have been logged out.", false); + }) + .fail(function(jqXHR, textStatus, errorThrown) { + console.error("Logout error:", textStatus, errorThrown); + }); + }); + }); - - - + + + + + + + +
+
+

Welcome to OpenSlice Chat Assistant

+

AI-powered assistance for your OpenSlice platform

+ + + +
+ + +
+
+ + +
+
+

What you can do

+
+
+
+
💬
+

Natural Language Queries

+

Ask questions about your services, catalogs, and deployments using natural language. Get instant answers powered by AI.

+
+
+
+
+
🔧
+

Intelligent Tool Execution

+

Let AI help you interact with TMF APIs and manage resources efficiently. Automate complex workflows with simple commands.

+
+
+
+
+
📊
+

Real-time Information

+

Get instant answers about your platform status, resource utilization, and system health in real-time.

+
+
+
+
+
+ + + + + -- GitLab From ce910b543c1527b1b326c75128ebba68f5b786b2 Mon Sep 17 00:00:00 2001 From: denazi Date: Fri, 19 Dec 2025 14:47:48 +0200 Subject: [PATCH 2/3] BE Service #2: Addition of gitlab-ci and Dockerization --- .gitlab-ci.yml | 40 +++++++++++++++++++++++++ Dockerfile | 26 ++++++++-------- docker-compose.yml | 35 ++++++++++++++++++++-- pom.xml | 4 +++ src/main/resources/application.yaml | 46 +++++++++++++---------------- 5 files changed, 109 insertions(+), 42 deletions(-) create mode 100644 .gitlab-ci.yml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..40be386 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,40 @@ +include: + - project: osl/code/org.etsi.osl.main + ref: main + file: + - ci-templates/default.yml + - ci-templates/build.yml + rules: + - if: '$CI_COMMIT_REF_NAME == "main"' + + - project: osl/code/org.etsi.osl.main + ref: develop + file: + - ci-templates/default.yml + - ci-templates/build.yml + rules: + - if: '$CI_COMMIT_REF_NAME == "develop"' + + - project: osl/code/org.etsi.osl.main + ref: $CI_COMMIT_REF_NAME + file: + - ci-templates/default.yml + - ci-templates/build.yml + rules: + - if: '$CI_COMMIT_REF_PROTECTED == "true" && $CI_COMMIT_REF_NAME != "main" && $CI_COMMIT_REF_NAME != "develop"' + + - project: osl/code/org.etsi.osl.main + ref: develop + file: + - ci-templates/default.yml + - ci-templates/build_unprotected.yml + rules: + - if: '$CI_COMMIT_REF_NAME != "main" && $CI_COMMIT_REF_NAME != "develop" && $CI_COMMIT_REF_PROTECTED == "false"' + +maven_build: + extends: .maven_build + +docker_build: + extends: .docker_build + needs: + - maven_build diff --git a/Dockerfile b/Dockerfile index aa6f298..440fed7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,32 +1,32 @@ -# Build stage +# ---------- Stage 1: Build ---------- FROM maven:3.9-eclipse-temurin-17 AS builder +WORKDIR /app -WORKDIR /build - -# Copy pom.xml and download dependencies +# Copy pom.xml and download dependencies first (cache layer) COPY pom.xml . -RUN mvn dependency:go-offline +RUN mvn dependency:go-offline -B -# Copy source code +# Copy the rest of the source code COPY src ./src -# Build application +# Build the Spring Boot executable JAR RUN mvn clean package -DskipTests -# Runtime stage +# ---------- Stage 2: Runtime ---------- FROM eclipse-temurin:17-jre-alpine -WORKDIR /app +# Create application directory +RUN mkdir -p /opt/openslice/lib/ -# Copy built JAR from builder stage -COPY --from=builder /build/target/org.etsi.osl.mcp.backend-0.0.1-SNAPSHOT.jar app.jar +# Copy the built JAR from the builder stage +COPY --from=builder /app/target/org.etsi.osl.mcp.backend-0.0.1-SNAPSHOT.jar /opt/openslice/lib/app.jar # Expose port EXPOSE 11880 # Health check -HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:11880/actuator/health || exit 1 # Run application -ENTRYPOINT ["java", "-jar", "app.jar"] +CMD ["java", "-jar", "/opt/openslice/lib/app.jar"] diff --git a/docker-compose.yml b/docker-compose.yml index 58bcfa1..cf9c10a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,17 +29,46 @@ services: ports: - "11880:11880" environment: + # Server Configuration + SERVER_PORT: 11880 + SPRING_APPLICATION_NAME: osl-mcp-backend + + # AI Configuration SPRING_AI_OLLAMA_BASE_URL: http://ollama:11434 - SPRING_AI_MCP_CLIENT_STREAMABLE_HTTP_CONNECTIONS_OPENSLICE_SERVER_URL: http://mcp-server:13015 - SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_AUTHSERVER_ISSUER_URI: http://keycloak:8080/auth/realms/openslice - SPRING_ACTIVEMQ_BROKERURL: tcp://activemq:61616 + SPRING_AI_OLLAMA_CHAT_MODEL: gpt-oss:20b + SPRING_AI_OLLAMA_CHAT_TEMPERATURE: 0.7 + + # MCP Client Configuration + SPRING_AI_MCP_CLIENT_TYPE: SYNC + SPRING_AI_MCP_CLIENT_STREAMABLE_HTTP_CONNECTIONS_OPENSLICE_SERVER_URL: http://openslice-mcp:13015/sse + + # OAuth2/Keycloak Configuration + SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: http://keycloak:8080/auth/realms/openslice + SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KEYCLOAK_ISSUER_URI: http://keycloak:8080/auth/realms/openslice + SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KEYCLOAK_CLIENT_ID: osapiWebClientId + + # ActiveMQ Configuration + SPRING_ACTIVEMQ_BROKER_URL: tcp://anartemis:61616?jms.watchTopicAdvisories=false SPRING_ACTIVEMQ_USER: artemis SPRING_ACTIVEMQ_PASSWORD: artemis + + # Logging Configuration + LOGGING_LEVEL_ROOT: INFO + LOGGING_LEVEL_OSL: DEBUG + LOGGING_LEVEL_SPRING_AI: DEBUG + depends_on: + - ollama healthcheck: test: ["CMD", "curl", "-f", "http://localhost:11880/actuator/health"] interval: 30s timeout: 10s retries: 3 + start_period: 60s volumes: ollama_data: + +networks: + default: + name: compose_back + external: true diff --git a/pom.xml b/pom.xml index dd21939..e392e4f 100644 --- a/pom.xml +++ b/pom.xml @@ -81,6 +81,10 @@ spring-boot-starter-test test
+ + org.springframework.boot + spring-boot-starter-actuator + diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 8333f7b..83819de 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,59 +1,53 @@ server: - port: 11880 + port: ${SERVER_PORT:11880} spring: application: - name: osl-mcp-backend + name: ${SPRING_APPLICATION_NAME:osl-mcp-backend} ai: ollama: - base-url: http://localhost:11434 + base-url: ${SPRING_AI_OLLAMA_BASE_URL:http://localhost:11434} chat: options: - model: gpt-oss:20b - xmodel: qwen3:8b - temperature: 0.7 + model: ${SPRING_AI_OLLAMA_CHAT_MODEL:gpt-oss:20b} + temperature: ${SPRING_AI_OLLAMA_CHAT_TEMPERATURE:0.7} mcp: client: - type: SYNC # or ASYNC + type: ${SPRING_AI_MCP_CLIENT_TYPE:SYNC} # or ASYNC streamable-http: connections: openslice-server: - url: http://localhost:13015 + url: ${SPRING_AI_MCP_CLIENT_STREAMABLE_HTTP_CONNECTIONS_OPENSLICE_SERVER_URL:http://localhost:13015} security: oauth2: resourceserver: jwt: - issuer-uri: http://keycloak:8080/auth/realms/openslice + issuer-uri: ${SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI:http://keycloak:8080/auth/realms/openslice} client: registration: keycloak: - client-id: osapiWebClientId - authorization-grant-type: authorization_code - redirect-uri: "{baseUrl}/login/oauth2/code/keycloak" + client-id: ${SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_KEYCLOAK_CLIENT_ID:osapiWebClientId} scope: - openid - profile provider: keycloak provider: keycloak: - issuer-uri: http://keycloak:8080/auth/realms/openslice - authorization-uri: http://keycloak:8080/auth/realms/openslice/protocol/openid-connect/auth - token-uri: http://keycloak:8080/auth/realms/openslice/protocol/openid-connect/token - user-info-uri: http://keycloak:8080/auth/realms/openslice/protocol/openid-connect/userinfo - jwk-set-uri: http://keycloak:8080/auth/realms/openslice/protocol/openid-connect/certs + issuer-uri: ${SPRING_SECURITY_OAUTH2_CLIENT_PROVIDER_KEYCLOAK_ISSUER_URI:http://keycloak:8080/auth/realms/openslice} activemq: - brokerUrl: tcp://localhost:61616?jms.watchTopicAdvisories=false - user: artemis - password: artemis + brokerUrl: ${SPRING_ACTIVEMQ_BROKER_URL:tcp://localhost:61616?jms.watchTopicAdvisories=false} + user: ${SPRING_ACTIVEMQ_USER:artemis} + password: ${SPRING_ACTIVEMQ_PASSWORD:artemis} pool: - enabled: true - max-connections: 100 + enabled: ${SPRING_ACTIVEMQ_POOL_ENABLED:true} + max-connections: ${SPRING_ACTIVEMQ_POOL_MAX_CONNECTIONS:100} packages: - trust-all: true + trust-all: ${SPRING_ACTIVEMQ_PACKAGES_TRUST_ALL:true} + logging: level: - root: INFO - org.etsi.osl.*: DEBUG - org.springframework.ai.chat.client.advisor: DEBUG \ No newline at end of file + root: ${LOGGING_LEVEL_ROOT:INFO} + org.etsi.osl.*: ${LOGGING_LEVEL_OSL:DEBUG} + org.springframework.ai.chat.client.advisor: ${LOGGING_LEVEL_SPRING_AI:DEBUG} \ No newline at end of file -- GitLab From e62b8c08fbde27d056252ddadf1a05544ea51c80 Mon Sep 17 00:00:00 2001 From: denazi Date: Fri, 19 Dec 2025 14:55:41 +0200 Subject: [PATCH 3/3] BE Service #2: Addition of ci_settings --- ci_settings.xml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 ci_settings.xml diff --git a/ci_settings.xml b/ci_settings.xml new file mode 100644 index 0000000..69ad06e --- /dev/null +++ b/ci_settings.xml @@ -0,0 +1,16 @@ + + + + gitlab-maven + + + + Job-Token + ${CI_JOB_TOKEN} + + + + + + -- GitLab