From c056d5275fa973a3882e93002587c46ff6399c8e Mon Sep 17 00:00:00 2001 From: Dusan Date: Wed, 14 Jan 2026 20:32:39 +0100 Subject: [PATCH 01/17] Add Gateway routing configuration. --- build.gradle.kts | 9 ++++---- src/main/resources/application.properties | 26 +++++++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 4686e9e..e70d9cf 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -21,10 +21,11 @@ repositories { extra["springCloudVersion"] = "2025.1.0" dependencies { - implementation("org.springframework.boot:spring-boot-starter-webmvc") - implementation("org.springframework.cloud:spring-cloud-starter-gateway-server-webmvc") - testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") + implementation("org.springframework.boot:spring-boot-starter-webmvc") + implementation("org.springframework.cloud:spring-cloud-starter-gateway-server-webmvc") + developmentOnly("org.springframework.boot:spring-boot-devtools") + testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") } dependencyManagement { diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 6365994..48d526f 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,27 @@ spring.application.name=gateway +server.port=8080 + +#spring.main.web-application-type=reactive +spring.cloud.gateway.server.webmvc.routes[0].id=user-service +spring.cloud.gateway.server.webmvc.routes[0].uri=${USER_SERVICE_URL:http://localhost:8081} +spring.cloud.gateway.server.webmvc.routes[0].predicates[0]=Path=/api/user/** + +spring.cloud.gateway.server.webmvc.routes[1].id=accommodation-service +spring.cloud.gateway.server.webmvc.routes[1].uri=${ACCOMMODATION_SERVICE_URL:http://localhost:8082} +spring.cloud.gateway.server.webmvc.routes[1].predicates[0]=Path=/api/accommodation/** + +spring.cloud.gateway.server.webmvc.routes[2].id=notification-service +spring.cloud.gateway.server.webmvc.routes[2].uri=${NOTIFICATION_SERVICE_URL:http://localhost:8083} +spring.cloud.gateway.server.webmvc.routes[2].predicates[0]=Path=/api/notification/** + +spring.cloud.gateway.server.webmvc.routes[3].id=rating-service +spring.cloud.gateway.server.webmvc.routes[3].uri=${RATING_SERVICE_URL:http://localhost:8084} +spring.cloud.gateway.server.webmvc.routes[3].predicates[0]=Path=/api/rating/** + +spring.cloud.gateway.server.webmvc.routes[4].id=reservation-service +spring.cloud.gateway.server.webmvc.routes[4].uri=${RESERVATION_SERVICE_URL:http://localhost:8085} +spring.cloud.gateway.server.webmvc.routes[4].predicates[0]=Path=/api/reservation/** + +spring.cloud.gateway.server.webmvc.routes[5].id=search-service +spring.cloud.gateway.server.webmvc.routes[5].uri=${SEARCH_SERVICE_URL:http://localhost:8086} +spring.cloud.gateway.server.webmvc.routes[5].predicates[0]=Path=/api/search/** \ No newline at end of file From 9a11870d9242e6ae3b14274ab4f746727030f342 Mon Sep 17 00:00:00 2001 From: lukaDjordjevic01 <96748944+lukaDjordjevic01@users.noreply.github.com> Date: Sat, 17 Jan 2026 16:01:00 +0100 Subject: [PATCH 02/17] feat: Add Dockerfile --- .gitignore | 5 ++++- Dockerfile | 12 ++++++++++++ gradlew | 0 src/main/resources/application.properties | 2 +- 4 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 Dockerfile mode change 100644 => 100755 gradlew diff --git a/.gitignore b/.gitignore index df0b53a..eac1f4d 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,7 @@ out/ .vscode/ ### Mac ### -.DS_Store \ No newline at end of file +.DS_Store + +### Environment variables ### +*.env \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e7440a4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM eclipse-temurin:25-jre-alpine + +WORKDIR /app + +COPY build/libs/*SNAPSHOT.jar app.jar + +RUN addgroup -S spring && adduser -S spring -G spring +USER spring:spring + +EXPOSE 8080 + +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 48d526f..dea9737 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,5 +1,5 @@ spring.application.name=gateway -server.port=8080 +server.port=${SERVER_PORT:8080} #spring.main.web-application-type=reactive spring.cloud.gateway.server.webmvc.routes[0].id=user-service From 1f23f5aa7a3eb579d4c45790bb96656cbfa3bc42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Jano=C5=A1evi=C4=87?= Date: Sat, 17 Jan 2026 22:44:10 +0100 Subject: [PATCH 03/17] feat: Add Logstash logging configuration and dependencies Introduced Logstash Logback encoder dependency and added logback-spring.xml for structured logging to Logstash. Updated application.properties with logging configuration and log levels. Added logback-test.xml for test logging setup. --- build.gradle.kts | 1 + src/main/resources/application.properties | 9 ++- src/main/resources/logback-spring.xml | 76 +++++++++++++++++++++++ src/test/resources/logback-test.xml | 18 ++++++ 4 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 src/main/resources/logback-spring.xml create mode 100644 src/test/resources/logback-test.xml diff --git a/build.gradle.kts b/build.gradle.kts index e70d9cf..787616f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -23,6 +23,7 @@ extra["springCloudVersion"] = "2025.1.0" dependencies { implementation("org.springframework.boot:spring-boot-starter-webmvc") implementation("org.springframework.cloud:spring-cloud-starter-gateway-server-webmvc") + implementation("net.logstash.logback:logstash-logback-encoder:8.0") developmentOnly("org.springframework.boot:spring-boot-devtools") testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test") testRuntimeOnly("org.junit.platform:junit-platform-launcher") diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index dea9737..3fac15f 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -24,4 +24,11 @@ spring.cloud.gateway.server.webmvc.routes[4].predicates[0]=Path=/api/reservation spring.cloud.gateway.server.webmvc.routes[5].id=search-service spring.cloud.gateway.server.webmvc.routes[5].uri=${SEARCH_SERVICE_URL:http://localhost:8086} -spring.cloud.gateway.server.webmvc.routes[5].predicates[0]=Path=/api/search/** \ No newline at end of file +spring.cloud.gateway.server.webmvc.routes[5].predicates[0]=Path=/api/search/** + +# Logging configuration +logging.logstash.host=${LOGSTASH_HOST:localhost:5000} +logging.level.root=INFO +logging.level.com.devoops=DEBUG +logging.level.org.springframework.web=INFO +logging.level.org.springframework.cloud.gateway=INFO \ No newline at end of file diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..eaa5def --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,76 @@ + + + + + + + + + {"service.name":"${applicationName}","host.name":"${HOSTNAME:-unknown}"} + + @timestamp + message + logger + thread + level + + + + + + + ${logstashHost} + + {"service.name":"${applicationName}","host.name":"${HOSTNAME:-unknown}"} + + @timestamp + message + logger + thread + level + [ignore] + + requestId + userId + + + + + + + 512 + 0 + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml new file mode 100644 index 0000000..8c647eb --- /dev/null +++ b/src/test/resources/logback-test.xml @@ -0,0 +1,18 @@ + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + + From d3cd3df74c7092eb928de70f02733e6e20a955f2 Mon Sep 17 00:00:00 2001 From: Dusan Date: Mon, 19 Jan 2026 21:13:40 +0100 Subject: [PATCH 04/17] feat: Add zipkin tracing. --- build.gradle.kts | 6 ++++++ src/main/resources/application.properties | 6 +++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 787616f..2f2b915 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -24,6 +24,12 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-webmvc") implementation("org.springframework.cloud:spring-cloud-starter-gateway-server-webmvc") implementation("net.logstash.logback:logstash-logback-encoder:8.0") + implementation("org.springframework.boot:spring-boot-starter-actuator") + //zipkin(tracing) + implementation("org.springframework.boot:spring-boot-micrometer-tracing-brave") + implementation("org.springframework.boot:spring-boot-starter-zipkin") + implementation("io.micrometer:micrometer-tracing-bridge-brave") + implementation("io.zipkin.reporter2:zipkin-reporter-brave") developmentOnly("org.springframework.boot:spring-boot-devtools") testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test") testRuntimeOnly("org.junit.platform:junit-platform-launcher") diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 3fac15f..eee48cb 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -31,4 +31,8 @@ logging.logstash.host=${LOGSTASH_HOST:localhost:5000} logging.level.root=INFO logging.level.com.devoops=DEBUG logging.level.org.springframework.web=INFO -logging.level.org.springframework.cloud.gateway=INFO \ No newline at end of file +logging.level.org.springframework.cloud.gateway=INFO + +#Zipkin tracing configuration +management.tracing.sampling.probability=1.0 +management.tracing.export.zipkin.endpoint=${ZIPKIN_HOST:http://zipkin}:${ZIPKIN_PORT:9411}/api/v2/spans \ No newline at end of file From 9b239435ccdccaa74e20175e18028603b7fa5a1b Mon Sep 17 00:00:00 2001 From: lukaDjordjevic01 <96748944+lukaDjordjevic01@users.noreply.github.com> Date: Sat, 31 Jan 2026 16:17:08 +0100 Subject: [PATCH 05/17] fix: Remove local environment variables from gitignore. --- .gitignore | 2 -- environment/.local.env | 10 ++++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 environment/.local.env diff --git a/.gitignore b/.gitignore index eac1f4d..4a82c18 100644 --- a/.gitignore +++ b/.gitignore @@ -39,5 +39,3 @@ out/ ### Mac ### .DS_Store -### Environment variables ### -*.env \ No newline at end of file diff --git a/environment/.local.env b/environment/.local.env new file mode 100644 index 0000000..ff9f157 --- /dev/null +++ b/environment/.local.env @@ -0,0 +1,10 @@ +SERVER_PORT=8080 +USER_SERVICE_URL=http://devoops-user-service:8080 +ACCOMMODATION_SERVICE_URL=http://devoops-accommodation-service:8080 +NOTIFICATION_SERVICE_URL=http://devoops-notification-service:8080 +RATING_SERVICE_URL=http://devoops-rating-service:8080 +RESERVATION_SERVICE_URL=http://devoops-reservation-service:8080 +SEARCH_SERVICE_URL=http://devoops-search-service:8080 +LOGSTASH_HOST=logstash:5000 +ZIPKIN_HOST=zipkin +ZIPKIN_PORT=9411 From 3476131dfbaa17890d217c57c6b1e97a15a7e9e2 Mon Sep 17 00:00:00 2001 From: lukaDjordjevic01 <96748944+lukaDjordjevic01@users.noreply.github.com> Date: Sat, 31 Jan 2026 16:30:19 +0100 Subject: [PATCH 06/17] fix: Fix zipkin endpoint in application.properties. --- src/main/resources/application.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index eee48cb..043bb4e 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -35,4 +35,4 @@ logging.level.org.springframework.cloud.gateway=INFO #Zipkin tracing configuration management.tracing.sampling.probability=1.0 -management.tracing.export.zipkin.endpoint=${ZIPKIN_HOST:http://zipkin}:${ZIPKIN_PORT:9411}/api/v2/spans \ No newline at end of file +management.tracing.export.zipkin.endpoint=http://${ZIPKIN_HOST:zipkin}:${ZIPKIN_PORT:9411}/api/v2/spans From db9b5befe0aa43c89ccc993ab06a68fddee4cbb9 Mon Sep 17 00:00:00 2001 From: lukaDjordjevic01 <96748944+lukaDjordjevic01@users.noreply.github.com> Date: Sat, 7 Feb 2026 17:42:52 +0100 Subject: [PATCH 07/17] feat: Add Prometheus Integration --- build.gradle.kts | 1 + src/main/resources/application.properties | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 2f2b915..45433e3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -25,6 +25,7 @@ dependencies { implementation("org.springframework.cloud:spring-cloud-starter-gateway-server-webmvc") implementation("net.logstash.logback:logstash-logback-encoder:8.0") implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("io.micrometer:micrometer-registry-prometheus") //zipkin(tracing) implementation("org.springframework.boot:spring-boot-micrometer-tracing-brave") implementation("org.springframework.boot:spring-boot-starter-zipkin") diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 043bb4e..3ff62bc 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -36,3 +36,8 @@ logging.level.org.springframework.cloud.gateway=INFO #Zipkin tracing configuration management.tracing.sampling.probability=1.0 management.tracing.export.zipkin.endpoint=http://${ZIPKIN_HOST:zipkin}:${ZIPKIN_PORT:9411}/api/v2/spans + +# Actuator endpoints for Prometheus metrics +management.endpoints.web.exposure.include=health,prometheus,metrics +management.endpoint.health.show-details=always +management.prometheus.metrics.export.enabled=true From 0e80b9268a7518a3aaa4634b361559bd39163724 Mon Sep 17 00:00:00 2001 From: lukaDjordjevic01 <96748944+lukaDjordjevic01@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:12:34 +0100 Subject: [PATCH 08/17] feat: Add custom filters and configuration for necessary metrics. --- .../devoops/gateway/config/MetricsConfig.java | 45 +++++++++++++++ .../gateway/filter/TrafficMetricsFilter.java | 57 +++++++++++++++++++ .../gateway/filter/UniqueVisitorFilter.java | 53 +++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 src/main/java/com/devoops/gateway/config/MetricsConfig.java create mode 100644 src/main/java/com/devoops/gateway/filter/TrafficMetricsFilter.java create mode 100644 src/main/java/com/devoops/gateway/filter/UniqueVisitorFilter.java diff --git a/src/main/java/com/devoops/gateway/config/MetricsConfig.java b/src/main/java/com/devoops/gateway/config/MetricsConfig.java new file mode 100644 index 0000000..b31b504 --- /dev/null +++ b/src/main/java/com/devoops/gateway/config/MetricsConfig.java @@ -0,0 +1,45 @@ +package com.devoops.gateway.config; + +import io.micrometer.common.KeyValue; +import io.micrometer.common.KeyValues; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.jspecify.annotations.NonNull; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.server.observation.DefaultServerRequestObservationConvention; +import org.springframework.http.server.observation.ServerRequestObservationContext; +import org.springframework.http.server.observation.ServerRequestObservationConvention; + +import java.util.stream.StreamSupport; + +@Configuration +public class MetricsConfig { + + @Bean + public ServerRequestObservationConvention serverRequestObservationConvention() { + return new DefaultServerRequestObservationConvention() { + @Override + @NonNull + public KeyValues getLowCardinalityKeyValues(@NonNull ServerRequestObservationContext context) { + KeyValues keyValues = super.getLowCardinalityKeyValues(context); + + HttpServletResponse response = context.getResponse(); + if (response != null && response.getStatus() == 404) { + HttpServletRequest request = context.getCarrier(); + assert request != null; + String originalUri = request.getRequestURI(); + + KeyValues filtered = KeyValues.of( + StreamSupport.stream(keyValues.spliterator(), false) + .filter(kv -> !"uri".equals(kv.getKey())) + .toArray(KeyValue[]::new) + ); + keyValues = filtered.and(KeyValue.of("uri", originalUri)); + } + + return keyValues; + } + }; + } +} diff --git a/src/main/java/com/devoops/gateway/filter/TrafficMetricsFilter.java b/src/main/java/com/devoops/gateway/filter/TrafficMetricsFilter.java new file mode 100644 index 0000000..0ef36e9 --- /dev/null +++ b/src/main/java/com/devoops/gateway/filter/TrafficMetricsFilter.java @@ -0,0 +1,57 @@ +package com.devoops.gateway.filter; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.jspecify.annotations.NonNull; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.ContentCachingRequestWrapper; +import org.springframework.web.util.ContentCachingResponseWrapper; + +import java.io.IOException; + +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class TrafficMetricsFilter extends OncePerRequestFilter { + + private final Counter requestBytesCounter; + private final Counter responseBytesCounter; + + public TrafficMetricsFilter(MeterRegistry registry) { + this.requestBytesCounter = Counter.builder("http_traffic_bytes_request_total") + .description("Total bytes received in HTTP requests") + .register(registry); + this.responseBytesCounter = Counter.builder("http_traffic_bytes_response_total") + .description("Total bytes sent in HTTP responses") + .register(registry); + } + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + ContentCachingRequestWrapper wrappedRequest = new ContentCachingRequestWrapper(request, 10 * 1024 * 1024); + ContentCachingResponseWrapper wrappedResponse = new ContentCachingResponseWrapper(response); + + try { + filterChain.doFilter(wrappedRequest, wrappedResponse); + } finally { + long requestBytes = wrappedRequest.getContentLength(); + if (requestBytes < 0) { + requestBytes = wrappedRequest.getContentAsByteArray().length; + } + requestBytesCounter.increment(requestBytes); + + long responseBytes = wrappedResponse.getContentSize(); + responseBytesCounter.increment(responseBytes); + + wrappedResponse.copyBodyToResponse(); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/devoops/gateway/filter/UniqueVisitorFilter.java b/src/main/java/com/devoops/gateway/filter/UniqueVisitorFilter.java new file mode 100644 index 0000000..3b8c0cb --- /dev/null +++ b/src/main/java/com/devoops/gateway/filter/UniqueVisitorFilter.java @@ -0,0 +1,53 @@ +package com.devoops.gateway.filter; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.jspecify.annotations.NonNull; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +public class UniqueVisitorFilter extends OncePerRequestFilter { + + private final MeterRegistry registry; + + public UniqueVisitorFilter(MeterRegistry registry) { + this.registry = registry; + } + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String ip = extractClientIp(request); + String userAgent = request.getHeader("User-Agent"); + String visitorHash = computeHash(ip, userAgent); + + Counter.builder("http_visitor_request_total") + .description("HTTP requests with visitor fingerprint") + .tag("visitor_hash", visitorHash) + .register(registry) + .increment(); + + filterChain.doFilter(request, response); + } + + private String extractClientIp(HttpServletRequest request) { + String xff = request.getHeader("X-Forwarded-For"); + if (xff != null && !xff.isEmpty()) { + return xff.split(",")[0].trim(); + } + return request.getRemoteAddr(); + } + + private String computeHash(String ip, String userAgent) { + String input = ip + "|" + (userAgent != null ? userAgent : "unknown"); + return Integer.toHexString(input.hashCode()); + } +} \ No newline at end of file From b6eb56a1209f707f11c336f48a84b0b6eedb8ae8 Mon Sep 17 00:00:00 2001 From: Dusan Date: Mon, 9 Feb 2026 23:15:24 +0100 Subject: [PATCH 09/17] feat: Add CI pipeline --- .github/workflows/ci.yml | 92 ++++++++++++++++++++++++++++++++++ .github/workflows/pr-check.yml | 27 ++++++++++ 2 files changed, 119 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/pr-check.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..92a6f58 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,92 @@ +name: CI Pipeline + +on: + push: + branches: + - develop + - main + tags: + - 'v*' + + +env: + DOCKERHUB_USERNAME: threeamigoscoding + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 25 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '25' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@0b6dd653ba04f4f93bf581ec31e66cbd7dcb644d + + - name: Build with Gradle + run: ./gradlew clean build + + - name: Run tests + run: ./gradlew test + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: build-artifact + path: build/libs/*.jar + retention-days: 1 + + publish: + needs: build + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download build artifact + uses: actions/download-artifact@v4 + with: + name: build-artifact + path: build/libs + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f + + - name: Log in to Docker Hub + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 + with: + username: ${{ env.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Generate version tag + id: version + run: | + if [[ $GITHUB_REF == refs/tags/* ]]; then + VERSION_TAG=${GITHUB_REF#refs/tags/v} + elif [[ $GITHUB_REF == refs/heads/main ]]; then + SHORT_SHA=$(git rev-parse --short HEAD) + VERSION_TAG="main-${SHORT_SHA}" + else + SHORT_SHA=$(git rev-parse --short HEAD) + VERSION_TAG="develop-${SHORT_SHA}" + fi + echo "tag=$VERSION_TAG" >> $GITHUB_OUTPUT + echo "Generated version tag: $VERSION_TAG" + + - name: Build and push Docker image + uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 + with: + context: . + push: true + tags: | + ${{ env.DOCKERHUB_USERNAME }}/devoops-gateway-service:${{ steps.version.outputs.tag }} + ${{ env.DOCKERHUB_USERNAME }}/devoops-gateway-service:latest + cache-from: type=gha + cache-to: type=gha,mode=max \ No newline at end of file diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml new file mode 100644 index 0000000..e6f3777 --- /dev/null +++ b/.github/workflows/pr-check.yml @@ -0,0 +1,27 @@ +name: PR Check + +on: + pull_request: + branches: + - develop + - main + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 25 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '25' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Build with Gradle + run: ./gradlew clean build \ No newline at end of file From 8d32269e7567d17d030c77b73229c8c055e4ff6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Jano=C5=A1evi=C4=87?= Date: Thu, 12 Feb 2026 19:42:27 +0100 Subject: [PATCH 10/17] feat: Add JWT authentication filter and service Introduce JWT-based authentication for the gateway: add JwtAuthenticationFilter (OncePerRequestFilter) that skips public paths, validates Bearer tokens, injects X-User-Id and X-User-Role headers, and returns 401 on failure. Add JwtService to parse and extract userId/role from tokens using jjwt. Add jjwt dependencies to build.gradle.kts and wire a base64 JWT secret via environment (.local.env) and application.properties default. --- build.gradle.kts | 3 + environment/.local.env | 1 + .../filter/JwtAuthenticationFilter.java | 124 ++++++++++++++++++ .../devoops/gateway/service/JwtService.java | 36 +++++ src/main/resources/application.properties | 3 + 5 files changed, 167 insertions(+) create mode 100644 src/main/java/com/devoops/gateway/filter/JwtAuthenticationFilter.java create mode 100644 src/main/java/com/devoops/gateway/service/JwtService.java diff --git a/build.gradle.kts b/build.gradle.kts index 45433e3..fb34cb6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -23,6 +23,9 @@ extra["springCloudVersion"] = "2025.1.0" dependencies { implementation("org.springframework.boot:spring-boot-starter-webmvc") implementation("org.springframework.cloud:spring-cloud-starter-gateway-server-webmvc") + implementation("io.jsonwebtoken:jjwt-api:0.12.6") + runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6") + runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6") implementation("net.logstash.logback:logstash-logback-encoder:8.0") implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("io.micrometer:micrometer-registry-prometheus") diff --git a/environment/.local.env b/environment/.local.env index ff9f157..e00a233 100644 --- a/environment/.local.env +++ b/environment/.local.env @@ -8,3 +8,4 @@ SEARCH_SERVICE_URL=http://devoops-search-service:8080 LOGSTASH_HOST=logstash:5000 ZIPKIN_HOST=zipkin ZIPKIN_PORT=9411 +JWT_SECRET=dGhpcyBpcyBhIHZlcnkgc2VjdXJlIGtleSBmb3IgSFMyNTYgdGhhdCBpcyBhdCBsZWFzdCAyNTYgYml0cyBsb25n diff --git a/src/main/java/com/devoops/gateway/filter/JwtAuthenticationFilter.java b/src/main/java/com/devoops/gateway/filter/JwtAuthenticationFilter.java new file mode 100644 index 0000000..9f9568f --- /dev/null +++ b/src/main/java/com/devoops/gateway/filter/JwtAuthenticationFilter.java @@ -0,0 +1,124 @@ +package com.devoops.gateway.filter; + +import com.devoops.gateway.service.JwtService; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; +import jakarta.servlet.http.HttpServletResponse; +import org.jspecify.annotations.NonNull; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.util.AntPathMatcher; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.*; + +@Component +@Order(Ordered.HIGHEST_PRECEDENCE + 10) +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private static final List PUBLIC_PATHS = List.of( + "/api/user/auth/**", + "/api/user/test", + "/actuator/**" + ); + + private final AntPathMatcher pathMatcher = new AntPathMatcher(); + private final JwtService jwtService; + + public JwtAuthenticationFilter(JwtService jwtService) { + this.jwtService = jwtService; + } + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) throws ServletException, IOException { + String path = request.getRequestURI(); + + if (isPublicPath(path)) { + filterChain.doFilter(request, response); + return; + } + + String authHeader = request.getHeader("Authorization"); + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + sendError(response, "Missing or invalid Authorization header"); + return; + } + + try { + String token = authHeader.substring(7); + Claims claims = jwtService.parseToken(token); + + String userId = jwtService.getUserId(claims); + String role = jwtService.getRole(claims); + + if (userId == null || role == null) { + sendError(response, "Invalid token claims"); + return; + } + + HttpServletRequest wrappedRequest = new HeaderAddingRequestWrapper(request, Map.of( + "X-User-Id", userId, + "X-User-Role", role + )); + + filterChain.doFilter(wrappedRequest, response); + } catch (JwtException e) { + sendError(response, "Invalid or expired token"); + } + } + + private boolean isPublicPath(String path) { + return PUBLIC_PATHS.stream().anyMatch(pattern -> pathMatcher.match(pattern, path)); + } + + private void sendError(HttpServletResponse response, String message) throws IOException { + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.setContentType("application/json"); + response.getWriter().write("{\"status\":401,\"detail\":\"%s\"}".formatted(message)); + } + + private static class HeaderAddingRequestWrapper extends HttpServletRequestWrapper { + private final Map extraHeaders; + + public HeaderAddingRequestWrapper(HttpServletRequest request, Map extraHeaders) { + super(request); + this.extraHeaders = extraHeaders; + } + + @Override + public String getHeader(String name) { + if (extraHeaders.containsKey(name)) { + return extraHeaders.get(name); + } + return super.getHeader(name); + } + + @Override + public Enumeration getHeaders(String name) { + if (extraHeaders.containsKey(name)) { + return Collections.enumeration(List.of(extraHeaders.get(name))); + } + return super.getHeaders(name); + } + + @Override + public Enumeration getHeaderNames() { + Set names = new LinkedHashSet<>(); + Enumeration original = super.getHeaderNames(); + while (original.hasMoreElements()) { + names.add(original.nextElement()); + } + names.addAll(extraHeaders.keySet()); + return Collections.enumeration(names); + } + } +} diff --git a/src/main/java/com/devoops/gateway/service/JwtService.java b/src/main/java/com/devoops/gateway/service/JwtService.java new file mode 100644 index 0000000..8ee4f3a --- /dev/null +++ b/src/main/java/com/devoops/gateway/service/JwtService.java @@ -0,0 +1,36 @@ +package com.devoops.gateway.service; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import javax.crypto.SecretKey; + +@Service +public class JwtService { + + private final SecretKey signingKey; + + public JwtService(@Value("${jwt.secret}") String secret) { + this.signingKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret)); + } + + public Claims parseToken(String token) { + return Jwts.parser() + .verifyWith(signingKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + public String getUserId(Claims claims) { + return claims.get("userId", String.class); + } + + public String getRole(Claims claims) { + return claims.get("role", String.class); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 3ff62bc..4254648 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -33,6 +33,9 @@ logging.level.com.devoops=DEBUG logging.level.org.springframework.web=INFO logging.level.org.springframework.cloud.gateway=INFO +# JWT +jwt.secret=${JWT_SECRET:dGhpcyBpcyBhIHZlcnkgc2VjdXJlIGtleSBmb3IgSFMyNTYgdGhhdCBpcyBhdCBsZWFzdCAyNTYgYml0cyBsb25n} + #Zipkin tracing configuration management.tracing.sampling.probability=1.0 management.tracing.export.zipkin.endpoint=http://${ZIPKIN_HOST:zipkin}:${ZIPKIN_PORT:9411}/api/v2/spans From cc637c5558dd245334415e6655053f3573ef5c8e Mon Sep 17 00:00:00 2001 From: lukaDjordjevic01 <96748944+lukaDjordjevic01@users.noreply.github.com> Date: Tue, 17 Feb 2026 11:32:52 +0100 Subject: [PATCH 11/17] fix: Update public endpoint processing. Have also the request method included in public endpoint processing. --- .../filter/JwtAuthenticationFilter.java | 32 +++++++++++++++---- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/devoops/gateway/filter/JwtAuthenticationFilter.java b/src/main/java/com/devoops/gateway/filter/JwtAuthenticationFilter.java index 9f9568f..2c929c3 100644 --- a/src/main/java/com/devoops/gateway/filter/JwtAuthenticationFilter.java +++ b/src/main/java/com/devoops/gateway/filter/JwtAuthenticationFilter.java @@ -11,6 +11,7 @@ import org.jspecify.annotations.NonNull; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; +import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; import org.springframework.util.AntPathMatcher; @@ -23,10 +24,24 @@ @Order(Ordered.HIGHEST_PRECEDENCE + 10) public class JwtAuthenticationFilter extends OncePerRequestFilter { - private static final List PUBLIC_PATHS = List.of( - "/api/user/auth/**", - "/api/user/test", - "/actuator/**" + private record PublicEndpoint(String pattern, HttpMethod... methods) { + boolean matchesMethod(String method) { + if (methods.length == 0) { + return true; // No method restriction = all methods allowed + } + return Arrays.stream(methods).anyMatch(m -> m.name().equalsIgnoreCase(method)); + } + } + + private static final List PUBLIC_ENDPOINTS = List.of( + // Auth endpoints - all methods + new PublicEndpoint("/api/user/auth/**"), + new PublicEndpoint("/api/user/test"), + new PublicEndpoint("/actuator/**"), + // Accommodation endpoints - GET only + new PublicEndpoint("/api/accommodation", HttpMethod.GET), + new PublicEndpoint("/api/accommodation/*/photos", HttpMethod.GET), + new PublicEndpoint("/api/accommodation/*/photos/*", HttpMethod.GET) ); private final AntPathMatcher pathMatcher = new AntPathMatcher(); @@ -41,8 +56,9 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws ServletException, IOException { String path = request.getRequestURI(); + String method = request.getMethod(); - if (isPublicPath(path)) { + if (isPublicEndpoint(path, method)) { filterChain.doFilter(request, response); return; } @@ -76,8 +92,10 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, } } - private boolean isPublicPath(String path) { - return PUBLIC_PATHS.stream().anyMatch(pattern -> pathMatcher.match(pattern, path)); + private boolean isPublicEndpoint(String path, String method) { + return PUBLIC_ENDPOINTS.stream() + .anyMatch(endpoint -> + pathMatcher.match(endpoint.pattern(), path) && endpoint.matchesMethod(method)); } private void sendError(HttpServletResponse response, String message) throws IOException { From 9d6e16efec0b85a8b922ce7c6fd7b87525561069 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20=C4=8Cuturi=C4=87?= Date: Tue, 17 Feb 2026 13:31:31 +0100 Subject: [PATCH 12/17] fix: Update public endpoints for accommodation gRPC --- .../com/devoops/gateway/filter/JwtAuthenticationFilter.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/devoops/gateway/filter/JwtAuthenticationFilter.java b/src/main/java/com/devoops/gateway/filter/JwtAuthenticationFilter.java index 2c929c3..e0caf56 100644 --- a/src/main/java/com/devoops/gateway/filter/JwtAuthenticationFilter.java +++ b/src/main/java/com/devoops/gateway/filter/JwtAuthenticationFilter.java @@ -41,7 +41,10 @@ boolean matchesMethod(String method) { // Accommodation endpoints - GET only new PublicEndpoint("/api/accommodation", HttpMethod.GET), new PublicEndpoint("/api/accommodation/*/photos", HttpMethod.GET), - new PublicEndpoint("/api/accommodation/*/photos/*", HttpMethod.GET) + new PublicEndpoint("/api/accommodation/*/photos/*", HttpMethod.GET), + // Availability endpoints - GET only + new PublicEndpoint("/api/accommodation/*/availability", HttpMethod.GET), + new PublicEndpoint("/api/accommodation/*/availability/*", HttpMethod.GET) ); private final AntPathMatcher pathMatcher = new AntPathMatcher(); From 7a733633fc43b6f4768fb52b89b90f1d6aa7d86d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Jano=C5=A1evi=C4=87?= Date: Wed, 18 Feb 2026 22:29:43 +0100 Subject: [PATCH 13/17] fix: Add public endpoints for accommodation --- .../com/devoops/gateway/filter/JwtAuthenticationFilter.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/devoops/gateway/filter/JwtAuthenticationFilter.java b/src/main/java/com/devoops/gateway/filter/JwtAuthenticationFilter.java index e0caf56..55026c3 100644 --- a/src/main/java/com/devoops/gateway/filter/JwtAuthenticationFilter.java +++ b/src/main/java/com/devoops/gateway/filter/JwtAuthenticationFilter.java @@ -40,6 +40,8 @@ boolean matchesMethod(String method) { new PublicEndpoint("/actuator/**"), // Accommodation endpoints - GET only new PublicEndpoint("/api/accommodation", HttpMethod.GET), + new PublicEndpoint("/api/accommodation/*", HttpMethod.GET), + new PublicEndpoint("/api/accommodation/host/*", HttpMethod.GET), new PublicEndpoint("/api/accommodation/*/photos", HttpMethod.GET), new PublicEndpoint("/api/accommodation/*/photos/*", HttpMethod.GET), // Availability endpoints - GET only From 33e316e0ffb027e0d75b385290667228a2973619 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Jano=C5=A1evi=C4=87?= Date: Sun, 22 Feb 2026 17:06:03 +0100 Subject: [PATCH 14/17] fix: Add public endpoints for rating --- .../com/devoops/gateway/filter/JwtAuthenticationFilter.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/devoops/gateway/filter/JwtAuthenticationFilter.java b/src/main/java/com/devoops/gateway/filter/JwtAuthenticationFilter.java index 55026c3..8623229 100644 --- a/src/main/java/com/devoops/gateway/filter/JwtAuthenticationFilter.java +++ b/src/main/java/com/devoops/gateway/filter/JwtAuthenticationFilter.java @@ -46,7 +46,10 @@ boolean matchesMethod(String method) { new PublicEndpoint("/api/accommodation/*/photos/*", HttpMethod.GET), // Availability endpoints - GET only new PublicEndpoint("/api/accommodation/*/availability", HttpMethod.GET), - new PublicEndpoint("/api/accommodation/*/availability/*", HttpMethod.GET) + new PublicEndpoint("/api/accommodation/*/availability/*", HttpMethod.GET), + // Rating endpoints - GET only + new PublicEndpoint("/api/rating", HttpMethod.GET), + new PublicEndpoint("/api/rating/target/*", HttpMethod.GET) ); private final AntPathMatcher pathMatcher = new AntPathMatcher(); From f023dfc9b017e349d1ed7d276e8db04b171bc921 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Jano=C5=A1evi=C4=87?= Date: Mon, 23 Feb 2026 18:55:07 +0100 Subject: [PATCH 15/17] fix: Removed duplicate tests from pipeline --- .github/workflows/ci.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92a6f58..74c4fab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,9 +32,6 @@ jobs: - name: Build with Gradle run: ./gradlew clean build - - name: Run tests - run: ./gradlew test - - name: Upload build artifact uses: actions/upload-artifact@v4 with: From 6faede197f78e62c2f23f8248e24d83dc8685e56 Mon Sep 17 00:00:00 2001 From: lukaDjordjevic01 <96748944+lukaDjordjevic01@users.noreply.github.com> Date: Tue, 24 Feb 2026 18:25:12 +0100 Subject: [PATCH 16/17] feat: Add Helm values. --- environment/helm/values.yaml | 54 ++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 environment/helm/values.yaml diff --git a/environment/helm/values.yaml b/environment/helm/values.yaml new file mode 100644 index 0000000..e454f94 --- /dev/null +++ b/environment/helm/values.yaml @@ -0,0 +1,54 @@ +fullnameOverride: devoops-gateway-service +replicaCount: 1 + +image: + registry: docker.io + repository: threeamigoscoding/devoops-gateway-service + tag: "latest" + pullPolicy: IfNotPresent + +service: + type: ClusterIP + httpPort: 8080 + grpc: + enabled: false + +ingress: + enabled: true + className: nginx + host: devoops.local + path: / + pathType: Prefix + annotations: {} + +resources: + requests: + memory: 256Mi + cpu: 250m + limits: + memory: 512Mi + cpu: 1000m + +health: + path: /actuator/health + periodSeconds: 10 + failureThreshold: 3 + startup: + initialDelaySeconds: 10 + periodSeconds: 5 + failureThreshold: 60 + +configData: + JAVA_TOOL_OPTIONS: "-XX:+UseSerialGC -Xms128m -Xmx384m -XX:ActiveProcessorCount=1" + SERVER_PORT: "8080" + USER_SERVICE_URL: "http://devoops-user-service:8080" + ACCOMMODATION_SERVICE_URL: "http://devoops-accommodation-service:8080" + NOTIFICATION_SERVICE_URL: "http://devoops-notification-service:8080" + RATING_SERVICE_URL: "http://devoops-rating-service:8080" + RESERVATION_SERVICE_URL: "http://devoops-reservation-service:8080" + LOGSTASH_HOST: "devoops-logstash:5000" + ZIPKIN_HOST: "devoops-jaeger" + ZIPKIN_PORT: "9411" + +secretData: + JWT_SECRET: "dGhpcyBpcyBhIHZlcnkgc2VjdXJlIGtleSBmb3IgSFMyNTYgdGhhdCBpcyBhdCBsZWFzdCAyNTYgYml0cyBsb25n" From b6bb19073f54d2a43619b2e10d63bb858528e8c8 Mon Sep 17 00:00:00 2001 From: Dusan Date: Tue, 24 Feb 2026 22:05:05 +0100 Subject: [PATCH 17/17] feat: Build image with Kaniko --- .github/workflows/ci.yml | 35 ++++++++++++++++------------------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 74c4fab..3bf3a7e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,15 +53,6 @@ jobs: name: build-artifact path: build/libs - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f - - - name: Log in to Docker Hub - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 - with: - username: ${{ env.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Generate version tag id: version run: | @@ -77,13 +68,19 @@ jobs: echo "tag=$VERSION_TAG" >> $GITHUB_OUTPUT echo "Generated version tag: $VERSION_TAG" - - name: Build and push Docker image - uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 - with: - context: . - push: true - tags: | - ${{ env.DOCKERHUB_USERNAME }}/devoops-gateway-service:${{ steps.version.outputs.tag }} - ${{ env.DOCKERHUB_USERNAME }}/devoops-gateway-service:latest - cache-from: type=gha - cache-to: type=gha,mode=max \ No newline at end of file + - name: Build and push Docker image with Kaniko + env: + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + run: | + mkdir -p /tmp/kaniko/.docker + echo "{\"auths\":{\"https://index.docker.io/v1/\":{\"auth\":\"$(echo -n ${{ env.DOCKERHUB_USERNAME }}:${DOCKERHUB_TOKEN} | base64)\"}}}" > /tmp/kaniko/.docker/config.json + docker run \ + -v ${{ github.workspace }}:/workspace \ + -v /tmp/kaniko/.docker:/kaniko/.docker \ + gcr.io/kaniko-project/executor:latest \ + --context=/workspace \ + --dockerfile=/workspace/Dockerfile \ + --destination=${{ env.DOCKERHUB_USERNAME }}/devoops-gateway-service:${{ steps.version.outputs.tag }} \ + --destination=${{ env.DOCKERHUB_USERNAME }}/devoops-gateway-service:latest \ + --cache=true \ + --cache-repo=${{ env.DOCKERHUB_USERNAME }}/devoops-gateway-service-cache \ No newline at end of file