diff --git a/.gitignore b/.gitignore index 549e00a2a..b1a8f549d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,25 @@ target/ !**/src/main/**/target/ !**/src/test/**/target/ +# Environment variables and secrets +.env +*.tfvars +!*.tfvars.example + +# Terraform files +**/.terraform/ +**/.terraform.lock.hcl +**/terraform.tfstate +**/terraform.tfstate.backup +**/*.tfplan + +# Keys and credentials +infra/keys/ +*.json +!package.json +!package-lock.json +!tsconfig.json + ### STS ### .apt_generated .classpath @@ -31,3 +50,6 @@ build/ ### VS Code ### .vscode/ + +## Keys ## +infra/keys/ diff --git a/README.md b/README.md index 1d4a67936..617b9abe6 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ -## Introduction +## Introduction - This project is a development of a small set of **Spring Boot** and **Cloud** based Microservices projects that implement cloud-native intuitive, Reactive Programming, Event-driven, Microservices design patterns, and coding best practices. - The project follows **CloudNative** recommendations and The [**twelve-factor app**](https://12factor.net/) methodology for building *software-as-a-service apps* to show how μServices should be developed and deployed. - This project uses cutting edge technologies like Docker, Kubernetes, Elasticsearch Stack for diff --git a/api-gateway/Dockerfile b/api-gateway/Dockerfile index e0c700df9..7abb352ef 100644 --- a/api-gateway/Dockerfile +++ b/api-gateway/Dockerfile @@ -1,12 +1,9 @@ - FROM openjdk:11 ARG PROJECT_VERSION=0.1.0 RUN mkdir -p /home/app WORKDIR /home/app -ENV SPRING_PROFILES_ACTIVE dev +ENV SPRING_PROFILES_ACTIVE=dev COPY api-gateway/ . ADD api-gateway/target/api-gateway-v${PROJECT_VERSION}.jar api-gateway.jar EXPOSE 8080 -ENTRYPOINT ["java", "-Dspring.profiles.active=${SPRING_PROFILES_ACTIVE}", "-jar", "api-gateway.jar"] - - +ENTRYPOINT ["java", "-Dspring.profiles.active=${SPRING_PROFILES_ACTIVE}", "-jar", "api-gateway.jar"] \ No newline at end of file diff --git a/api-gateway/compose.yml b/api-gateway/compose.yml index 56a28e0a3..e3f0e9af3 100644 --- a/api-gateway/compose.yml +++ b/api-gateway/compose.yml @@ -1,12 +1,21 @@ - version: '3' services: api-gateway-container: - image: selimhorri/api-gateway-ecommerce-boot:0.1.0 + image: juanse201/api-gateway-ecommerce-boot:0.1.0 ports: - 8080:8080 environment: - SPRING_PROFILES_ACTIVE=dev + - SPRING_ZIPKIN_BASE_URL=http://zipkin:9411 + - SPRING_CONFIG_IMPORT=optional:configserver:http://cloud-config-container:9296/ + - EUREKA_CLIENT_REGION=default + - EUREKA_CLIENT_AVAILABILITY_ZONES_DEFAULT=myzone + - EUREKA_CLIENT_SERVICEURL_MYZONE=http://service-discovery-container:8761/eureka + - EUREKA_CLIENT_SERVICEURL_DEFAULTZONE=http://service-discovery-container:8761/eureka/ + networks: + - microservices_network - - +networks: + microservices_network: + external: true + name: microservices_network \ No newline at end of file diff --git a/api-gateway/pom.xml b/api-gateway/pom.xml index d25eb1984..339c585ac 100644 --- a/api-gateway/pom.xml +++ b/api-gateway/pom.xml @@ -64,6 +64,13 @@ pom import + + org.springframework.boot + spring-boot-dependencies + 2.5.7 + pom + import + diff --git a/api-gateway/src/main/resources/application.yml b/api-gateway/src/main/resources/application.yml index dd013a72d..c5148a6f4 100644 --- a/api-gateway/src/main/resources/application.yml +++ b/api-gateway/src/main/resources/application.yml @@ -5,9 +5,9 @@ server: spring: zipkin: - base-url: ${SPRING_ZIPKIN_BASE_URL:http://localhost:9411/} + base-url: ${SPRING_ZIPKIN_BASE_URL:http://zipkin:9411/} config: - import: ${SPRING_CONFIG_IMPORT:optional:configserver:http://localhost:9296} + import: ${SPRING_CONFIG_IMPORT:optional:configserver:http://cloud-config-container:9296} application: name: API-GATEWAY profiles: @@ -62,6 +62,12 @@ spring: predicates: - Path=/app/** +eureka: + client: + service-url: + defaultZone: ${EUREKA_CLIENT_SERVICEURL_DEFAULTZONE:http://service-discovery-container:8761/eureka/} + + resilience4j: circuitbreaker: instances: diff --git a/build-and-push.ps1 b/build-and-push.ps1 new file mode 100644 index 000000000..d737fa071 --- /dev/null +++ b/build-and-push.ps1 @@ -0,0 +1,49 @@ +# PowerShell script to build and push all microservice images to Docker Hub + +$ErrorActionPreference = 'Continue' + +# Remove all local Docker images (force, only if any exist) +$localImages = docker images -q +if ($localImages) { + Write-Host "Removing all local Docker images..." -ForegroundColor Yellow + docker rmi -f $localImages +} else { + Write-Host "No local Docker images to remove." -ForegroundColor Green +} + +# Array of service names +$services = @( + "cloud-config", + "service-discovery", + "api-gateway", + "proxy-client", + "order-service", + "payment-service", + "product-service", + "shipping-service", + "user-service", + "favourite-service" +) + +# Docker Hub username +$dockerUser = "juanse201" + + +foreach ($service in $services) { + $imageName = "$dockerUser/$service-ecommerce-boot:0.1.0" + $dockerfilePath = "./$service/Dockerfile" + Write-Host "Building $imageName ..." + docker build -t $imageName -f $dockerfilePath . + if ($LASTEXITCODE -ne 0) { + Write-Host "Build failed for $service" -ForegroundColor Red + exit 1 + } + Write-Host "Pushing $imageName ..." + docker push $imageName + if ($LASTEXITCODE -ne 0) { + Write-Host "Push failed for $service" -ForegroundColor Red + exit 1 + } +} + +Write-Host "All images built and pushed successfully!" -ForegroundColor Green diff --git a/cloud-config/Dockerfile b/cloud-config/Dockerfile index cd5b044ee..04a638678 100644 --- a/cloud-config/Dockerfile +++ b/cloud-config/Dockerfile @@ -1,4 +1,3 @@ - FROM openjdk:11 ARG PROJECT_VERSION=0.1.0 RUN mkdir -p /home/app @@ -6,6 +5,4 @@ WORKDIR /home/app COPY cloud-config/ . ADD cloud-config/target/cloud-config-v${PROJECT_VERSION}.jar cloud-config.jar EXPOSE 9296 -ENTRYPOINT ["java", "-jar", "cloud-config.jar"] - - +ENTRYPOINT ["java", "-jar", "cloud-config.jar"] \ No newline at end of file diff --git a/cloud-config/compose.yml b/cloud-config/compose.yml index 2ebe9109c..1eb97d9c1 100644 --- a/cloud-config/compose.yml +++ b/cloud-config/compose.yml @@ -1,12 +1,10 @@ - version: '3' services: cloud-config-container: - image: selimhorri/cloud-config-ecommerce-boot:0.1.0 + image: juanse201/cloud-config-ecommerce-boot:0.1.0 ports: - 9296:9296 environment: - SPRING_PROFILES_ACTIVE=dev - diff --git a/cloud-config/src/main/resources/application.yml b/cloud-config/src/main/resources/application.yml index 6ac6f81e6..b6f4e30fe 100644 --- a/cloud-config/src/main/resources/application.yml +++ b/cloud-config/src/main/resources/application.yml @@ -4,7 +4,7 @@ server: spring: zipkin: - base-url: ${SPRING_ZIPKIN_BASE_URL:http://localhost:9411/} + base-url: ${SPRING_ZIPKIN_BASE_URL:http://zipkin:9411/} application: name: CLOUD-CONFIG cloud: @@ -14,6 +14,11 @@ spring: uri: https://github.com/SelimHorri/cloud-config-server clone-on-start: true +eureka: + client: + service-url: + defaultZone: ${EUREKA_CLIENT_SERVICEURL_DEFAULTZONE:http://service-discovery-container:8761/eureka/} + resilience4j: circuitbreaker: instances: diff --git a/cloud-config/src/test/java/com/selimhorri/app/integration/CloudConfigApplicationIntegrationTests.java b/cloud-config/src/test/java/com/selimhorri/app/integration/CloudConfigApplicationIntegrationTests.java new file mode 100644 index 000000000..0e98424d2 --- /dev/null +++ b/cloud-config/src/test/java/com/selimhorri/app/integration/CloudConfigApplicationIntegrationTests.java @@ -0,0 +1,42 @@ +package com.selimhorri.app.integration; + +import com.selimhorri.app.CloudConfigApplication; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest(classes = CloudConfigApplication.class) +class CloudConfigApplicationIntegrationTests { + + @Autowired + private ApplicationContext context; + + @Test + void contextLoads() { + assertNotNull(context); + } + + @Test + void configServerBeanExists() { + // ConfigServerConfiguration bean should exist if config server is enabled + assertTrue(context.getBeanNamesForType(org.springframework.cloud.config.server.config.ConfigServerConfiguration.class).length > 0); + } + + @Test + void eurekaClientBeanExists() { + // Eureka client bean should exist if Eureka is enabled + assertTrue(context.getBeanNamesForType(org.springframework.cloud.netflix.eureka.EurekaClientConfigBean.class).length > 0); + } + + @Test + void mainClassIsLoaded() { + assertNotNull(context.getBean(CloudConfigApplication.class)); + } + + @Test + void environmentIsNotNull() { + assertNotNull(context.getEnvironment()); + } +} \ No newline at end of file diff --git a/cloud-config/src/test/java/com/selimhorri/app/unit/CloudConfigApplicationUnitTests.java b/cloud-config/src/test/java/com/selimhorri/app/unit/CloudConfigApplicationUnitTests.java new file mode 100644 index 000000000..f12aa547e --- /dev/null +++ b/cloud-config/src/test/java/com/selimhorri/app/unit/CloudConfigApplicationUnitTests.java @@ -0,0 +1,30 @@ +package com.selimhorri.app.unit; + +import com.selimhorri.app.CloudConfigApplication; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import java.lang.annotation.Annotation; +import static org.junit.jupiter.api.Assertions.*; + +class CloudConfigApplicationUnitTests { + + @Test + void testSpringBootApplicationAnnotationPresent() { + assertTrue(CloudConfigApplication.class.isAnnotationPresent(org.springframework.boot.autoconfigure.SpringBootApplication.class)); + } + + @Test + void testEnableEurekaClientAnnotationPresent() { + assertTrue(CloudConfigApplication.class.isAnnotationPresent(org.springframework.cloud.netflix.eureka.EnableEurekaClient.class)); + } + + @Test + void testEnableConfigServerAnnotationPresent() { + assertTrue(CloudConfigApplication.class.isAnnotationPresent(org.springframework.cloud.config.server.EnableConfigServer.class)); + } + + @Test + void testClassIsPublic() { + assertTrue(java.lang.reflect.Modifier.isPublic(CloudConfigApplication.class.getModifiers())); + } +} \ No newline at end of file diff --git a/compose.yml b/compose.yml index 2d6442c32..c6897ef82 100644 --- a/compose.yml +++ b/compose.yml @@ -1,70 +1,349 @@ - +#docker-compose version: '3' services: - zipkin-container: + zipkin: image: openzipkin/zipkin ports: - 9411:9411 + networks: + - microservices_network + service-discovery-container: - image: selimhorri/service-discovery-ecommerce-boot:0.1.0 + image: juanse201/service-discovery-ecommerce-boot:0.1.0 ports: - 8761:8761 + networks: + microservices_network: + aliases: + - service-discovery-container environment: - - SPRING_PROFILES_ACTIVE=dev + - SPRING_ZIPKIN_BASE-URL=http://zipkin:9411 + - SPRING_CONFIG_IMPORT=optional:configserver:http://cloud-config-container:9296/ + cloud-config-container: - image: selimhorri/cloud-config-ecommerce-boot:0.1.0 + image: juanse201/cloud-config-ecommerce-boot:0.1.0 ports: - 9296:9296 + networks: + microservices_network: + aliases: + - cloud-config-container environment: - SPRING_PROFILES_ACTIVE=dev + - SPRING_ZIPKIN_BASE-URL=http://zipkin:9411 + - EUREKA_CLIENT_REGION=default + - EUREKA_CLIENT_AVAILABILITYZONES_DEFAULT=myzone + - EUREKA_CLIENT_SERVICEURL_MYZONE=http://service-discovery-container:8761/eureka + - EUREKA_CLIENT_SERVICEURL_DEFAULTZONE=http://service-discovery-container:8761/eureka/ + api-gateway-container: - image: selimhorri/api-gateway-ecommerce-boot:0.1.0 + image: juanse201/api-gateway-ecommerce-boot:0.1.0 ports: - 8080:8080 + networks: + - microservices_network environment: - SPRING_PROFILES_ACTIVE=dev + - SPRING_ZIPKIN_BASE-URL=http://zipkin:9411 + - EUREKA_CLIENT_REGION=default + - EUREKA_CLIENT_AVAILABILITYZONES_DEFAULT=myzone + - EUREKA_CLIENT_SERVICEURL_MYZONE=http://service-discovery-container:8761/eureka + - EUREKA_CLIENT_SERVICEURL_DEFAULTZONE=http://service-discovery-container:8761/eureka/ + - SPRING_CONFIG_IMPORT=optional:configserver:http://cloud-config-container:9296/ + - EUREKA_CLIENT_REGISTER_WITH_EUREKA=true + - EUREKA_CLIENT_FETCH_REGISTRY=true + - SPRING_JPA_HIBERNATE_DDL_AUTO=update + - EUREKA_INSTANCE_PREFER_IP_ADDRESS=true + - EUREKA_INSTANCE_HOSTNAME=api-gateway-container + - EUREKA_INSTANCE_NON_SECURE_PORT=8080 + proxy-client-container: - image: selimhorri/proxy-client-ecommerce-boot:0.1.0 + image: juanse201/proxy-client-ecommerce-boot:0.1.0 ports: - 8900:8900 + networks: + - microservices_network environment: - SPRING_PROFILES_ACTIVE=dev + - SPRING_ZIPKIN_BASE-URL=http://zipkin:9411 + - EUREKA_CLIENT_REGION=default + - EUREKA_CLIENT_AVAILABILITYZONES_DEFAULT=myzone + - EUREKA_CLIENT_SERVICEURL_MYZONE=http://service-discovery-container:8761/eureka + - EUREKA_CLIENT_SERVICEURL_DEFAULTZONE=http://service-discovery-container:8761/eureka/ + - SPRING_CONFIG_IMPORT=optional:configserver:http://cloud-config-container:9296/ + - EUREKA_CLIENT_REGISTER_WITH_EUREKA=true + - EUREKA_CLIENT_FETCH_REGISTRY=true + - SPRING_JPA_HIBERNATE_DDL_AUTO=update + - EUREKA_INSTANCE_PREFER_IP_ADDRESS=true + - EUREKA_INSTANCE_HOSTNAME=proxy-client-container + - EUREKA_INSTANCE_NON_SECURE_PORT=8900 + order-service-container: - image: selimhorri/order-service-ecommerce-boot:0.1.0 + image: juanse201/order-service-ecommerce-boot:0.1.0 ports: - 8300:8300 + networks: + - microservices_network environment: - SPRING_PROFILES_ACTIVE=dev + - SPRING_ZIPKIN_BASE-URL=http://zipkin:9411 + - EUREKA_CLIENT_REGION=default + - EUREKA_CLIENT_AVAILABILITYZONES_DEFAULT=myzone + - EUREKA_CLIENT_SERVICEURL_MYZONE=http://service-discovery-container:8761/eureka + - EUREKA_CLIENT_SERVICEURL_DEFAULTZONE=http://service-discovery-container:8761/eureka/ + - SPRING_CONFIG_IMPORT=optional:configserver:http://cloud-config-container:9296/ + - EUREKA_CLIENT_REGISTER_WITH_EUREKA=true + - EUREKA_CLIENT_FETCH_REGISTRY=true + - SPRING_JPA_HIBERNATE_DDL_AUTO=update + - EUREKA_INSTANCE_PREFER_IP_ADDRESS=true + - EUREKA_INSTANCE_HOSTNAME=order-service-container + - EUREKA_INSTANCE_NON_SECURE_PORT=8300 + payment-service-container: - image: selimhorri/payment-service-ecommerce-boot:0.1.0 + image: juanse201/payment-service-ecommerce-boot:0.1.0 ports: - 8400:8400 + networks: + - microservices_network environment: - SPRING_PROFILES_ACTIVE=dev + - SPRING_ZIPKIN_BASE-URL=http://zipkin:9411 + - EUREKA_CLIENT_REGION=default + - EUREKA_CLIENT_AVAILABILITYZONES_DEFAULT=myzone + - EUREKA_CLIENT_SERVICEURL_MYZONE=http://service-discovery-container:8761/eureka + - EUREKA_CLIENT_SERVICEURL_DEFAULTZONE=http://service-discovery-container:8761/eureka/ + - SPRING_CONFIG_IMPORT=optional:configserver:http://cloud-config-container:9296/ + - EUREKA_CLIENT_REGISTER_WITH_EUREKA=true + - EUREKA_CLIENT_FETCH_REGISTRY=true + - SPRING_JPA_HIBERNATE_DDL_AUTO=update + - EUREKA_INSTANCE_PREFER_IP_ADDRESS=true + - EUREKA_INSTANCE_HOSTNAME=payment-service-container + - EUREKA_INSTANCE_NON_SECURE_PORT=8400 + product-service-container: - image: selimhorri/product-service-ecommerce-boot:0.1.0 + image: juanse201/product-service-ecommerce-boot:0.1.0 ports: - 8500:8500 + networks: + - microservices_network environment: - SPRING_PROFILES_ACTIVE=dev + - SPRING_ZIPKIN_BASE-URL=http://zipkin:9411 + - EUREKA_CLIENT_REGION=default + - EUREKA_CLIENT_AVAILABILITYZONES_DEFAULT=myzone + - EUREKA_CLIENT_SERVICEURL_MYZONE=http://service-discovery-container:8761/eureka + - EUREKA_CLIENT_SERVICEURL_DEFAULTZONE=http://service-discovery-container:8761/eureka/ + - SPRING_CONFIG_IMPORT=optional:configserver:http://cloud-config-container:9296/ + - EUREKA_CLIENT_REGISTER_WITH_EUREKA=true + - EUREKA_CLIENT_FETCH_REGISTRY=true + - SPRING_JPA_HIBERNATE_DDL_AUTO=update + - EUREKA_INSTANCE_PREFER_IP_ADDRESS=true + - EUREKA_INSTANCE_HOSTNAME=product-service-container + - EUREKA_INSTANCE_NON_SECURE_PORT=8500 + shipping-service-container: - image: selimhorri/shipping-service-ecommerce-boot:0.1.0 + image: juanse201/shipping-service-ecommerce-boot:0.1.0 ports: - 8600:8600 + networks: + - microservices_network environment: - SPRING_PROFILES_ACTIVE=dev + - SPRING_ZIPKIN_BASE-URL=http://zipkin:9411 + - EUREKA_CLIENT_REGION=default + - EUREKA_CLIENT_AVAILABILITYZONES_DEFAULT=myzone + - EUREKA_CLIENT_SERVICEURL_MYZONE=http://service-discovery-container:8761/eureka + - EUREKA_CLIENT_SERVICEURL_DEFAULTZONE=http://service-discovery-container:8761/eureka/ + - SPRING_CONFIG_IMPORT=optional:configserver:http://cloud-config-container:9296/ + - EUREKA_CLIENT_REGISTER_WITH_EUREKA=true + - EUREKA_CLIENT_FETCH_REGISTRY=true + - SPRING_JPA_HIBERNATE_DDL_AUTO=update + - EUREKA_INSTANCE_PREFER_IP_ADDRESS=true + - EUREKA_INSTANCE_HOSTNAME=shipping-service-container + - EUREKA_INSTANCE_NON_SECURE_PORT=8600 + user-service-container: - image: selimhorri/user-service-ecommerce-boot:0.1.0 + image: juanse201/user-service-ecommerce-boot:0.1.0 ports: - 8700:8700 + networks: + - microservices_network environment: - SPRING_PROFILES_ACTIVE=dev + - SPRING_ZIPKIN_BASE-URL=http://zipkin:9411 + - EUREKA_CLIENT_REGION=default + - EUREKA_CLIENT_AVAILABILITYZONES_DEFAULT=myzone + - EUREKA_CLIENT_SERVICEURL_MYZONE=http://service-discovery-container:8761/eureka + - EUREKA_CLIENT_SERVICEURL_DEFAULTZONE=http://service-discovery-container:8761/eureka/ + - SPRING_CONFIG_IMPORT=optional:configserver:http://cloud-config-container:9296/ + - EUREKA_CLIENT_REGISTER_WITH_EUREKA=true + - EUREKA_CLIENT_FETCH_REGISTRY=true + - SPRING_JPA_HIBERNATE_DDL_AUTO=update + - EUREKA_INSTANCE_PREFER_IP_ADDRESS=true + - EUREKA_INSTANCE_HOSTNAME=user-service-container + - EUREKA_INSTANCE_NON_SECURE_PORT=8700 + favourite-service-container: - image: selimhorri/favourite-service-ecommerce-boot:0.1.0 + image: juanse201/favourite-service-ecommerce-boot:0.1.0 ports: - 8800:8800 + networks: + - microservices_network environment: - SPRING_PROFILES_ACTIVE=dev + - SPRING_ZIPKIN_BASE-URL=http://zipkin:9411 + - EUREKA_CLIENT_REGION=default + - EUREKA_CLIENT_AVAILABILITYZONES_DEFAULT=myzone + - EUREKA_CLIENT_SERVICEURL_MYZONE=http://service-discovery-container:8761/eureka + - EUREKA_CLIENT_SERVICEURL_DEFAULTZONE=http://service-discovery-container:8761/eureka/ + - SPRING_CONFIG_IMPORT=optional:configserver:http://cloud-config-container:9296/ + - EUREKA_CLIENT_REGISTER_WITH_EUREKA=true + - EUREKA_CLIENT_FETCH_REGISTRY=true + - SPRING_JPA_HIBERNATE_DDL_AUTO=update + - EUREKA_INSTANCE_PREFER_IP_ADDRESS=true + - EUREKA_INSTANCE_HOSTNAME=favourite-service-container + - EUREKA_INSTANCE_NON_SECURE_PORT=8800 + + jenkins: + build: + context: ./jenkins + dockerfile: Dockerfile + container_name: jenkins-local + ports: + - "8080:8080" + - "50000:50000" + volumes: + # Persistent Jenkins home (plugins, jobs, config) + - jenkins_home:/var/jenkins_home + + # Mount host Docker socket (so Jenkins can build images) + # ⚠️ On Windows, this only works if you're using WSL2 backend + - /var/run/docker.sock:/var/run/docker.sock + + # Share kubeconfig and Minikube certs + - ~/.kube:/root/.kube:ro + - ~/.minikube:/root/.minikube:ro + + environment: + JAVA_OPTS: "-Djenkins.install.runSetupWizard=false" + privileged: true # Needed for some Docker operations + + prometheus: + image: prom/prometheus:latest + container_name: prometheus + ports: + - "9090:9090" + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml + - ./prometheus/alert.rules.yml:/etc/prometheus/alert.rules.yml + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--storage.tsdb.retention.time=30d' + - '--web.console.libraries=/etc/prometheus/console_libraries' + - '--web.console.templates=/etc/prometheus/consoles' + - '--web.enable-lifecycle' + networks: + - microservices_network + depends_on: + - api-gateway-container + + alertmanager: + image: prom/alertmanager:latest + container_name: alertmanager + ports: + - "9093:9093" + volumes: + - ./prometheus/alertmanager.yml:/etc/alertmanager/alertmanager.yml + command: + - '--config.file=/etc/alertmanager/alertmanager.yml' + - '--storage.path=/alertmanager' + networks: + - microservices_network + restart: unless-stopped + + grafana: + image: grafana/grafana:latest + container_name: grafana + ports: + - "3000:3000" + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=admin + - GF_USERS_ALLOW_SIGN_UP=false + - GF_SERVER_ROOT_URL=http://localhost:3000 + - GF_INSTALL_PLUGINS=grafana-piechart-panel + volumes: + - grafana-storage:/var/lib/grafana + networks: + - microservices_network + depends_on: + - prometheus + + jaeger: + image: jaegertracing/all-in-one:latest + container_name: jaeger + ports: + - "16686:16686" + - "6831:6831/udp" + - "6832:6832/udp" + - "5778:5778" + - "14250:14250" + - "14268:14268" + - "9411:9411" + environment: + - COLLECTOR_ZIPKIN_HOST_PORT=:9411 + networks: + - microservices_network + + elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:8.11.1 + container_name: elasticsearch + environment: + - discovery.type=single-node + - xpack.security.enabled=false + - "ES_JAVA_OPTS=-Xms512m -Xmx512m" + ports: + - "9200:9200" + volumes: + - elasticsearch-data:/usr/share/elasticsearch/data + networks: + - microservices_network + + logstash: + image: docker.elastic.co/logstash/logstash:8.11.1 + container_name: logstash + ports: + - "5000:5000" + - "9600:9600" + volumes: + - ./elk/logstash.conf:/usr/share/logstash/pipeline/logstash.conf + environment: + - "LS_JAVA_OPTS=-Xmx512m -Xms512m" + - "PIPELINE_WORKERS=2" + networks: + - microservices_network + depends_on: + - elasticsearch + + kibana: + image: docker.elastic.co/kibana/kibana:8.11.1 + container_name: kibana + ports: + - "5601:5601" + environment: + - ELASTICSEARCH_HOSTS=http://elasticsearch:9200 + networks: + - microservices_network + depends_on: + - elasticsearch +volumes: + jenkins_home: + grafana-storage: + elasticsearch-data: +networks: + microservices_network: + driver: bridge diff --git a/docker-compose.observability.yml b/docker-compose.observability.yml new file mode 100644 index 000000000..b1b75a5f1 --- /dev/null +++ b/docker-compose.observability.yml @@ -0,0 +1,158 @@ +# Docker Compose para Stack de Observabilidad (versión ligera) +# Solo incluye los servicios de observabilidad y 3 microservicios de ejemplo + +services: + # ===== MICROSERVICIOS DE EJEMPLO (solo 3) ===== + + product-service-container: + image: juanse201/product-service-ecommerce-boot:0.1.0 + ports: + - 8500:8500 + networks: + - microservices_network + environment: + - SPRING_PROFILES_ACTIVE=dev + - SPRING_ZIPKIN_BASE-URL=http://jaeger:9411 + - EUREKA_CLIENT_REGISTER_WITH_EUREKA=false + - SPRING_JPA_HIBERNATE_DDL_AUTO=update + + order-service-container: + image: juanse201/order-service-ecommerce-boot:0.1.0 + ports: + - 8300:8300 + networks: + - microservices_network + environment: + - SPRING_PROFILES_ACTIVE=dev + - SPRING_ZIPKIN_BASE-URL=http://jaeger:9411 + - EUREKA_CLIENT_REGISTER_WITH_EUREKA=false + - SPRING_JPA_HIBERNATE_DDL_AUTO=update + + payment-service-container: + image: juanse201/payment-service-ecommerce-boot:0.1.0 + ports: + - 8400:8400 + networks: + - microservices_network + environment: + - SPRING_PROFILES_ACTIVE=dev + - SPRING_ZIPKIN_BASE-URL=http://jaeger:9411 + - EUREKA_CLIENT_REGISTER_WITH_EUREKA=false + - SPRING_JPA_HIBERNATE_DDL_AUTO=update + + # ===== STACK DE OBSERVABILIDAD ===== + + prometheus: + image: prom/prometheus:latest + container_name: prometheus + ports: + - "9091:9090" # Changed to 9091 to avoid port conflict + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml + - ./prometheus/alert.rules.yml:/etc/prometheus/alert.rules.yml + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--storage.tsdb.retention.time=7d' + - '--web.enable-lifecycle' + networks: + - microservices_network + restart: unless-stopped + + alertmanager: + image: prom/alertmanager:latest + container_name: alertmanager + ports: + - "9093:9093" + volumes: + - ./prometheus/alertmanager.yml:/etc/alertmanager/alertmanager.yml + command: + - '--config.file=/etc/alertmanager/alertmanager.yml' + - '--storage.path=/alertmanager' + networks: + - microservices_network + restart: unless-stopped + + grafana: + image: grafana/grafana:latest + container_name: grafana + ports: + - "3000:3000" + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=admin + - GF_USERS_ALLOW_SIGN_UP=false + - GF_SERVER_ROOT_URL=http://localhost:3000 + volumes: + - grafana-storage:/var/lib/grafana + networks: + - microservices_network + depends_on: + - prometheus + restart: unless-stopped + + jaeger: + image: jaegertracing/all-in-one:latest + container_name: jaeger + ports: + - "16686:16686" # Jaeger UI + - "9411:9411" # Zipkin compatible endpoint + environment: + - COLLECTOR_ZIPKIN_HOST_PORT=:9411 + networks: + - microservices_network + restart: unless-stopped + + elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:8.11.1 + container_name: elasticsearch + environment: + - discovery.type=single-node + - xpack.security.enabled=false + - "ES_JAVA_OPTS=-Xms256m -Xmx256m" # Reducido para ahorrar memoria + ports: + - "9200:9200" + volumes: + - elasticsearch-data:/usr/share/elasticsearch/data + networks: + - microservices_network + restart: unless-stopped + + logstash: + image: docker.elastic.co/logstash/logstash:8.11.1 + container_name: logstash + ports: + - "5000:5000" + - "9600:9600" + volumes: + - ./elk/logstash.conf:/usr/share/logstash/pipeline/logstash.conf + environment: + - "LS_JAVA_OPTS=-Xmx256m -Xms256m" # Reducido para ahorrar memoria + - "PIPELINE_WORKERS=1" + networks: + - microservices_network + depends_on: + - elasticsearch + restart: unless-stopped + + kibana: + image: docker.elastic.co/kibana/kibana:8.11.1 + container_name: kibana + ports: + - "5601:5601" + environment: + - ELASTICSEARCH_HOSTS=http://elasticsearch:9200 + networks: + - microservices_network + depends_on: + - elasticsearch + restart: unless-stopped + +volumes: + grafana-storage: + elasticsearch-data: + + +networks: + microservices_network: + driver: bridge diff --git a/docs/DockerImages.md b/docs/DockerImages.md new file mode 100644 index 000000000..58c7dc79e --- /dev/null +++ b/docs/DockerImages.md @@ -0,0 +1,11 @@ +#To rebuild docker images you need to be in root and create jar files + +mvn clean package -DskipTests + +#The to build and upload the docker images you need to be in root and use the command + +docker build -f payment-service/Dockerfile -t juanse201/payment-service-ecommerce-boot:0.1.0 . + +#thats an example + +docker build -f {microservice name}/Dockerfile -t {dockerHubUser}/{ImageName} . \ No newline at end of file diff --git a/docs/Jenkins.md b/docs/Jenkins.md new file mode 100644 index 000000000..2d1d4f404 --- /dev/null +++ b/docs/Jenkins.md @@ -0,0 +1,61 @@ +Order to run jenkins + +root + +docker-compose build --no-cache jenkins + +#inside jenkins folder updates docker desktop image +docker build -t ecommerce-microservice-backend-app-jenkins:latest . + +#to get image faster +docker-compose up -d jenkins +docker-compose down jenkins + +#kubernets / minikube + +minikube start + +#if any pods are up + +kubectl get pods + +kubectl delete deployment jenkins + +#if theres no image in local +minikube image load ecommerce-microservice-backend-app-jenkins:latest + +kubectl apply -f K8/jenkins/jenkins.yaml + +kubectl rollout restart deployment + +#check new pod is up with update jenkins image +kubectl get pods + +#run command + +minikube service jenkins + + +#Check docker socket in kuber pod in case of build not functioning + +kubectl get pods + +kubectl exec -it [id de pod] -- ls -l /var/run/docker.sock + +minikube ssh "sudo chgrp 102 /var/run/docker.sock" + +#Also dont forget to make namespaces for stage and prod so when you run kubernet pods in stage you dont take down prod + +kubectl create namespace stage +kubectl create namespace prod + +#And give jenkins permission to access and use this namespaces + +kubectl create rolebinding jenkins-prod-admin ` + --clusterrole=admin ` + --serviceaccount=default:jenkins ` + --namespace=prod + + +kubectl exec -it jenkins-c5ff74544-4tk4m -- cat /var/jenkins_home/secrets/initialAdminPassword + diff --git a/docs/OBSERVABILIDAD.md b/docs/OBSERVABILIDAD.md new file mode 100644 index 000000000..21fcc3ee2 --- /dev/null +++ b/docs/OBSERVABILIDAD.md @@ -0,0 +1,695 @@ +# Observabilidad +## E-Commerce Microservices Backend Application + +**Proyecto**: Ingeniería de Software V +**Fecha**: Noviembre 2025 +**Versión**: 1.0 + +--- + +## Tabla de Contenidos + +1. [Introducción](#introducción) +2. [Stack de Observabilidad](#stack-de-observabilidad) +3. [Configuración y Deployment](#configuración-y-deployment) +4. [Métricas](#métricas) +5. [Logs](#logs) +6. [Traces](#traces) +7. [Dashboards](#dashboards) +8. [Testing](#testing) + +--- + +## Introducción + +Este documento describe la implementación completa del stack de observabilidad para el sistema de microservicios e-commerce, siguiendo las mejores prácticas de la industria con los tres pilares fundamentales: **Métricas, Logs y Traces**. + +### Objetivos + +- Visibilidad completa del sistema +- Detección temprana de problemas +- Análisis de performance +- Debugging distribuido +- Alerting proactivo + +--- + +## ️ Stack de Observabilidad + +### Componentes Principales + +| Componente | Puerto | Propósito | +|------------|--------|-----------| +| **Prometheus** | 9090 | Recolección y almacenamiento de métricas | +| **Grafana** | 3000 | Visualización de métricas y dashboards | +| **Elasticsearch** | 9200 | Almacenamiento de logs | +| **Kibana** | 5601 | Visualización y análisis de logs | +| **Logstash** | 5000 | Procesamiento y agregación de logs | +| **Jaeger** | 16686 | Distributed tracing | +| **Alertmanager** | 9093 | Gestión de alertas | + +### Arquitectura + +``` +┌─────────────────────────────────────────────────────────┐ +│ Microservicios │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ Order │ │ Product │ │ Payment │ ... │ +│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ +│ │ │ │ │ +└───────┼─────────────┼──────────────┼────────────────────┘ + │ │ │ + ├─────────────┼──────────────┼──→ Metrics → Prometheus → Grafana + │ │ │ + ├─────────────┼──────────────┼──→ Logs → Logstash → Elasticsearch → Kibana + │ │ │ + └─────────────┴──────────────┴──→ Traces → Jaeger +``` + +--- + +## ️ Configuración y Deployment + +### Docker Compose + +El stack completo se despliega con: + +```bash +docker compose up -d +``` + +**Servicios incluidos en `compose.yml`**: +- Microservicios (Order, Product, Payment, User, Shipping, Favourite) +- Service Discovery (Eureka) +- API Gateway +- Cloud Config +- Observabilidad Stack (Prometheus, Grafana, ELK, Jaeger) + +### Configuración de Microservicios + +Cada microservicio está configurado para exportar métricas, logs y traces: + +```yaml +# application.yml +spring: + zipkin: + base-url: http://jaeger:9411/ + +management: + endpoints: + web: + exposure: + include: health,info,metrics,prometheus,env,loggers + endpoint: + health: + show-details: always + probes: + enabled: true + metrics: + export: + prometheus: + enabled: true + tags: + application: ${spring.application.name} + environment: ${spring.profiles.active} +``` + +--- + +## Métricas + +### Prometheus + +**Acceso**: `http://localhost:9091` + +#### Configuración + +```yaml +# prometheus.yml +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: 'spring-actuator' + metrics_path: '/actuator/prometheus' + static_configs: + - targets: + - 'order-service:8300' + - 'product-service:8500' + - 'payment-service:8400' +``` + +#### Métricas Exportadas + +**Total por servicio**: ~342 líneas + +**Categorías**: + +1. **JVM Metrics** +```promql +jvm_memory_used_bytes{area="heap"} +jvm_memory_max_bytes{area="heap"} +jvm_threads_live_threads +jvm_gc_pause_seconds_count +``` + +2. **HTTP Metrics** +```promql +http_server_requests_seconds_count +http_server_requests_seconds_sum +http_server_requests_active_seconds_active_count +``` + +3. **Resilience4j Metrics** (15 métricas) +```promql +# Bulkhead +resilience4j_bulkhead_available_concurrent_calls +resilience4j_bulkhead_max_allowed_concurrent_calls +resilience4j_bulkhead_concurrent_calls + +# Circuit Breaker +resilience4j_circuitbreaker_state +resilience4j_circuitbreaker_failure_rate +resilience4j_circuitbreaker_buffered_calls + +# Retry +resilience4j_retry_calls +``` + +4. **Database Metrics** +```promql +hikaricp_connections_active +hikaricp_connections_idle +hikaricp_connections_pending +``` + +5. **System Metrics** +```promql +system_cpu_usage +system_cpu_count +process_uptime_seconds +``` + +#### Queries Útiles + +**Request Rate**: +```promql +rate(http_server_requests_seconds_count{application="ORDER-SERVICE"}[1m]) +``` + +**Memory Usage %**: +```promql +100 * (jvm_memory_used_bytes{area="heap",application="ORDER-SERVICE"} +/ jvm_memory_max_bytes{area="heap",application="ORDER-SERVICE"}) +``` + +**Circuit Breaker State**: +```promql +resilience4j_circuitbreaker_state{name="orderService"} +``` + +**p95 Request Duration**: +```promql +histogram_quantile(0.95, + rate(http_server_requests_seconds_bucket{application="ORDER-SERVICE"}[5m])) +``` + +**Error Rate**: +```promql +rate(http_server_requests_seconds_count{status=~"5.."}[1m]) +``` + +### Grafana + +**Acceso**: `http://localhost:3000` +**Credenciales**: admin / admin + +#### Configuración de Data Source + +1. Click en ️ (Settings) → Data Sources +2. Add data source → Prometheus +3. URL: `http://prometheus:9090` +4. Save & Test + +#### Dashboards Recomendados + +**Dashboard 1: JVM Overview** +- Import ID: `4701` (JVM Micrometer) +- Muestra: Memory, CPU, Threads, GC + +**Dashboard 2: E-Commerce Business Metrics** + +Paneles sugeridos: + +1. **HTTP Request Rate** +```promql +sum(rate(http_server_requests_seconds_count[1m])) by (uri) +``` + +2. **Active Requests** +```promql +http_server_requests_active_seconds_active_count +``` + +3. **Memory Usage** +```promql +jvm_memory_used_bytes{area="heap"} / jvm_memory_max_bytes{area="heap"} * 100 +``` + +4. **Circuit Breaker State** +```promql +resilience4j_circuitbreaker_state +``` + +5. **Database Connections** +```promql +hikaricp_connections_active +hikaricp_connections_idle +``` + +6. **Service Health** +```promql +up{job="spring-actuator"} +``` + +--- + +## Logs + +### Stack ELK + +**Componentes**: +- Elasticsearch (storage) +- Logstash (processing) +- Kibana (visualization) + +### Elasticsearch + +**Acceso**: `http://localhost:9200` + +**Configuración**: +```yaml +# compose.yml +elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:8.11.1 + environment: + - discovery.type=single-node + - xpack.security.enabled=false + ports: + - "9200:9200" +``` + +### Logstash + +**Puerto**: 5000 (input), 9600 (monitoring) + +**Pipeline**: +```ruby +# logstash/pipeline/logstash.conf +input { + tcp { + port => 5000 + codec => json + } +} + +filter { + # Parse and enrich logs + if [logger_name] =~ "com.selimhorri" { + mutate { + add_tag => ["app_log"] + } + } +} + +output { + elasticsearch { + hosts => ["elasticsearch:9200"] + index => "logstash-%{+YYYY.MM.dd}" + } +} +``` + +### Kibana + +**Acceso**: `http://localhost:5601` + +#### Configuración Inicial + +1. Abrir Kibana +2. Menu ☰ → Discover +3. Create data view: + - Name: `logstash-*` + - Timestamp field: `@timestamp` + - Create + +#### Queries Útiles + +**Error Logs**: +``` +level: ERROR +``` + +**Logs de un servicio**: +``` +application: "ORDER-SERVICE" +``` + +**Resilience Events**: +``` +message: *bulkhead* OR message: *retry* OR message: *circuit* +``` + +**Exception Stack Traces**: +``` +exception: * +``` + +### Logging en Microservicios + +**Configuración**: +```yaml +# application.yml +logging: + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n" + level: + root: INFO + com.selimhorri.app: DEBUG + org.springframework.cloud: DEBUG +``` + +**Logback con Logstash**: +```xml + + + logstash:5000 + + traceId + spanId + + +``` + +--- + +## Traces + +### Jaeger + +**Acceso**: `http://localhost:16686` + +**Propósito**: Distributed tracing para rastrear requests a través de múltiples servicios. + +#### Configuración + +```yaml +# application.yml +spring: + zipkin: + base-url: http://jaeger:9411/ + sleuth: + sampler: + probability: 1.0 # 100% sampling en dev +``` + +#### Docker Compose + +```yaml +jaeger: + image: jaegertracing/all-in-one:latest + ports: + - "16686:16686" # UI + - "9411:9411" # Zipkin compatible endpoint +``` + +#### Uso + +1. Abrir UI: `http://localhost:16686` +2. Seleccionar servicio: "order-service" +3. Click "Find Traces" +4. Ver detalles de traces específicos + +**Información visualizada**: +- Duración total del request +- Latencia por servicio +- Dependencias entre servicios +- Errores y excepciones +- Tags y metadata + +#### Análisis de Performance + +**Identificar servicios lentos**: +- Ver el timeline de cada span +- Comparar duraciones +- Detectar cuellos de botella + +**Debugging de errores**: +- Filtrar por error tags +- Ver stack traces +- Analizar flow del request + +--- + +## Dashboards + +### Dashboard Principal - Service Health + +**Panels**: + +1. **Service Uptime** +```promql +up{job="spring-actuator"} +``` + +2. **Request Rate (requests/sec)** +```promql +sum(rate(http_server_requests_seconds_count[1m])) by (application) +``` + +3. **Error Rate** +```promql +sum(rate(http_server_requests_seconds_count{status=~"5.."}[1m])) by (application) +``` + +4. **Response Time p95** +```promql +histogram_quantile(0.95, + sum(rate(http_server_requests_seconds_bucket[5m])) by (le, application)) +``` + +5. **JVM Memory Usage** +```promql +jvm_memory_used_bytes{area="heap"} / jvm_memory_max_bytes{area="heap"} * 100 +``` + +6. **Active Database Connections** +```promql +hikaricp_connections_active +``` + +### Dashboard Resilience Patterns + +**Panels**: + +1. **Circuit Breaker State** +```promql +resilience4j_circuitbreaker_state +``` + +2. **Bulkhead Concurrent Calls** +```promql +resilience4j_bulkhead_concurrent_calls +``` + +3. **Retry Attempts Rate** +```promql +rate(resilience4j_retry_calls_total[1m]) +``` + +4. **Bulkhead Rejected Calls** +```promql +rate(resilience4j_bulkhead_rejected_calls_total[1m]) +``` + +--- + +## 🧪 Testing + +### Script de Verificación + +```powershell +# test-observability.ps1 + +$baseUrl = "http://localhost:8300/order-service" + +# 1. Verificar Prometheus +Write-Host "Testing Prometheus..." -ForegroundColor Cyan +$prom = Invoke-RestMethod -Uri "http://localhost:9091/api/v1/targets" +Write-Host " Targets: $($prom.data.activeTargets.Count)" -ForegroundColor Green + +# 2. Verificar métricas +Write-Host "`nTesting Metrics..." -ForegroundColor Cyan +$metrics = Invoke-RestMethod -Uri "$baseUrl/actuator/metrics" +Write-Host " Total metrics: $($metrics.names.Count)" -ForegroundColor Green +$resilience = $metrics.names | Where-Object {$_ -like "*resilience4j*"} +Write-Host " Resilience4j: $($resilience.Count)" -ForegroundColor Green + +# 3. Verificar Grafana +Write-Host "`nTesting Grafana..." -ForegroundColor Cyan +try { + $grafana = Invoke-RestMethod -Uri "http://localhost:3000/api/health" + Write-Host " Status: $($grafana.database)" -ForegroundColor Green +} catch { + Write-Host " Grafana not ready" -ForegroundColor Yellow +} + +# 4. Verificar Elasticsearch +Write-Host "`nTesting Elasticsearch..." -ForegroundColor Cyan +try { + $es = Invoke-RestMethod -Uri "http://localhost:9200/_cluster/health" + Write-Host " Status: $($es.status)" -ForegroundColor Green +} catch { + Write-Host " Elasticsearch not ready" -ForegroundColor Yellow +} + +# 5. Verificar Kibana +Write-Host "`nTesting Kibana..." -ForegroundColor Cyan +try { + $kibana = Invoke-RestMethod -Uri "http://localhost:5601/api/status" + Write-Host " Overall: $($kibana.status.overall.level)" -ForegroundColor Green +} catch { + Write-Host " Kibana not ready" -ForegroundColor Yellow +} + +# 6. Verificar Jaeger +Write-Host "`nTesting Jaeger..." -ForegroundColor Cyan +try { + Invoke-WebRequest -Uri "http://localhost:16686" -UseBasicParsing | Out-Null + Write-Host " UI accessible" -ForegroundColor Green +} catch { + Write-Host " Jaeger not ready" -ForegroundColor Yellow +} + +Write-Host "`n✓ Observability stack verification complete!" -ForegroundColor Green +``` + +### Generar Tráfico + +```powershell +# generate-traffic.ps1 + +Write-Host "Generating traffic..." -ForegroundColor Cyan + +for ($i=1; $i -le 100; $i++) { + Write-Progress -Activity "Generating load" -Status "$i/100" -PercentComplete $i + + # Health checks + Invoke-WebRequest -Uri "http://localhost:8300/order-service/actuator/health" -UseBasicParsing | Out-Null + Invoke-WebRequest -Uri "http://localhost:8500/product-service/actuator/health" -UseBasicParsing | Out-Null + Invoke-WebRequest -Uri "http://localhost:8400/payment-service/actuator/health" -UseBasicParsing | Out-Null + + # Metrics + Invoke-WebRequest -Uri "http://localhost:8300/order-service/actuator/prometheus" -UseBasicParsing | Out-Null + + Start-Sleep -Milliseconds 100 +} + +Write-Host "✓ Traffic generated - Check Grafana dashboards!" -ForegroundColor Green +``` + +--- + +## Alerting + +### Prometheus Alertmanager + +**Acceso**: `http://localhost:9093` + +**Configuración de Alertas**: + +```yaml +# prometheus/alerts.yml +groups: + - name: service_health + interval: 30s + rules: + - alert: ServiceDown + expr: up{job="spring-actuator"} == 0 + for: 1m + labels: + severity: critical + annotations: + summary: "Service {{ $labels.instance }} is down" + + - alert: HighErrorRate + expr: rate(http_server_requests_seconds_count{status=~"5.."}[5m]) > 0.1 + for: 5m + labels: + severity: warning + annotations: + summary: "High error rate on {{ $labels.application }}" + + - alert: HighMemoryUsage + expr: jvm_memory_used_bytes{area="heap"} / jvm_memory_max_bytes{area="heap"} > 0.9 + for: 10m + labels: + severity: warning + annotations: + summary: "High memory usage on {{ $labels.application }}" +``` + +--- + +## Métricas de Resumen + +### Cobertura del Stack + +- **Métricas**: Prometheus + Grafana + - 342 métricas por servicio + - 15 métricas de Resilience4j + - Dashboards configurables + +- **Logs**: ELK Stack + - Logstash processing + - Elasticsearch storage + - Kibana visualization + +- **Traces**: Jaeger + - Distributed tracing + - Service dependencies + - Performance analysis + +### Accesos Rápidos + +| Componente | URL | Credenciales | +|------------|-----|--------------| +| Grafana | http://localhost:3000 | admin / admin | +| Prometheus | http://localhost:9091 | - | +| Kibana | http://localhost:5601 | - | +| Jaeger | http://localhost:16686 | - | +| Elasticsearch | http://localhost:9200 | - | +| Alertmanager | http://localhost:9093 | - | + +--- + +## 📚 Referencias + +### Documentación +- [Prometheus Documentation](https://prometheus.io/docs/) +- [Grafana Documentation](https://grafana.com/docs/) +- [Elastic Stack](https://www.elastic.co/guide/index.html) +- [Jaeger Documentation](https://www.jaegertracing.io/docs/) +- [Spring Boot Actuator](https://docs.spring.io/spring-boot/docs/current/reference/html/actuator.html) + +### Best Practices +- [Google SRE Book](https://sre.google/sre-book/table-of-contents/) +- [The Twelve-Factor App](https://12factor.net/) +- [Microservices Observability](https://www.nginx.com/blog/microservices-reference-architecture-nginx-monitoring-logging/) + +--- + +**Fecha de última actualización**: Noviembre 30, 2025 +**Versión del documento**: 1.0 +**Proyecto**: E-Commerce Microservices Backend Application diff --git "a/docs/PATRONES_DISE\303\221O.md" "b/docs/PATRONES_DISE\303\221O.md" new file mode 100644 index 000000000..0dc28bcda --- /dev/null +++ "b/docs/PATRONES_DISE\303\221O.md" @@ -0,0 +1,501 @@ +# Patrones de Diseño +## E-Commerce Microservices Backend Application + +**Proyecto**: Ingeniería de Software V +**Fecha**: Noviembre 2025 +**Versión**: 1.0 + +--- + +## Tabla de Contenidos + +1. [Introducción](#introducción) +2. [Patrones Existentes](#patrones-existentes) +3. [Patrones Implementados](#patrones-implementados) +4. [Testing y Verificación](#testing-y-verificación) +5. [Referencias](#referencias) + +--- + +## Introducción + +Este documento describe los patrones de diseño implementados en la arquitectura de microservicios del sistema e-commerce. El objetivo es mejorar la resiliencia, configurabilidad y mantenibilidad del sistema mediante la aplicación de patrones probados en la industria. + +### Objetivos Alcanzados + +- Identificación de 10 patrones existentes +- Implementación de 4 patrones adicionales +- Documentación completa con ejemplos +- Testing y verificación funcional + +--- + +## ️ Patrones Existentes + +### 1. API Gateway Pattern + +**Ubicación**: `api-gateway/` + +**Propósito**: Punto de entrada único para todos los clientes, simplificando la comunicación y proporcionando capacidades transversales como autenticación, rate limiting y routing. + +**Beneficios**: +- Simplifica la comunicación del cliente +- Centraliza políticas de seguridad +- Facilita el versionado de APIs +- Reduce la complejidad del cliente + +**Evidencia**: +```yaml +# api-gateway/src/main/resources/application.yml +spring: + cloud: + gateway: + routes: + - id: order-service + uri: lb://ORDER-SERVICE + predicates: + - Path=/order-service/** +``` + +### 2. Service Registry Pattern (Eureka) + +**Ubicación**: `service-discovery/` + +**Propósito**: Registro y descubrimiento dinámico de servicios, permitiendo que los microservicios se encuentren entre sí sin configuración hardcoded. + +**Beneficios**: +- Descubrimiento automático de servicios +- Balanceo de carga dinámico +- Tolerancia a fallos +- Escalabilidad horizontal simplificada + +**Evidencia**: +```yaml +eureka: + client: + service-url: + defaultZone: http://service-discovery:8761/eureka/ +``` + +### 3. Externalized Configuration Pattern + +**Ubicación**: `cloud-config/` + +**Propósito**: Configuración centralizada almacenada en Git, permitiendo cambios de configuración sin redeploying. + +**Beneficios**: +- Configuración versionada +- Cambios sin redeploy +- Configuración por ambiente +- Auditoría de cambios + +### 4. Database per Service Pattern + +**Propósito**: Cada microservicio tiene su propia base de datos, garantizando independencia y desacoplamiento. + +**Beneficios**: +- Desacoplamiento de servicios +- Escalabilidad independiente +- Flexibilidad tecnológica +- Aislamiento de fallos + +### 5. Circuit Breaker Pattern + +**Ubicación**: `proxy-client/`, configurado con Resilience4j + +**Propósito**: Prevenir fallos en cascada detectando fallos y evitando llamadas a servicios no disponibles. + +**Configuración**: +```yaml +resilience4j: + circuitbreaker: + instances: + orderService: + failure-rate-threshold: 50 + minimum-number-of-calls: 5 + wait-duration-in-open-state: 5s +``` + +### 6. Distributed Tracing Pattern + +**Herramientas**: Zipkin, Jaeger, Spring Cloud Sleuth + +**Propósito**: Rastrear requests a través de múltiples servicios para debugging y análisis de latencia. + +**Beneficios**: +- Visibilidad end-to-end +- Análisis de latencia +- Debugging distribuido +- Identificación de cuellos de botella + +### 7. Health Check Pattern + +**Implementación**: Spring Boot Actuator + +**Propósito**: Exponer endpoints de salud para orquestación y monitoreo. + +**Endpoints**: +``` +GET /actuator/health +GET /actuator/health/liveness +GET /actuator/health/readiness +``` + +### 8. Aggregator Pattern + +**Ubicación**: `proxy-client/` + +**Propósito**: Agregar llamadas a múltiples servicios para reducir el chattiness del cliente. + +### 9. Repository Pattern + +**Implementación**: Spring Data JPA en todos los servicios + +**Propósito**: Abstracción del acceso a datos para facilitar testing y cambios. + +### 10. Observability Pattern + +**Herramientas**: Prometheus, Grafana, ELK Stack, Jaeger + +**Propósito**: Visibilidad completa del sistema mediante métricas, logs y traces. + +--- + +## Patrones Implementados + +### 1. Bulkhead Pattern (Resilience) + +**Ubicación**: `order-service/src/main/java/com/selimhorri/app/resilience/OrderBulkheadService.java` + +**Propósito**: Aislar recursos para diferentes tipos de operaciones, previniendo que un tipo de carga consuma todos los recursos disponibles. + +**Tipos Implementados**: + +#### Bulkhead Semafórico (Síncrono) +```java +@Bulkhead(name = "orderProcessing", + fallbackMethod = "orderProcessingFallback", + type = Type.SEMAPHORE) +public OrderDto processOrder(OrderDto orderDto) { + // Limitado a 10 llamadas concurrentes + OrderDto savedOrder = orderService.save(orderDto); + return savedOrder; +} +``` + +#### Bulkhead Thread Pool (Asíncrono) +```java +@Bulkhead(name = "orderAsyncProcessing", + fallbackMethod = "orderAsyncProcessingFallback", + type = Type.THREADPOOL) +public OrderDto processOrderAsync(OrderDto orderDto) { + // Thread pool dedicado de 5 threads + // Queue capacity: 20 + return orderService.save(orderDto); +} +``` + +**Configuración**: +```yaml +resilience4j: + bulkhead: + instances: + orderProcessing: + max-concurrent-calls: 10 + max-wait-duration: 0ms + highPriorityOrders: + max-concurrent-calls: 5 + + thread-pool-bulkhead: + instances: + orderAsyncProcessing: + max-thread-pool-size: 5 + core-thread-pool-size: 3 + queue-capacity: 20 +``` + +**Beneficios**: +- Aislamiento de recursos +- Prevención de resource starvation +- Degradación elegante con fallbacks +- Métricas granulares por operación + +**Métricas Expuestas**: +- `resilience4j.bulkhead.available.concurrent.calls` +- `resilience4j.bulkhead.max.allowed.concurrent.calls` +- `resilience4j.bulkhead.concurrent.calls` + +--- + +### 2. Feature Toggle Pattern (Configuration) + +**Ubicación**: +- `order-service/src/main/java/com/selimhorri/app/config/FeatureToggle.java` +- `order-service/src/main/java/com/selimhorri/app/config/FeatureToggleService.java` +- `order-service/src/main/java/com/selimhorri/app/resource/FeatureToggleController.java` + +**Propósito**: Habilitar/deshabilitar funcionalidades dinámicamente sin redeployment, facilitando canary releases, A/B testing y kill switches. + +**Características**: +- Activación/desactivación en runtime +- Rollout porcentual (0-100%) +- Habilitación por usuario específico +- Habilitación por ambiente (dev, stage, prod) +- API REST para gestión + +**Uso en Código**: +```java +@Autowired +private FeatureToggleService featureToggleService; + +public OrderDto processOrder(OrderDto order) { + if (featureToggleService.isEnabled("new-checkout-flow")) { + return newCheckoutFlow(order); + } else { + return legacyCheckoutFlow(order); + } +} +``` + +**Configuración**: +```yaml +features: + environment: dev + toggles: + new-checkout-flow: + enabled: true + rollout-percentage: 50 + description: "New optimized checkout flow" + enabled-environments: + - dev + - stage + + advanced-order-analytics: + enabled: true + rollout-percentage: 10 + enabled-users: + - admin@example.com +``` + +**API REST**: +```bash +# Ver todos los features +GET /actuator/features + +# Ver feature específico +GET /actuator/features/new-checkout-flow + +# Verificar si está habilitado +GET /actuator/features/new-checkout-flow/enabled + +# Activar/Desactivar +POST /actuator/features/new-checkout-flow/enable +POST /actuator/features/new-checkout-flow/disable + +# Cambiar rollout percentage +PUT /actuator/features/new-checkout-flow/rollout?percentage=75 +``` + +**Beneficios**: +- Deployment desacoplado de release +- Rollback instantáneo sin redeploy +- Testing en producción con usuarios específicos +- Canary releases y gradual rollout +- A/B testing facilitado +- Kill switches para features problemáticas + +--- + +### 3. Retry Pattern con Exponential Backoff (Resilience) + +**Ubicación**: `order-service/src/main/java/com/selimhorri/app/resilience/OrderRetryService.java` + +**Propósito**: Reintentar operaciones fallidas con delays exponencialmente crecientes para evitar sobrecarga de servicios temporalmente no disponibles. + +**Implementación**: +```java +@Retry(name = "orderProcessing", fallbackMethod = "saveOrderFallback") +public OrderDto saveOrderWithRetry(OrderDto orderDto) { + log.info("Attempting to save order: {}", orderDto.getOrderId()); + OrderDto savedOrder = orderService.save(orderDto); + return savedOrder; +} + +public OrderDto saveOrderFallback(OrderDto orderDto, Exception exception) { + log.error("All retry attempts failed: {}", exception.getMessage()); + orderDto.setOrderDesc("FAILED: " + orderDto.getOrderDesc()); + // Store in DLQ for manual processing + return orderDto; +} +``` + +**Configuración**: +```yaml +resilience4j: + retry: + instances: + orderProcessing: + max-attempts: 3 + wait-duration: 1000ms + exponential-backoff-multiplier: 2 + retry-exceptions: + - java.net.ConnectException + - java.net.SocketTimeoutException + - org.springframework.web.client.HttpServerErrorException + + externalApiCall: + max-attempts: 5 + wait-duration: 500ms + exponential-backoff-multiplier: 1.5 +``` + +**Casos de Uso**: +- Guardado de órdenes (transient DB issues) +- Llamadas a APIs externas +- Procesamiento de pagos +- Actualización de inventario + +**Beneficios**: +- Manejo automático de fallos transitorios +- Exponential backoff previene thundering herd +- Configuración granular por operación +- Fallback para degradación elegante +- Métricas de retry para observabilidad + +--- + +### 4. Resilience Event Listeners (Observability) + +**Ubicación**: `order-service/src/main/java/com/selimhorri/app/config/ResilienceEventListener.java` + +**Propósito**: Proporcionar visibilidad completa de eventos de resiliencia para debugging, monitoring y alerting. + +**Eventos Capturados**: +- Circuit Breaker: State transitions, errors, success, calls not permitted +- Bulkhead: Calls rejected, permitted, finished +- Retry: Attempts, success, errors, ignored errors + +**Implementación**: +```java +@EventListener +public void onCircuitBreakerStateTransition(CircuitBreakerOnStateTransitionEvent event) { + log.warn("Circuit Breaker '{}' transitioned from {} to {}", + event.getCircuitBreakerName(), + event.getStateTransition().getFromState(), + event.getStateTransition().getToState()); + // Send alert, update dashboard, trigger auto-scaling +} + +@EventListener +public void onBulkheadCallRejected(BulkheadOnCallRejectedEvent event) { + log.warn("Bulkhead '{}' rejected call - max concurrent calls reached", + event.getBulkheadName()); + // Alert operations team, consider scaling +} +``` + +**Beneficios**: +- Logging estructurado de eventos +- Base para sistema de alertas +- Métricas custom por evento +- Debugging facilitado + +--- + +## Testing y Verificación + +### Endpoints de Verificación + +```bash +# Health Check +curl http://localhost:8300/order-service/actuator/health | jq + +# Feature Toggles +curl http://localhost:8300/order-service/actuator/features | jq + +# Métricas Resilience4j +curl http://localhost:8300/order-service/actuator/metrics | jq '.names | map(select(contains("resilience4j")))' + +# Métricas específicas +curl http://localhost:8300/order-service/actuator/metrics/resilience4j.bulkhead.concurrent.calls | jq +curl http://localhost:8300/order-service/actuator/metrics/resilience4j.retry.calls | jq +curl http://localhost:8300/order-service/actuator/metrics/resilience4j.circuitbreaker.state | jq +``` + +### Testing de Feature Toggles + +```bash +# Ver feature específico +curl http://localhost:8300/order-service/actuator/features/new-checkout-flow | jq + +# Activar/Desactivar +curl -X POST http://localhost:8300/order-service/actuator/features/new-checkout-flow/disable | jq +curl -X POST http://localhost:8300/order-service/actuator/features/new-checkout-flow/enable | jq + +# Canary Release (gradual rollout) +curl -X PUT "http://localhost:8300/order-service/actuator/features/new-checkout-flow/rollout?percentage=25" | jq +curl -X PUT "http://localhost:8300/order-service/actuator/features/new-checkout-flow/rollout?percentage=50" | jq +curl -X PUT "http://localhost:8300/order-service/actuator/features/new-checkout-flow/rollout?percentage=100" | jq +``` + +### Métricas Exportadas + +**Total**: 15 métricas de Resilience4j + +**Bulkhead** (7 métricas): +- `resilience4j.bulkhead.available.concurrent.calls` +- `resilience4j.bulkhead.max.allowed.concurrent.calls` +- `resilience4j.bulkhead.concurrent.calls` +- `resilience4j.bulkhead.thread.pool.size` +- `resilience4j.bulkhead.queue.depth` +- `resilience4j.bulkhead.core.thread.pool.size` +- `resilience4j.bulkhead.max.thread.pool.size` + +**Circuit Breaker** (7 métricas): +- `resilience4j.circuitbreaker.state` +- `resilience4j.circuitbreaker.failure.rate` +- `resilience4j.circuitbreaker.buffered.calls` +- `resilience4j.circuitbreaker.calls` +- `resilience4j.circuitbreaker.not.permitted.calls` +- `resilience4j.circuitbreaker.slow.call.rate` +- `resilience4j.circuitbreaker.slow.calls` + +**Retry** (1 métrica): +- `resilience4j.retry.calls` + +--- + +## Resumen de Cumplimiento + +| Requisito | Estado | Detalles | +|-----------|--------|----------| +| Identificar patrones existentes | 100% | 10 patrones documentados | +| Implementar patrón de resiliencia | 100% | Bulkhead + Retry (2 patrones) | +| Implementar patrón de configuración | 100% | Feature Toggle | +| Documentar propósito y beneficios | 100% | Documentación completa | +| Código funcional | 100% | Verificado y testeado | +| Observabilidad | 100% | 15 métricas + eventos | + +--- + +## Referencias + +### Documentación Técnica +- [Resilience4j Documentation](https://resilience4j.readme.io/) +- [Spring Cloud Documentation](https://spring.io/projects/spring-cloud) +- [Microservices Patterns - Chris Richardson](https://microservices.io/patterns/) + +### Libros +- "Release It!" - Michael Nygard +- "Building Microservices" - Sam Newman +- "Microservices Patterns" - Chris Richardson + +### Artículos +- [Martin Fowler - Feature Toggles](https://martinfowler.com/articles/feature-toggles.html) +- [Netflix - Hystrix](https://github.com/Netflix/Hystrix/wiki) + +--- + +**Fecha de última actualización**: Noviembre 30, 2025 +**Versión del documento**: 1.0 +**Proyecto**: E-Commerce Microservices Backend Application diff --git a/docs/PipelineLogic.md b/docs/PipelineLogic.md new file mode 100644 index 000000000..7b31da7ea --- /dev/null +++ b/docs/PipelineLogic.md @@ -0,0 +1,21 @@ +#get images from docker hub named juanse201/{SERVICE_NAME} + +#after getting them gotta run them dont know if these commands are correct +minkube start +minikube image load {docker image name} +kubectl apply -f K8/{SERVICE_NAME}/{SERVICE_NAME}.yaml +minikube service {SERVICE_NAME} +#problem is that dont know in the case if theres already a image how to get multiple pods with diferent images or how to run them at the same time cause minikube service command think only runs one + + +#globaltests dir you can do cd globaltests or immedietly place it there + +#run order e2e test after services have been up +mvn test -Dtest=OrderServiceE2ETest + +#If it passes then find docker image in dockerhub and duplicate it with a extra name or tag like :stage and push it to dockerhub it should be seperate. + + + +docker build -t juanse201/order-service:dev -f order-service/Dockerfile . +docker build -t juanse201/api-gateway:dev -f api-gateway/Dockerfile . \ No newline at end of file diff --git a/docs/QUICKSTART-AZURE.md b/docs/QUICKSTART-AZURE.md new file mode 100644 index 000000000..72699e363 --- /dev/null +++ b/docs/QUICKSTART-AZURE.md @@ -0,0 +1,265 @@ +# 🚀 Inicio Rápido - Despliegue en Azure VM + +Esta es la guía de inicio rápido para desplegar el proyecto de microservicios de e-commerce en una VM de Azure. + +--- + +## ⚡ Resumen de 5 Minutos + +### 1️⃣ Crear VM en Azure Portal + +``` +Nombre: vm-ecommerce-app +Imagen: Ubuntu 22.04 LTS +Tamaño: Standard_D8s_v3 (8 vCPUs, 32GB RAM) +Disco: 200GB SSD Premium +SSH: Generar nuevo par de claves +``` + +**Puertos a abrir en NSG**: 22, 80, 443, 3000, 5000, 5601, 8080, 8300-8900, 9090, 9093, 9200, 9296, 9411, 8761, 16686 + +### 2️⃣ Conectar a la VM + +```bash +chmod 400 ~/.ssh/vm-ecommerce-key.pem +ssh -i ~/.ssh/vm-ecommerce-key.pem azureuser@ +``` + +### 3️⃣ Configurar VM (una sola vez) + +```bash +# Clonar repositorio +git clone https://github.com/JuanseDev2001/ecommerce-microservice-backend-app.git ~/projects/ecommerce-microservice-backend-app +cd ~/projects/ecommerce-microservice-backend-app + +# Ejecutar configuración automática +chmod +x scripts/azure-vm-setup.sh +./scripts/azure-vm-setup.sh + +# IMPORTANTE: Cerrar sesión y volver a conectar +exit +ssh -i ~/.ssh/vm-ecommerce-key.pem azureuser@ +``` + +### 4️⃣ Desplegar Aplicación + +```bash +cd ~/projects/ecommerce-microservice-backend-app +./scripts/deploy-gradual.sh +``` + +**Espera**: 10-15 minutos mientras se despliegan todos los servicios. + +### 5️⃣ Verificar + +```bash +./scripts/check-services.sh +``` + +### 6️⃣ Acceder desde el Navegador + +Reemplaza `` con la IP de tu VM: + +- **Eureka**: http://\:8761 +- **API Gateway**: http://\:8080/actuator/health +- **Grafana**: http://\:3000 (admin/admin) +- **Prometheus**: http://\:9090 +- **Jaeger**: http://\:16686 +- **Kibana**: http://\:5601 + +--- + +## 📋 Comandos Esenciales + +### Gestión de Servicios + +```bash +# Ver estado de todos los servicios +docker ps + +# Ver logs de todos los servicios +docker-compose -f compose.yml logs -f + +# Ver logs de un servicio específico +docker-compose -f compose.yml logs -f + +# Reiniciar un servicio +docker-compose -f compose.yml restart + +# Reiniciar todos los servicios +docker-compose -f compose.yml restart + +# Detener todos los servicios +docker-compose -f compose.yml down + +# Iniciar todos los servicios +docker-compose -f compose.yml up -d +``` + +### Monitoreo + +```bash +# Verificar estado de servicios +./scripts/check-services.sh + +# Ver uso de recursos +docker stats + +# Ver memoria del sistema +free -h + +# Ver uso de disco +df -h +``` + +### Mantenimiento + +```bash +# Hacer backup +./scripts/backup.sh + +# Limpiar recursos de Docker +docker system prune -a + +# Ver logs del sistema +sudo journalctl -xe +``` + +--- + +## 🎯 Servicios y Puertos + +| Servicio | Puerto | URL | +|----------|--------|-----| +| **Infraestructura** | +| Eureka | 8761 | http://\:8761 | +| Config Server | 9296 | http://\:9296 | +| API Gateway | 8080 | http://\:8080 | +| Zipkin | 9411 | http://\:9411 | +| **Microservicios** | +| Proxy Client | 8900 | http://\:8900/swagger-ui.html | +| User Service | 8700 | http://\:8700/swagger-ui.html | +| Product Service | 8500 | http://\:8500/swagger-ui.html | +| Order Service | 8300 | http://\:8300/swagger-ui.html | +| Payment Service | 8400 | http://\:8400/swagger-ui.html | +| Shipping Service | 8600 | http://\:8600/swagger-ui.html | +| Favourite Service | 8800 | http://\:8800/swagger-ui.html | +| **Observabilidad** | +| Prometheus | 9090 | http://\:9090 | +| Grafana | 3000 | http://\:3000 | +| Alertmanager | 9093 | http://\:9093 | +| Jaeger | 16686 | http://\:16686 | +| Elasticsearch | 9200 | http://\:9200 | +| Logstash | 5000 | - | +| Kibana | 5601 | http://\:5601 | + +--- + +## 🔧 Solución Rápida de Problemas + +### Servicio no responde + +```bash +# 1. Ver si el contenedor está corriendo +docker ps | grep + +# 2. Ver logs del servicio +docker-compose -f compose.yml logs + +# 3. Reiniciar el servicio +docker-compose -f compose.yml restart +``` + +### Elasticsearch no inicia + +```bash +sudo sysctl -w vm.max_map_count=262144 +docker-compose -f compose.yml restart elasticsearch +``` + +### Memoria insuficiente + +```bash +# Limpiar recursos +docker system prune -a + +# Reiniciar servicios pesados +docker-compose -f compose.yml restart elasticsearch logstash kibana +``` + +### No puedo acceder desde el navegador + +1. Verifica NSG en Azure Portal: VM → Networking → Inbound port rules +2. Verifica firewall: `sudo ufw status` +3. Verifica que el servicio esté corriendo: `docker ps` + +--- + +## 📚 Documentación Completa + +- **Guía Detallada**: `docs/azure-vm-deployment-guide.md` +- **Checklist Completo**: `docs/azure-deployment-checklist.md` +- **Scripts**: `scripts/README.md` + +--- + +## 🆘 Ayuda Rápida + +```bash +# Ver todos los contenedores +docker ps -a + +# Ver servicios en Eureka +curl http://localhost:8761/eureka/apps | grep "" + +# Verificar salud del API Gateway +curl http://localhost:8080/actuator/health + +# Ver uso de recursos en tiempo real +htop + +# Ver conexiones de red +sudo netstat -tulpn +``` + +--- + +## 💡 Tips + +1. **Primera vez**: Usa el script `deploy-gradual.sh` para un despliegue ordenado +2. **Monitoreo**: Configura Grafana con los dashboards recomendados +3. **Backups**: Configura el cron job para backups automáticos +4. **Seguridad**: Cambia la contraseña de Grafana en el primer login +5. **Logs**: Usa `docker-compose logs -f` para debugging en tiempo real + +--- + +## ✅ Verificación Rápida + +Ejecuta estos comandos para verificar que todo funciona: + +```bash +# 1. Todos los contenedores corriendo +docker ps | wc -l +# Debe mostrar ~19 (18 contenedores + header) + +# 2. Servicios en Eureka +curl -s http://localhost:8761/eureka/apps | grep -o "[^<]*" | wc -l +# Debe mostrar 9 + +# 3. API Gateway saludable +curl -s http://localhost:8080/actuator/health | jq .status +# Debe mostrar "UP" + +# 4. Prometheus recolectando métricas +curl -s http://localhost:9090/-/healthy +# Debe mostrar "Prometheus is Healthy." +``` + +--- + +**¿Necesitas más ayuda?** Consulta la guía completa en `docs/azure-vm-deployment-guide.md` + +--- + +**Última actualización**: 2025-11-30 diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..8c3ed58d3 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,76 @@ +# Documentación del Proyecto +## E-Commerce Microservices Backend Application + +**Proyecto**: Ingeniería de Software V +**Versión**: 1.0 +**Fecha**: Noviembre 2025 + +--- + +## Estructura de Documentos + +Este directorio contiene la documentación técnica del proyecto organizada por tema: + +### Documentos Principales + +1. **PATRONES_DISEÑO.md** + - Patrones existentes identificados en la arquitectura + - Patrones implementados (Bulkhead, Retry, Feature Toggles) + - Testing y verificación + - Configuración y ejemplos de código + +2. **OBSERVABILIDAD.md** + - Stack completo de observabilidad + - Configuración de Prometheus, Grafana, ELK, Jaeger + - Métricas, logs y traces + - Dashboards y queries útiles + +### Documentos de Infraestructura + +3. **Jenkins.md** + - Configuración de CI/CD + - Pipelines y automatización + +4. **DockerImages.md** + - Imágenes Docker utilizadas + - Versionamiento + +5. **PipelineLogic.md** + - Lógica de los pipelines de deployment + +--- + +## Guía de Lectura + +### Para entender los Patrones de Diseño: +1. Leer `PATRONES_DISEÑO.md` +2. Revisar el código en `order-service/src/main/java/com/selimhorri/app/resilience/` +3. Probar los endpoints de actuator + +### Para configurar Observabilidad: +1. Leer `OBSERVABILIDAD.md` +2. Ejecutar `docker compose up -d` +3. Acceder a Grafana, Prometheus, Kibana y Jaeger +4. Configurar dashboards según los ejemplos + +--- + +## Enlaces Rápidos + +**Código Fuente**: +- Bulkhead: `order-service/src/main/java/com/selimhorri/app/resilience/OrderBulkheadService.java` +- Retry: `order-service/src/main/java/com/selimhorri/app/resilience/OrderRetryService.java` +- Feature Toggles: `order-service/src/main/java/com/selimhorri/app/config/FeatureToggleService.java` + +**Configuración**: +- Application: `order-service/src/main/resources/application.yml` +- Docker: `compose.yml` +- Prometheus: `prometheus/prometheus.yml` + +--- + +## Contacto + +Para preguntas sobre la documentación, contactar al equipo de desarrollo. + +**Última actualización**: Noviembre 30, 2025 diff --git a/docs/azure-architecture.md b/docs/azure-architecture.md new file mode 100644 index 000000000..815f9347b --- /dev/null +++ b/docs/azure-architecture.md @@ -0,0 +1,398 @@ +# 🏗️ Arquitectura de Despliegue en Azure + +## 📊 Diagrama de Componentes + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ AZURE CLOUD │ +│ ┌───────────────────────────────────────────────────────────────────┐ │ +│ │ Resource Group: rg-ecommerce-microservices │ │ +│ │ │ │ +│ │ ┌──────────────────────────────────────────────────────────────┐ │ │ +│ │ │ Virtual Network: vnet-ecommerce │ │ │ +│ │ │ │ │ │ +│ │ │ ┌────────────────────────────────────────────────────────┐ │ │ │ +│ │ │ │ VM: vm-ecommerce-app │ │ │ │ +│ │ │ │ • Ubuntu 22.04 LTS │ │ │ │ +│ │ │ │ • 8 vCPUs, 32GB RAM │ │ │ │ +│ │ │ │ • 200GB SSD Premium │ │ │ │ +│ │ │ │ │ │ │ │ +│ │ │ │ ┌────────────────────────────────────────────────┐ │ │ │ │ +│ │ │ │ │ DOCKER CONTAINERS │ │ │ │ │ +│ │ │ │ │ │ │ │ │ │ +│ │ │ │ │ ┌─────────────────────────────────────────┐ │ │ │ │ │ +│ │ │ │ │ │ INFRASTRUCTURE LAYER │ │ │ │ │ │ +│ │ │ │ │ ├─────────────────────────────────────────┤ │ │ │ │ │ +│ │ │ │ │ │ • Eureka (8761) │ │ │ │ │ │ +│ │ │ │ │ │ • Config Server (9296) │ │ │ │ │ │ +│ │ │ │ │ │ • API Gateway (8080) │ │ │ │ │ │ +│ │ │ │ │ │ • Zipkin (9411) │ │ │ │ │ │ +│ │ │ │ │ └─────────────────────────────────────────┘ │ │ │ │ │ +│ │ │ │ │ ↓ │ │ │ │ │ +│ │ │ │ │ ┌─────────────────────────────────────────┐ │ │ │ │ │ +│ │ │ │ │ │ MICROSERVICES LAYER │ │ │ │ │ │ +│ │ │ │ │ ├─────────────────────────────────────────┤ │ │ │ │ │ +│ │ │ │ │ │ • Proxy Client (8900) │ │ │ │ │ │ +│ │ │ │ │ │ • User Service (8700) │ │ │ │ │ │ +│ │ │ │ │ │ • Product Service (8500) │ │ │ │ │ │ +│ │ │ │ │ │ • Order Service (8300) │ │ │ │ │ │ +│ │ │ │ │ │ • Payment Service (8400) │ │ │ │ │ │ +│ │ │ │ │ │ • Shipping Service (8600) │ │ │ │ │ │ +│ │ │ │ │ │ • Favourite Service (8800) │ │ │ │ │ │ +│ │ │ │ │ └─────────────────────────────────────────┘ │ │ │ │ │ +│ │ │ │ │ ↓ │ │ │ │ │ +│ │ │ │ │ ┌─────────────────────────────────────────┐ │ │ │ │ │ +│ │ │ │ │ │ OBSERVABILITY LAYER │ │ │ │ │ │ +│ │ │ │ │ ├─────────────────────────────────────────┤ │ │ │ │ │ +│ │ │ │ │ │ Metrics: │ │ │ │ │ │ +│ │ │ │ │ │ • Prometheus (9090) │ │ │ │ │ │ +│ │ │ │ │ │ • Grafana (3000) │ │ │ │ │ │ +│ │ │ │ │ │ • Alertmanager (9093) │ │ │ │ │ │ +│ │ │ │ │ │ │ │ │ │ │ │ +│ │ │ │ │ │ Logging: │ │ │ │ │ │ +│ │ │ │ │ │ • Elasticsearch (9200) │ │ │ │ │ │ +│ │ │ │ │ │ • Logstash (5000) │ │ │ │ │ │ +│ │ │ │ │ │ • Kibana (5601) │ │ │ │ │ │ +│ │ │ │ │ │ │ │ │ │ │ │ +│ │ │ │ │ │ Tracing: │ │ │ │ │ │ +│ │ │ │ │ │ • Jaeger (16686) │ │ │ │ │ │ +│ │ │ │ │ └─────────────────────────────────────────┘ │ │ │ │ │ +│ │ │ │ │ │ │ │ │ │ +│ │ │ │ │ ┌─────────────────────────────────────────┐ │ │ │ │ │ +│ │ │ │ │ │ PERSISTENT STORAGE │ │ │ │ │ │ +│ │ │ │ │ ├─────────────────────────────────────────┤ │ │ │ │ │ +│ │ │ │ │ │ • jenkins_home │ │ │ │ │ │ +│ │ │ │ │ │ • grafana-storage │ │ │ │ │ │ +│ │ │ │ │ │ • elasticsearch-data │ │ │ │ │ │ +│ │ │ │ │ └─────────────────────────────────────────┘ │ │ │ │ │ +│ │ │ │ └────────────────────────────────────────────────┘ │ │ │ │ +│ │ │ └────────────────────────────────────────────────────────┘ │ │ │ +│ │ │ │ │ │ +│ │ │ Network Security Group: nsg-ecommerce-vm │ │ │ +│ │ │ • Inbound Rules: 22, 80, 443, 3000, 5000, 5601, 8080, │ │ │ +│ │ │ 8300-8900, 9090, 9093, 9200, 9296, 9411, 8761, 16686 │ │ │ +│ │ └──────────────────────────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ Public IP: pip-ecommerce-vm │ │ +│ └───────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ + ↑ + │ + INTERNET ACCESS + │ + ┌───────────────┴───────────────┐ + │ │ + ┌─────┴─────┐ ┌──────┴──────┐ + │ Browser │ │ SSH Client │ + │ (HTTP) │ │ (Port 22) │ + └───────────┘ └─────────────┘ +``` + +## 🔄 Flujo de Datos + +``` +┌──────────┐ +│ Client │ +└────┬─────┘ + │ HTTP Request + ↓ +┌────────────────────┐ +│ Azure Public IP │ +│ (pip-ecommerce) │ +└────────┬───────────┘ + │ + ↓ +┌────────────────────┐ +│ NSG │ +│ (Port Filtering) │ +└────────┬───────────┘ + │ + ↓ +┌────────────────────┐ +│ API Gateway │ +│ (Port 8080) │ +└────────┬───────────┘ + │ + ↓ +┌────────────────────┐ +│ Eureka │ +│ (Service Lookup) │ +└────────┬───────────┘ + │ + ↓ +┌────────────────────────────────────┐ +│ Microservices │ +│ (User, Product, Order, etc.) │ +└────────┬───────────────────────────┘ + │ + ├─────────────────┐ + │ │ + ↓ ↓ +┌────────────────┐ ┌──────────────┐ +│ Prometheus │ │ Zipkin │ +│ (Metrics) │ │ (Traces) │ +└────────────────┘ └──────────────┘ + │ │ + ↓ ↓ +┌────────────────┐ ┌──────────────┐ +│ Grafana │ │ Jaeger │ +│ (Visualization)│ │ (Tracing UI) │ +└────────────────┘ └──────────────┘ +``` + +## 📦 Distribución de Recursos + +### CPU Allocation (Estimado) + +``` +Service Discovery: 0.5 vCPU +Config Server: 0.5 vCPU +API Gateway: 1.0 vCPU +Microservices (7x): 3.5 vCPU (0.5 cada uno) +Prometheus: 0.5 vCPU +Grafana: 0.5 vCPU +Elasticsearch: 1.5 vCPU +Logstash: 1.0 vCPU +Kibana: 0.5 vCPU +Jaeger: 0.5 vCPU +───────────────────────────────── +Total: ~10 vCPU +``` + +### Memory Allocation (Estimado) + +``` +Service Discovery: 512 MB +Config Server: 512 MB +API Gateway: 1 GB +Microservices (7x): 7 GB (1 GB cada uno) +Prometheus: 2 GB +Grafana: 512 MB +Elasticsearch: 4 GB +Logstash: 2 GB +Kibana: 1 GB +Jaeger: 1 GB +System: 2 GB +───────────────────────────────── +Total: ~22 GB +``` + +### Disk Usage (Estimado) + +``` +Docker Images: ~15 GB +Elasticsearch Data: ~20 GB +Logs: ~5 GB +Grafana Data: ~1 GB +Prometheus Data: ~5 GB +System: ~10 GB +Free Space: ~144 GB +───────────────────────────────── +Total Disk (200 GB): ~200 GB +``` + +## 🌐 Network Topology + +``` +Internet + │ + ↓ +┌───────────────────────────────────────┐ +│ Azure Network Security Group (NSG) │ +│ ┌─────────────────────────────────┐ │ +│ │ Inbound Rules │ │ +│ │ • SSH (22) │ │ +│ │ • HTTP (80) │ │ +│ │ • HTTPS (443) │ │ +│ │ • Microservices (8000-9000) │ │ +│ │ • Monitoring (3000, 5601, etc) │ │ +│ └─────────────────────────────────┘ │ +└───────────────┬───────────────────────┘ + │ + ↓ +┌───────────────────────────────────────┐ +│ Virtual Network (10.0.0.0/16) │ +│ ┌─────────────────────────────────┐ │ +│ │ Subnet (10.0.0.0/24) │ │ +│ │ ┌───────────────────────────┐ │ │ +│ │ │ VM: 10.0.0.4 │ │ │ +│ │ │ ┌─────────────────────┐ │ │ │ +│ │ │ │ Docker Network │ │ │ │ +│ │ │ │ (172.18.0.0/16) │ │ │ │ +│ │ │ │ │ │ │ │ +│ │ │ │ All containers │ │ │ │ +│ │ │ │ communicate here │ │ │ │ +│ │ │ └─────────────────────┘ │ │ │ +│ │ └───────────────────────────┘ │ │ +│ └─────────────────────────────────┘ │ +└───────────────────────────────────────┘ +``` + +## 🔐 Security Layers + +``` +┌─────────────────────────────────────────┐ +│ Layer 1: Azure Network Security Group │ +│ • Port-based filtering │ +│ • Source IP restrictions (optional) │ +└─────────────┬───────────────────────────┘ + │ + ↓ +┌─────────────────────────────────────────┐ +│ Layer 2: VM Firewall (UFW) │ +│ • Additional port filtering │ +│ • Rate limiting (optional) │ +└─────────────┬───────────────────────────┘ + │ + ↓ +┌─────────────────────────────────────────┐ +│ Layer 3: Docker Network Isolation │ +│ • Internal network for containers │ +│ • Only exposed ports accessible │ +└─────────────┬───────────────────────────┘ + │ + ↓ +┌─────────────────────────────────────────┐ +│ Layer 4: Application Security │ +│ • Spring Security │ +│ • OAuth2/JWT (if configured) │ +│ • Service-to-service authentication │ +└─────────────────────────────────────────┘ +``` + +## 📊 Monitoring Stack + +``` +┌──────────────────────────────────────────────────────────┐ +│ OBSERVABILITY STACK │ +├──────────────────────────────────────────────────────────┤ +│ │ +│ ┌────────────────┐ ┌────────────────┐ ┌────────────┐ │ +│ │ METRICS │ │ LOGGING │ │ TRACING │ │ +│ ├────────────────┤ ├────────────────┤ ├────────────┤ │ +│ │ │ │ │ │ │ │ +│ │ Prometheus ←──┼──┤ Logstash ←──┼──┤ Zipkin │ │ +│ │ ↓ │ │ ↓ │ │ ↓ │ │ +│ │ Grafana │ │ Elasticsearch │ │ Jaeger │ │ +│ │ │ │ ↓ │ │ │ │ +│ │ Alertmanager │ │ Kibana │ │ │ │ +│ │ │ │ │ │ │ │ +│ └────────────────┘ └────────────────┘ └────────────┘ │ +│ ↑ ↑ ↑ │ +│ └──────────────────┴──────────────────┘ │ +│ │ │ +│ All Microservices │ +│ │ │ +└──────────────────────────────┴───────────────────────────┘ +``` + +## 🔄 Deployment Flow + +``` +┌─────────────┐ +│ Developer │ +│ (Local PC) │ +└──────┬──────┘ + │ 1. SSH Connection + ↓ +┌─────────────────────────────┐ +│ Azure VM │ +│ ┌───────────────────────┐ │ +│ │ 2. Git Clone/Pull │ │ +│ └───────────┬───────────┘ │ +│ ↓ │ +│ ┌───────────────────────┐ │ +│ │ 3. Docker Compose │ │ +│ │ Pull Images │ │ +│ └───────────┬───────────┘ │ +│ ↓ │ +│ ┌───────────────────────┐ │ +│ │ 4. Start Containers │ │ +│ │ (deploy-gradual.sh)│ │ +│ └───────────┬───────────┘ │ +│ ↓ │ +│ ┌───────────────────────┐ │ +│ │ 5. Health Checks │ │ +│ │ (check-services.sh)│ │ +│ └───────────┬───────────┘ │ +│ ↓ │ +│ ┌───────────────────────┐ │ +│ │ 6. Services Running │ │ +│ └───────────────────────┘ │ +└─────────────────────────────┘ + │ + ↓ +┌─────────────┐ +│ Users │ +│ (Internet) │ +└─────────────┘ +``` + +## 💾 Backup Strategy + +``` +┌──────────────────────────────────────┐ +│ BACKUP SCHEDULE │ +├──────────────────────────────────────┤ +│ │ +│ Daily (2:00 AM): │ +│ ┌────────────────────────────────┐ │ +│ │ • Docker Volumes │ │ +│ │ - jenkins_home │ │ +│ │ - grafana-storage │ │ +│ │ - elasticsearch-data │ │ +│ │ │ │ +│ │ • Configuration Files │ │ +│ │ - compose.yml │ │ +│ │ - prometheus/ │ │ +│ │ - elk/ │ │ +│ │ - .env │ │ +│ │ │ │ +│ │ • Custom Scripts │ │ +│ │ - scripts/ │ │ +│ └────────────────────────────────┘ │ +│ │ +│ Retention: 7 days │ +│ Location: ~/backups/ │ +│ │ +└──────────────────────────────────────┘ +``` + +## 🚀 Scaling Options + +### Vertical Scaling (Current) +``` +Single VM with all services +✓ Simple management +✓ Lower cost +✗ Single point of failure +✗ Limited scalability +``` + +### Horizontal Scaling (Future) +``` +Option 1: Multiple VMs + Load Balancer +┌─────────────┐ +│ Load Balancer│ +└──────┬───────┘ + ┌───┴───┬───────┬───────┐ + │ │ │ │ +┌──▼──┐ ┌──▼──┐ ┌──▼──┐ ┌──▼──┐ +│ VM1 │ │ VM2 │ │ VM3 │ │ VM4 │ +└─────┘ └─────┘ └─────┘ └─────┘ + +Option 2: Azure Kubernetes Service (AKS) +┌─────────────────────────────┐ +│ AKS Cluster │ +│ ┌───────────────────────┐ │ +│ │ Multiple Pods │ │ +│ │ Auto-scaling │ │ +│ │ Self-healing │ │ +│ │ Load balancing │ │ +│ └───────────────────────┘ │ +└─────────────────────────────┘ +``` + +--- + +**Última actualización**: 2025-11-30 diff --git a/docs/azure-deployment-checklist.md b/docs/azure-deployment-checklist.md new file mode 100644 index 000000000..ac4aff2dc --- /dev/null +++ b/docs/azure-deployment-checklist.md @@ -0,0 +1,439 @@ +# ✅ Checklist de Despliegue en Azure VM + +## 📋 Preparación (Antes de crear la VM) + +- [ ] Cuenta de Azure activa +- [ ] Acceso al Portal de Azure +- [ ] Cliente SSH instalado (Linux/Mac/Windows Terminal) +- [ ] Conocer la IP pública que usarás para acceder + +--- + +## 🖥️ Creación de la VM en Azure + +### Configuración Básica +- [ ] Crear recurso → Máquina Virtual +- [ ] Nombre: `vm-ecommerce-app` +- [ ] Región: Seleccionar la más cercana +- [ ] Imagen: `Ubuntu Server 22.04 LTS - x64 Gen2` +- [ ] Tamaño: Mínimo `Standard_D4s_v3` (4 vCPUs, 16GB RAM) +- [ ] Recomendado: `Standard_D8s_v3` (8 vCPUs, 32GB RAM) + +### Autenticación +- [ ] Tipo: `Clave pública SSH` +- [ ] Usuario: `azureuser` +- [ ] Generar nuevo par de claves: `vm-ecommerce-key` +- [ ] **IMPORTANTE**: Descargar y guardar el archivo `.pem` + +### Discos +- [ ] Tipo: `SSD Premium` +- [ ] Tamaño: Mínimo 100GB, recomendado 200GB + +### Redes +- [ ] Red virtual: Crear nueva `vnet-ecommerce` +- [ ] IP pública: Crear nueva `pip-ecommerce-vm` +- [ ] NSG: Crear nuevo `nsg-ecommerce-vm` + +### Puertos Iniciales +- [ ] SSH (22) +- [ ] HTTP (80) +- [ ] HTTPS (443) + +### Finalizar +- [ ] Revisar configuración +- [ ] Crear VM +- [ ] Esperar 5-10 minutos +- [ ] Copiar IP pública de la VM + +--- + +## 🔐 Configuración de Seguridad (NSG) + +### Agregar Reglas de Puerto en Azure Portal + +**Opción 1: Regla consolidada (más simple)** +- [ ] Ir a NSG → Reglas de entrada → Agregar +- [ ] Puertos: `3000,5000,5601,8080,8300-8900,9090,9093,9200,9296,9411,8761,16686` +- [ ] Protocolo: TCP +- [ ] Acción: Permitir +- [ ] Nombre: `Allow-Microservices-All` + +**Opción 2: Reglas individuales (más seguro)** +- [ ] Eureka: 8761 +- [ ] Config Server: 9296 +- [ ] API Gateway: 8080 +- [ ] Proxy Client: 8900 +- [ ] Order Service: 8300 +- [ ] Payment Service: 8400 +- [ ] Product Service: 8500 +- [ ] Shipping Service: 8600 +- [ ] User Service: 8700 +- [ ] Favourite Service: 8800 +- [ ] Prometheus: 9090 +- [ ] Grafana: 3000 +- [ ] Kibana: 5601 +- [ ] Jaeger: 16686 +- [ ] Elasticsearch: 9200 +- [ ] Logstash: 5000 +- [ ] Alertmanager: 9093 +- [ ] Zipkin: 9411 + +--- + +## 🔌 Conexión Inicial a la VM + +### Linux/Mac +```bash +# Configurar permisos de la clave +chmod 400 ~/.ssh/vm-ecommerce-key.pem + +# Conectar por SSH +ssh -i ~/.ssh/vm-ecommerce-key.pem azureuser@ +``` + +### Windows +- [ ] Usar PuTTY, Windows Terminal con WSL, o Azure Cloud Shell +- [ ] Convertir `.pem` a `.ppk` si usas PuTTY + +### Verificación +- [ ] Conexión SSH exitosa +- [ ] Prompt muestra: `azureuser@vm-ecommerce-app:~$` + +--- + +## ⚙️ Configuración Automática de la VM + +### Opción A: Script Automático (Recomendado) + +```bash +# Descargar el script de configuración +cd ~ +git clone https://github.com/JuanseDev2001/ecommerce-microservice-backend-app.git ~/projects/ecommerce-microservice-backend-app + +# O si ya tienes el proyecto localmente, súbelo con scp: +# scp -i ~/.ssh/vm-ecommerce-key.pem -r ./ecommerce-microservice-backend-app azureuser@:~/projects/ + +# Ejecutar script de configuración +cd ~/projects/ecommerce-microservice-backend-app +chmod +x scripts/azure-vm-setup.sh +./scripts/azure-vm-setup.sh +``` + +**Checklist del script**: +- [ ] Sistema actualizado +- [ ] Docker instalado +- [ ] Docker Compose instalado +- [ ] Java 11 instalado +- [ ] Maven instalado +- [ ] Swap configurado (8GB) +- [ ] Firewall (UFW) configurado +- [ ] Límites de sistema ajustados +- [ ] Directorios de datos creados + +**IMPORTANTE**: +- [ ] Cerrar sesión SSH: `exit` +- [ ] Volver a conectar por SSH + +--- + +### Opción B: Configuración Manual + +Si prefieres hacerlo paso a paso, sigue la guía completa en: +`docs/azure-vm-deployment-guide.md` + +--- + +## 🚀 Despliegue de la Aplicación + +### Preparación +```bash +cd ~/projects/ecommerce-microservice-backend-app +``` + +### Verificar archivos +- [ ] `compose.yml` existe +- [ ] Directorio `prometheus/` existe +- [ ] Directorio `elk/` existe +- [ ] Scripts en `scripts/` tienen permisos de ejecución + +### Despliegue Gradual (Recomendado) +```bash +./scripts/deploy-gradual.sh +``` + +**El script hará**: +- [ ] Fase 1: Infraestructura (Zipkin, Eureka, Config Server) +- [ ] Fase 2: Gateway (API Gateway, Proxy Client) +- [ ] Fase 3: Microservicios (6 servicios de negocio) +- [ ] Fase 4: Observabilidad (Prometheus, Grafana, ELK, Jaeger) + +**Tiempo estimado**: 10-15 minutos + +### Despliegue Rápido (Alternativa) +```bash +docker-compose -f compose.yml up -d +``` + +--- + +## ✅ Verificación del Despliegue + +### Ejecutar script de verificación +```bash +./scripts/check-services.sh +``` + +**Debe mostrar**: +- [ ] Todos los contenedores corriendo +- [ ] Todos los servicios HTTP respondiendo +- [ ] Servicios registrados en Eureka +- [ ] Uso de recursos aceptable + +### Verificación manual + +**Contenedores Docker**: +```bash +docker ps +``` +- [ ] 18+ contenedores corriendo + +**Servicios en Eureka**: +```bash +curl http://localhost:8761 +``` +- [ ] Eureka UI accesible +- [ ] 9 servicios registrados + +**API Gateway**: +```bash +curl http://localhost:8080/actuator/health +``` +- [ ] Respuesta: `{"status":"UP"}` + +--- + +## 🌐 Acceso desde el Navegador + +Reemplaza `` con la IP de tu VM. + +### Infraestructura +- [ ] Eureka: `http://:8761` +- [ ] Config Server: `http://:9296/actuator/health` +- [ ] API Gateway: `http://:8080/actuator/health` + +### Microservicios (Swagger UI) +- [ ] User Service: `http://:8700/swagger-ui.html` +- [ ] Product Service: `http://:8500/swagger-ui.html` +- [ ] Order Service: `http://:8300/swagger-ui.html` +- [ ] Payment Service: `http://:8400/swagger-ui.html` +- [ ] Shipping Service: `http://:8600/swagger-ui.html` +- [ ] Favourite Service: `http://:8800/swagger-ui.html` +- [ ] Proxy Client: `http://:8900/swagger-ui.html` + +### Observabilidad +- [ ] Prometheus: `http://:9090` +- [ ] Grafana: `http://:3000` (admin/admin) +- [ ] Kibana: `http://:5601` +- [ ] Jaeger: `http://:16686` +- [ ] Zipkin: `http://:9411` + +--- + +## 📊 Configuración de Grafana + +- [ ] Acceder a `http://:3000` +- [ ] Login: `admin` / `admin` +- [ ] Cambiar contraseña +- [ ] Agregar Data Source → Prometheus + - URL: `http://prometheus:9090` + - Save & Test +- [ ] Importar dashboards: + - Dashboard ID `3662` (Prometheus Stats) + - Dashboard ID `1860` (Node Exporter) + - Dashboard ID `11074` (Spring Boot) + +--- + +## 🧪 Ejecutar Tests + +```bash +cd ~/projects/ecommerce-microservice-backend-app +chmod +x test-em-all.sh +./test-em-all.sh +``` + +- [ ] Todos los tests pasan +- [ ] No hay errores críticos + +--- + +## 💾 Configurar Backups + +### Backup manual +```bash +./scripts/backup.sh +``` + +### Backup automático (diario a las 2 AM) +```bash +(crontab -l 2>/dev/null; echo "0 2 * * * $HOME/projects/ecommerce-microservice-backend-app/scripts/backup.sh") | crontab - +``` + +- [ ] Backup manual ejecutado exitosamente +- [ ] Cron job configurado +- [ ] Verificar: `crontab -l` + +--- + +## 🔄 Configurar Auto-inicio + +```bash +sudo nano /etc/systemd/system/ecommerce-microservices.service +``` + +Copiar contenido del archivo de la guía, luego: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable ecommerce-microservices.service +sudo systemctl start ecommerce-microservices.service +sudo systemctl status ecommerce-microservices.service +``` + +- [ ] Servicio creado +- [ ] Servicio habilitado +- [ ] Servicio iniciado +- [ ] Estado: `active (running)` + +--- + +## 📈 Monitoreo Continuo + +### Comandos útiles + +**Ver logs en tiempo real**: +```bash +docker-compose -f compose.yml logs -f +``` + +**Ver logs de un servicio específico**: +```bash +docker-compose -f compose.yml logs -f +``` + +**Ver uso de recursos**: +```bash +docker stats +htop +``` + +**Ver estado de servicios**: +```bash +./scripts/check-services.sh +``` + +--- + +## 🛠️ Troubleshooting Común + +### Problema: Servicio no inicia +```bash +# Ver logs +docker-compose -f compose.yml logs + +# Reiniciar servicio +docker-compose -f compose.yml restart +``` + +### Problema: Elasticsearch no inicia +```bash +# Verificar límites +sysctl vm.max_map_count + +# Ajustar si es necesario +sudo sysctl -w vm.max_map_count=262144 +``` + +### Problema: Memoria insuficiente +```bash +# Ver memoria +free -h + +# Limpiar recursos Docker +docker system prune -a + +# Reiniciar servicios pesados +docker-compose -f compose.yml restart elasticsearch logstash kibana +``` + +### Problema: No se puede acceder desde navegador +- [ ] Verificar NSG en Azure Portal +- [ ] Verificar UFW: `sudo ufw status` +- [ ] Verificar que el servicio esté corriendo: `docker ps` +- [ ] Verificar puerto: `sudo netstat -tlnp | grep ` + +--- + +## ✨ Optimizaciones Opcionales + +### Configurar dominio personalizado +- [ ] Comprar dominio +- [ ] Configurar DNS → IP pública de la VM +- [ ] Instalar Nginx como reverse proxy +- [ ] Configurar SSL con Let's Encrypt + +### Mejorar seguridad +- [ ] Configurar Azure Key Vault +- [ ] Implementar OAuth2/JWT +- [ ] Configurar WAF +- [ ] Restringir NSG a IPs específicas + +### Escalar +- [ ] Configurar Azure Load Balancer +- [ ] Crear VM Scale Set +- [ ] Migrar a Azure Kubernetes Service (AKS) + +--- + +## 📚 Recursos + +- [ ] Guía completa: `docs/azure-vm-deployment-guide.md` +- [ ] README de scripts: `scripts/README.md` +- [ ] Documentación del proyecto: `README.md` + +--- + +## ✅ Checklist Final de Verificación + +- [ ] VM creada y accesible +- [ ] Todos los puertos configurados +- [ ] Software instalado (Docker, Java, Maven) +- [ ] Proyecto clonado +- [ ] Servicios desplegados +- [ ] Todos los contenedores corriendo +- [ ] Eureka muestra todos los servicios +- [ ] API Gateway responde +- [ ] Prometheus recolectando métricas +- [ ] Grafana configurado +- [ ] Tests pasan correctamente +- [ ] Backups configurados +- [ ] Auto-inicio configurado +- [ ] Acceso desde navegador funciona + +--- + +## 🎉 ¡Felicidades! + +Si todos los items están marcados, tu aplicación de microservicios está corriendo exitosamente en Azure. + +**Próximos pasos sugeridos**: +1. Explorar los dashboards de Grafana +2. Revisar traces en Jaeger +3. Probar los endpoints con Swagger UI +4. Configurar alertas en Prometheus +5. Personalizar dashboards de Kibana + +--- + +**Fecha de creación**: 2025-11-30 +**Versión**: 1.0 diff --git a/docs/azure-vm-deployment-guide.md b/docs/azure-vm-deployment-guide.md new file mode 100644 index 000000000..02c671220 --- /dev/null +++ b/docs/azure-vm-deployment-guide.md @@ -0,0 +1,1079 @@ +# Guía Completa: Despliegue en Azure VM + +## Tabla de Contenidos +1. [Requisitos Previos](#requisitos-previos) +2. [Creación de la Máquina Virtual](#creación-de-la-máquina-virtual) +3. [Configuración Inicial de la VM](#configuración-inicial-de-la-vm) +4. [Instalación de Software Necesario](#instalación-de-software-necesario) +5. [Configuración de Puertos y Seguridad](#configuración-de-puertos-y-seguridad) +6. [Despliegue del Proyecto](#despliegue-del-proyecto) +7. [Verificación y Monitoreo](#verificación-y-monitoreo) +8. [Troubleshooting](#troubleshooting) + +--- + +## Requisitos Previos + +### Recursos Necesarios para el Proyecto +Basado en el análisis del proyecto, necesitarás: + +- **Microservicios**: 9 servicios principales +- **Infraestructura**: Eureka, Config Server, API Gateway, Zipkin +- **Observabilidad**: Prometheus, Grafana, ELK Stack (Elasticsearch, Logstash, Kibana), Jaeger +- **CI/CD**: Jenkins (opcional) + +### Especificaciones Recomendadas de la VM +- **CPU**: Mínimo 8 vCPUs (recomendado: 16 vCPUs) +- **RAM**: Mínimo 16 GB (recomendado: 32 GB) +- **Disco**: Mínimo 100 GB SSD (recomendado: 200 GB) +- **Sistema Operativo**: Ubuntu 22.04 LTS + +--- + +## Creación de la Máquina Virtual + +### Paso 1: Acceder al Portal de Azure + +1. Ingresa a [Azure Portal](https://portal.azure.com) +2. Inicia sesión con tu cuenta de Azure + +### Paso 2: Crear una Nueva Máquina Virtual + +#### 2.1 Iniciar la Creación +1. En el portal de Azure, haz clic en **"Crear un recurso"** +2. Busca **"Máquina virtual"** o **"Virtual Machine"** +3. Haz clic en **"Crear"** + +#### 2.2 Configuración Básica (Pestaña "Basics") + +**Detalles del Proyecto:** +- **Suscripción**: Selecciona tu suscripción de Azure +- **Grupo de recursos**: + - Opción 1: Crear nuevo → Nombre: `rg-ecommerce-microservices` + - Opción 2: Usar uno existente + +**Detalles de la Instancia:** +- **Nombre de la máquina virtual**: `vm-ecommerce-app` +- **Región**: Selecciona la más cercana (ej: `East US`, `West Europe`, `Brazil South`) +- **Opciones de disponibilidad**: `No se requiere redundancia de infraestructura` +- **Tipo de seguridad**: `Standard` +- **Imagen**: `Ubuntu Server 22.04 LTS - x64 Gen2` +- **Tamaño**: + - Haz clic en "Ver todos los tamaños" + - **Recomendado**: `Standard_D8s_v3` (8 vCPUs, 32 GB RAM) + - **Mínimo**: `Standard_D4s_v3` (4 vCPUs, 16 GB RAM) + - **Óptimo**: `Standard_D16s_v3` (16 vCPUs, 64 GB RAM) + +**Cuenta de Administrador:** +- **Tipo de autenticación**: `Clave pública SSH` +- **Nombre de usuario**: `azureuser` (o el que prefieras) +- **Origen de clave pública SSH**: + - Opción 1: `Generar nuevo par de claves` → Nombre: `vm-ecommerce-key` + - Opción 2: `Usar clave pública existente` (si ya tienes una) + +**Reglas de Puerto de Entrada:** +- Selecciona: `Permitir los puertos seleccionados` +- **Puertos de entrada públicos**: + - `SSH (22)` + - `HTTP (80)` + - `HTTPS (443)` + +#### 2.3 Configuración de Discos (Pestaña "Disks") + +- **Tipo de disco del SO**: `SSD Premium` (mejor rendimiento) +- **Tamaño del disco**: `200 GB` (mínimo 100 GB) +- **Cifrado**: Dejar por defecto +- **Discos de datos**: (Opcional) Agregar un disco adicional de 100 GB para datos + +#### 2.4 Configuración de Redes (Pestaña "Networking") + +**Interfaz de Red:** +- **Red virtual**: Crear nueva → `vnet-ecommerce` +- **Subred**: `default (10.0.0.0/24)` +- **IP pública**: Crear nueva → `pip-ecommerce-vm` +- **Grupo de seguridad de red NIC**: `Avanzado` +- **Configurar grupo de seguridad de red**: Crear nuevo → `nsg-ecommerce-vm` + +**Equilibrio de Carga:** +- Dejar sin configurar por ahora + +#### 2.5 Configuración de Administración (Pestaña "Management") + +- **Identidad**: Dejar por defecto +- **Apagado automático**: + - Habilitar si deseas (opcional) + - Configurar hora: `11:00 PM` en tu zona horaria +- **Copia de seguridad**: Habilitar (recomendado) +- **Diagnósticos de arranque**: Habilitar + +#### 2.6 Configuración Avanzada (Pestaña "Advanced") + +- **Extensiones**: Ninguna por ahora +- **Datos personalizados**: Dejar vacío +- **Cloud init**: Dejar vacío + +#### 2.7 Etiquetas (Pestaña "Tags") + +Agregar etiquetas para organización (opcional): +- `Environment`: `Production` o `Development` +- `Project`: `ecommerce-microservices` +- `Owner`: Tu nombre o equipo + +#### 2.8 Revisar y Crear + +1. Haz clic en **"Revisar y crear"** +2. Azure validará la configuración +3. Revisa el resumen y el costo estimado +4. Haz clic en **"Crear"** + +#### 2.9 Descargar la Clave SSH + +Si seleccionaste "Generar nuevo par de claves": +1. Se abrirá un diálogo para descargar la clave privada +2. **IMPORTANTE**: Descarga y guarda el archivo `.pem` en un lugar seguro +3. No podrás descargarlo nuevamente +4. En Linux/Mac, guárdalo en `~/.ssh/vm-ecommerce-key.pem` + +**Espera 5-10 minutos** mientras Azure crea la VM. + +--- + +## Configuración Inicial de la VM + +### Paso 3: Conectarse a la VM + +#### 3.1 Obtener la IP Pública + +1. Ve a **"Máquinas virtuales"** en el portal de Azure +2. Selecciona tu VM `vm-ecommerce-app` +3. En la página de información general, copia la **"Dirección IP pública"** + +#### 3.2 Configurar Permisos de la Clave SSH (Linux/Mac) + +```bash +# Cambiar permisos de la clave +chmod 400 ~/.ssh/vm-ecommerce-key.pem +``` + +#### 3.3 Conectarse por SSH + +```bash +# Reemplaza con la IP de tu VM +ssh -i ~/.ssh/vm-ecommerce-key.pem azureuser@ +``` + +**Para Windows:** +- Usa **PuTTY** o **Windows Terminal** con WSL +- O usa **Azure Cloud Shell** desde el portal + +#### 3.4 Verificar Conexión + +Una vez conectado, deberías ver el prompt: +```bash +azureuser@vm-ecommerce-app:~$ +``` + +--- + +## Instalación de Software Necesario + +### Paso 4: Actualizar el Sistema + +```bash +# Actualizar lista de paquetes +sudo apt update + +# Actualizar paquetes instalados +sudo apt upgrade -y + +# Instalar utilidades básicas +sudo apt install -y curl wget git vim net-tools htop +``` + +### Paso 5: Instalar Docker + +```bash +# Instalar dependencias +sudo apt install -y apt-transport-https ca-certificates curl software-properties-common + +# Agregar clave GPG de Docker +curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg + +# Agregar repositorio de Docker +echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null + +# Actualizar lista de paquetes +sudo apt update + +# Instalar Docker +sudo apt install -y docker-ce docker-ce-cli containerd.io + +# Verificar instalación +docker --version + +# Agregar usuario al grupo docker (para no usar sudo) +sudo usermod -aG docker $USER + +# Aplicar cambios de grupo (o cerrar sesión y volver a conectar) +newgrp docker + +# Verificar que funciona sin sudo +docker ps +``` + +### Paso 6: Instalar Docker Compose + +```bash +# Descargar Docker Compose +sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose + +# Dar permisos de ejecución +sudo chmod +x /usr/local/bin/docker-compose + +# Verificar instalación +docker-compose --version +``` + +### Paso 7: Instalar Java 11 (para compilación local si es necesario) + +```bash +# Instalar OpenJDK 11 +sudo apt install -y openjdk-11-jdk + +# Verificar instalación +java -version +javac -version + +# Configurar JAVA_HOME +echo 'export JAVA_HOME=/usr/lib/jvm/java-11-openjdk-amd64' >> ~/.bashrc +echo 'export PATH=$PATH:$JAVA_HOME/bin' >> ~/.bashrc +source ~/.bashrc +``` + +### Paso 8: Instalar Maven (opcional, para compilación) + +```bash +# Instalar Maven +sudo apt install -y maven + +# Verificar instalación +mvn -version +``` + +### Paso 9: Instalar utilidades adicionales + +```bash +# Instalar jq (para procesar JSON) +sudo apt install -y jq + +# Instalar netstat y otras herramientas de red +sudo apt install -y net-tools + +# Instalar herramientas de monitoreo +sudo apt install -y htop iotop +``` + +--- + +## Configuración de Puertos y Seguridad + +### Paso 10: Configurar el Grupo de Seguridad de Red (NSG) + +Necesitas abrir los siguientes puertos para acceder a los servicios: + +#### 10.1 Puertos Necesarios + +| Servicio | Puerto | Descripción | +|----------|--------|-------------| +| SSH | 22 | Acceso remoto | +| HTTP | 80 | Acceso web general | +| HTTPS | 443 | Acceso web seguro | +| API Gateway | 8080 | Gateway principal | +| Eureka | 8761 | Service Discovery | +| Config Server | 9296 | Configuración centralizada | +| Proxy Client | 8900 | Cliente proxy | +| Order Service | 8300 | Servicio de órdenes | +| Payment Service | 8400 | Servicio de pagos | +| Product Service | 8500 | Servicio de productos | +| Shipping Service | 8600 | Servicio de envíos | +| User Service | 8700 | Servicio de usuarios | +| Favourite Service | 8800 | Servicio de favoritos | +| Prometheus | 9090 | Métricas | +| Grafana | 3000 | Dashboards | +| Alertmanager | 9093 | Alertas | +| Zipkin | 9411 | Tracing | +| Jaeger UI | 16686 | Tracing UI | +| Elasticsearch | 9200 | Búsqueda y logs | +| Logstash | 5000 | Ingesta de logs | +| Kibana | 5601 | Visualización de logs | +| Jenkins | 8080 | CI/CD (si se usa) | + +#### 10.2 Agregar Reglas de Entrada en Azure + +1. Ve al portal de Azure +2. Navega a **"Grupos de seguridad de red"** +3. Selecciona `nsg-ecommerce-vm` +4. Haz clic en **"Reglas de seguridad de entrada"** +5. Haz clic en **"+ Agregar"** + +**Crear regla para cada servicio:** + +**Ejemplo: Regla para API Gateway** +- **Origen**: `Any` (o tu IP específica para mayor seguridad) +- **Rangos de puertos de origen**: `*` +- **Destino**: `Any` +- **Rangos de puertos de destino**: `8080` +- **Protocolo**: `TCP` +- **Acción**: `Permitir` +- **Prioridad**: `1000` (incrementar para cada regla) +- **Nombre**: `Allow-API-Gateway` + +**Regla consolidada para todos los servicios (más simple):** +- **Origen**: `Any` (o tu IP) +- **Rangos de puertos de origen**: `*` +- **Destino**: `Any` +- **Rangos de puertos de destino**: `3000,5000,5601,8080,8300-8900,9090,9093,9200,9296,9411,9761,16686` +- **Protocolo**: `TCP` +- **Acción**: `Permitir` +- **Prioridad**: `1000` +- **Nombre**: `Allow-Microservices-All` + +**⚠️ Nota de Seguridad:** +Para producción, es mejor: +- Usar un balanceador de carga +- Exponer solo el API Gateway (8080) y servicios de monitoreo +- Usar VPN o Azure Bastion para acceso administrativo +- Restringir el origen a IPs específicas + +#### 10.3 Configurar Firewall en la VM (UFW) + +```bash +# Habilitar UFW +sudo ufw enable + +# Permitir SSH (importante, no te bloquees) +sudo ufw allow 22/tcp + +# Permitir HTTP y HTTPS +sudo ufw allow 80/tcp +sudo ufw allow 443/tcp + +# Permitir puertos de microservicios +sudo ufw allow 3000/tcp # Grafana +sudo ufw allow 5000/tcp # Logstash +sudo ufw allow 5601/tcp # Kibana +sudo ufw allow 8080/tcp # API Gateway +sudo ufw allow 8300/tcp # Order Service +sudo ufw allow 8400/tcp # Payment Service +sudo ufw allow 8500/tcp # Product Service +sudo ufw allow 8600/tcp # Shipping Service +sudo ufw allow 8700/tcp # User Service +sudo ufw allow 8800/tcp # Favourite Service +sudo ufw allow 8900/tcp # Proxy Client +sudo ufw allow 9090/tcp # Prometheus +sudo ufw allow 9093/tcp # Alertmanager +sudo ufw allow 9200/tcp # Elasticsearch +sudo ufw allow 9296/tcp # Config Server +sudo ufw allow 9411/tcp # Zipkin/Jaeger +sudo ufw allow 8761/tcp # Eureka +sudo ufw allow 16686/tcp # Jaeger UI + +# Verificar reglas +sudo ufw status numbered + +# Recargar firewall +sudo ufw reload +``` + +--- + +## Despliegue del Proyecto + +### Paso 11: Clonar el Repositorio + +```bash +# Crear directorio para proyectos +mkdir -p ~/projects +cd ~/projects + +# Clonar el repositorio +git clone https://github.com/JuanseDev2001/ecommerce-microservice-backend-app.git + +# Entrar al directorio +cd ecommerce-microservice-backend-app + +# Verificar contenido +ls -la +``` + +### Paso 12: Configurar Variables de Entorno + +```bash +# Crear archivo de variables de entorno +cat > .env << 'EOF' +# Configuración de la aplicación +SPRING_PROFILES_ACTIVE=dev + +# Configuración de Docker +COMPOSE_PROJECT_NAME=ecommerce-microservices + +# Configuración de recursos +JAVA_OPTS=-Xmx512m -Xms256m +ES_JAVA_OPTS=-Xms512m -Xmx512m +LS_JAVA_OPTS=-Xmx512m -Xms512m + +# IP pública de la VM (reemplazar con tu IP) +PUBLIC_IP= +EOF + +# Editar el archivo y reemplazar +nano .env +``` + +### Paso 13: Revisar y Ajustar Docker Compose + +```bash +# Ver el archivo compose.yml +cat compose.yml + +# Si necesitas hacer ajustes, edítalo +nano compose.yml +``` + +**Ajustes recomendados para VM con recursos limitados:** + +```yaml +# En cada servicio de Java, agregar límites de memoria: +environment: + - JAVA_OPTS=-Xmx512m -Xms256m +deploy: + resources: + limits: + memory: 1G + reservations: + memory: 512M +``` + +### Paso 14: Descargar las Imágenes Docker + +```bash +# Descargar todas las imágenes (esto puede tomar 10-20 minutos) +docker-compose -f compose.yml pull + +# Verificar imágenes descargadas +docker images +``` + +### Paso 15: Iniciar los Servicios + +#### Opción 1: Iniciar todo de una vez + +```bash +# Iniciar todos los servicios +docker-compose -f compose.yml up -d + +# Ver logs en tiempo real +docker-compose -f compose.yml logs -f +``` + +#### Opción 2: Iniciar por etapas (recomendado para primera vez) + +```bash +# Paso 1: Iniciar infraestructura base +docker-compose -f compose.yml up -d zipkin service-discovery-container cloud-config-container + +# Esperar 30 segundos +sleep 30 + +# Verificar que estén corriendo +docker-compose -f compose.yml ps + +# Paso 2: Iniciar API Gateway y Proxy +docker-compose -f compose.yml up -d api-gateway-container proxy-client-container + +# Esperar 30 segundos +sleep 30 + +# Paso 3: Iniciar microservicios de negocio +docker-compose -f compose.yml up -d order-service-container payment-service-container product-service-container shipping-service-container user-service-container favourite-service-container + +# Esperar 30 segundos +sleep 30 + +# Paso 4: Iniciar stack de observabilidad +docker-compose -f compose.yml up -d prometheus alertmanager grafana jaeger elasticsearch logstash kibana + +# Ver todos los contenedores +docker-compose -f compose.yml ps +``` + +### Paso 16: Verificar el Estado de los Servicios + +```bash +# Ver todos los contenedores corriendo +docker ps + +# Ver logs de un servicio específico +docker-compose -f compose.yml logs api-gateway-container + +# Ver logs de todos los servicios +docker-compose -f compose.yml logs + +# Ver uso de recursos +docker stats + +# Verificar salud de los contenedores +docker-compose -f compose.yml ps +``` + +### Paso 17: Esperar a que los Servicios Estén Listos + +```bash +# Crear script de verificación +cat > check-services.sh << 'EOF' +#!/bin/bash + +echo "Verificando servicios..." + +# Función para verificar un servicio +check_service() { + local name=$1 + local url=$2 + local max_attempts=30 + local attempt=1 + + echo -n "Verificando $name... " + + while [ $attempt -le $max_attempts ]; do + if curl -s -f "$url" > /dev/null 2>&1; then + echo "✓ OK" + return 0 + fi + sleep 10 + attempt=$((attempt + 1)) + done + + echo "✗ TIMEOUT" + return 1 +} + +# Verificar servicios principales +check_service "Eureka" "http://localhost:8761" +check_service "Config Server" "http://localhost:9296/actuator/health" +check_service "API Gateway" "http://localhost:8080/actuator/health" +check_service "Prometheus" "http://localhost:9090/-/healthy" +check_service "Grafana" "http://localhost:3000/api/health" +check_service "Kibana" "http://localhost:5601/api/status" +check_service "Jaeger" "http://localhost:16686" + +echo "" +echo "Verificación completada!" +EOF + +# Dar permisos de ejecución +chmod +x check-services.sh + +# Ejecutar verificación +./check-services.sh +``` + +--- + +## Verificación y Monitoreo + +### Paso 18: Acceder a los Servicios + +Desde tu navegador local, accede a los siguientes URLs (reemplaza `` con la IP de tu VM): + +#### Servicios de Infraestructura +- **Eureka (Service Discovery)**: `http://:8761` +- **API Gateway Health**: `http://:8080/actuator/health` +- **Swagger UI (Proxy Client)**: `http://:8900/swagger-ui.html` + +#### Servicios de Observabilidad +- **Prometheus**: `http://:9090` +- **Grafana**: `http://:3000` (usuario: `admin`, contraseña: `admin`) +- **Kibana**: `http://:5601` +- **Jaeger**: `http://:16686` +- **Zipkin**: `http://:9411` + +#### Microservicios +- **User Service**: `http://:8700/swagger-ui.html` +- **Product Service**: `http://:8500/swagger-ui.html` +- **Order Service**: `http://:8300/swagger-ui.html` +- **Payment Service**: `http://:8400/swagger-ui.html` +- **Shipping Service**: `http://:8600/swagger-ui.html` +- **Favourite Service**: `http://:8800/swagger-ui.html` + +### Paso 19: Configurar Grafana + +1. Accede a Grafana: `http://:3000` +2. Login con `admin` / `admin` +3. Cambia la contraseña cuando se solicite +4. Agregar Prometheus como Data Source: + - Ve a **Configuration** → **Data Sources** + - Click **Add data source** + - Selecciona **Prometheus** + - URL: `http://prometheus:9090` + - Click **Save & Test** + +5. Importar dashboards: + - Ve a **Create** → **Import** + - Usa estos IDs de dashboards públicos: + - `3662` - Prometheus 2.0 Stats + - `1860` - Node Exporter Full + - `11074` - Spring Boot Statistics + +### Paso 20: Ejecutar Tests de Integración + +```bash +# Dar permisos de ejecución al script de tests +chmod +x test-em-all.sh + +# Ejecutar tests +./test-em-all.sh + +# O si quieres iniciar, probar y detener: +./test-em-all.sh start stop +``` + +### Paso 21: Monitorear Recursos de la VM + +```bash +# Ver uso de CPU y memoria +htop + +# Ver uso de disco +df -h + +# Ver uso de red +sudo iftop + +# Ver logs del sistema +sudo journalctl -f + +# Ver estadísticas de Docker +docker stats +``` + +--- + +## Configuración de Persistencia y Backups + +### Paso 22: Configurar Volúmenes Persistentes + +```bash +# Crear directorios para datos persistentes +sudo mkdir -p /data/grafana +sudo mkdir -p /data/prometheus +sudo mkdir -p /data/elasticsearch +sudo mkdir -p /data/jenkins + +# Cambiar permisos +sudo chown -R 472:472 /data/grafana # UID de Grafana +sudo chown -R 65534:65534 /data/prometheus # UID de Prometheus +sudo chown -R 1000:1000 /data/elasticsearch # UID de Elasticsearch +sudo chown -R 1000:1000 /data/jenkins # UID de Jenkins +``` + +### Paso 23: Script de Backup + +```bash +# Crear script de backup +cat > ~/backup-ecommerce.sh << 'EOF' +#!/bin/bash + +BACKUP_DIR="/home/azureuser/backups" +DATE=$(date +%Y%m%d_%H%M%S) +BACKUP_FILE="$BACKUP_DIR/ecommerce-backup-$DATE.tar.gz" + +# Crear directorio de backups +mkdir -p $BACKUP_DIR + +echo "Iniciando backup..." + +# Backup de volúmenes de Docker +docker run --rm \ + -v jenkins_home:/data/jenkins \ + -v grafana-storage:/data/grafana \ + -v elasticsearch-data:/data/elasticsearch \ + -v $BACKUP_DIR:/backup \ + ubuntu tar czf /backup/docker-volumes-$DATE.tar.gz /data + +# Backup de configuraciones +cd ~/projects/ecommerce-microservice-backend-app +tar czf $BACKUP_DIR/config-$DATE.tar.gz \ + compose.yml \ + prometheus/ \ + elk/ \ + .env + +echo "Backup completado: $BACKUP_FILE" + +# Limpiar backups antiguos (mantener últimos 7 días) +find $BACKUP_DIR -name "*.tar.gz" -mtime +7 -delete + +echo "Backups antiguos eliminados" +EOF + +# Dar permisos de ejecución +chmod +x ~/backup-ecommerce.sh + +# Agregar a crontab para ejecutar diariamente a las 2 AM +(crontab -l 2>/dev/null; echo "0 2 * * * /home/azureuser/backup-ecommerce.sh") | crontab - +``` + +--- + +## Optimización y Mejores Prácticas + +### Paso 24: Configurar Swap (si la RAM es limitada) + +```bash +# Verificar swap actual +free -h + +# Crear archivo de swap de 8GB +sudo fallocate -l 8G /swapfile + +# Configurar permisos +sudo chmod 600 /swapfile + +# Crear swap +sudo mkswap /swapfile + +# Activar swap +sudo swapon /swapfile + +# Hacer permanente +echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab + +# Verificar +free -h +``` + +### Paso 25: Configurar Límites de Docker + +```bash +# Editar daemon.json de Docker +sudo nano /etc/docker/daemon.json +``` + +Agregar: +```json +{ + "log-driver": "json-file", + "log-opts": { + "max-size": "10m", + "max-file": "3" + }, + "default-ulimits": { + "nofile": { + "Name": "nofile", + "Hard": 64000, + "Soft": 64000 + } + } +} +``` + +```bash +# Reiniciar Docker +sudo systemctl restart docker +``` + +### Paso 26: Configurar Auto-inicio de Servicios + +```bash +# Crear servicio systemd +sudo nano /etc/systemd/system/ecommerce-microservices.service +``` + +Contenido: +```ini +[Unit] +Description=E-Commerce Microservices +Requires=docker.service +After=docker.service + +[Service] +Type=oneshot +RemainAfterExit=yes +WorkingDirectory=/home/azureuser/projects/ecommerce-microservice-backend-app +ExecStart=/usr/local/bin/docker-compose -f compose.yml up -d +ExecStop=/usr/local/bin/docker-compose -f compose.yml down +User=azureuser + +[Install] +WantedBy=multi-user.target +``` + +```bash +# Recargar systemd +sudo systemctl daemon-reload + +# Habilitar servicio +sudo systemctl enable ecommerce-microservices.service + +# Iniciar servicio +sudo systemctl start ecommerce-microservices.service + +# Verificar estado +sudo systemctl status ecommerce-microservices.service +``` + +--- + +## Troubleshooting + +### Problemas Comunes y Soluciones + +#### 1. Contenedores que no inician + +```bash +# Ver logs del contenedor +docker-compose -f compose.yml logs + +# Ver últimas 100 líneas +docker-compose -f compose.yml logs --tail=100 + +# Reiniciar un servicio específico +docker-compose -f compose.yml restart +``` + +#### 2. Problemas de memoria + +```bash +# Ver uso de memoria +free -h +docker stats + +# Limpiar recursos de Docker +docker system prune -a --volumes + +# Reiniciar servicios pesados uno por uno +docker-compose -f compose.yml restart elasticsearch +``` + +#### 3. Servicios no se registran en Eureka + +```bash +# Verificar que Eureka esté corriendo +curl http://localhost:8761 + +# Ver logs de Eureka +docker-compose -f compose.yml logs service-discovery-container + +# Reiniciar servicios en orden +docker-compose -f compose.yml restart service-discovery-container +sleep 30 +docker-compose -f compose.yml restart api-gateway-container +``` + +#### 4. No se puede acceder desde el navegador + +```bash +# Verificar que el puerto esté abierto en la VM +sudo netstat -tlnp | grep + +# Verificar firewall +sudo ufw status + +# Verificar NSG en Azure Portal +# Ir a: VM → Networking → Inbound port rules +``` + +#### 5. Elasticsearch no inicia + +```bash +# Aumentar límites de memoria virtual +sudo sysctl -w vm.max_map_count=262144 + +# Hacer permanente +echo 'vm.max_map_count=262144' | sudo tee -a /etc/sysctl.conf + +# Reiniciar Elasticsearch +docker-compose -f compose.yml restart elasticsearch +``` + +#### 6. Disco lleno + +```bash +# Ver uso de disco +df -h + +# Limpiar logs de Docker +sudo sh -c "truncate -s 0 /var/lib/docker/containers/*/*-json.log" + +# Limpiar imágenes no usadas +docker image prune -a + +# Limpiar volúmenes no usados +docker volume prune +``` + +### Comandos Útiles de Diagnóstico + +```bash +# Ver todos los contenedores (incluso detenidos) +docker ps -a + +# Ver uso de recursos en tiempo real +docker stats + +# Ver redes de Docker +docker network ls + +# Inspeccionar un contenedor +docker inspect + +# Ver logs del sistema +sudo journalctl -xe + +# Ver procesos que usan más CPU +top + +# Ver conexiones de red +sudo netstat -tulpn + +# Verificar DNS +nslookup google.com + +# Test de conectividad +ping 8.8.8.8 +``` + +--- + +## Scripts de Utilidad + +### Script de Reinicio Completo + +```bash +cat > ~/restart-all.sh << 'EOF' +#!/bin/bash + +echo "Deteniendo todos los servicios..." +cd ~/projects/ecommerce-microservice-backend-app +docker-compose -f compose.yml down + +echo "Esperando 10 segundos..." +sleep 10 + +echo "Limpiando recursos..." +docker system prune -f + +echo "Iniciando servicios..." +docker-compose -f compose.yml up -d + +echo "Esperando a que los servicios estén listos..." +sleep 60 + +echo "Verificando estado..." +docker-compose -f compose.yml ps + +echo "Listo!" +EOF + +chmod +x ~/restart-all.sh +``` + +### Script de Monitoreo + +```bash +cat > ~/monitor.sh << 'EOF' +#!/bin/bash + +echo "=== Estado de Contenedores ===" +docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" + +echo "" +echo "=== Uso de Recursos ===" +docker stats --no-stream + +echo "" +echo "=== Uso de Disco ===" +df -h | grep -E "Filesystem|/dev/sda" + +echo "" +echo "=== Memoria del Sistema ===" +free -h + +echo "" +echo "=== Servicios en Eureka ===" +curl -s http://localhost:8761/eureka/apps | grep "" | sed 's///g' | sed 's/<\/app>//g' +EOF + +chmod +x ~/monitor.sh +``` + +--- + +## Checklist Final + +### ✅ Verificación de Despliegue + +- [ ] VM creada y accesible por SSH +- [ ] Docker y Docker Compose instalados +- [ ] Puertos configurados en NSG y UFW +- [ ] Repositorio clonado +- [ ] Todos los contenedores corriendo (`docker ps`) +- [ ] Eureka accesible y muestra todos los servicios +- [ ] API Gateway responde en puerto 8080 +- [ ] Prometheus recolectando métricas +- [ ] Grafana accesible y configurado +- [ ] Kibana accesible +- [ ] Jaeger accesible +- [ ] Tests de integración pasan (`./test-em-all.sh`) +- [ ] Backups configurados +- [ ] Auto-inicio configurado +- [ ] Monitoreo funcionando + +--- + +## Próximos Pasos + +1. **Configurar un dominio personalizado** + - Comprar un dominio + - Configurar DNS apuntando a la IP pública + - Configurar Nginx como reverse proxy + - Instalar certificados SSL con Let's Encrypt + +2. **Implementar CI/CD** + - Configurar Jenkins + - Crear pipelines de despliegue + - Automatizar builds y tests + +3. **Mejorar seguridad** + - Configurar Azure Key Vault para secretos + - Implementar autenticación OAuth2 + - Configurar WAF (Web Application Firewall) + +4. **Escalar horizontalmente** + - Configurar Azure Load Balancer + - Crear VM Scale Sets + - Migrar a Azure Kubernetes Service (AKS) + +--- + +## Recursos Adicionales + +- [Documentación de Azure VMs](https://docs.microsoft.com/azure/virtual-machines/) +- [Docker Documentation](https://docs.docker.com/) +- [Spring Cloud Documentation](https://spring.io/projects/spring-cloud) +- [Prometheus Documentation](https://prometheus.io/docs/) +- [Grafana Documentation](https://grafana.com/docs/) + +--- + +## Soporte + +Si encuentras problemas: +1. Revisa la sección de [Troubleshooting](#troubleshooting) +2. Verifica los logs: `docker-compose logs` +3. Consulta la documentación del proyecto +4. Abre un issue en el repositorio de GitHub + +--- + +**¡Felicidades! Tu aplicación de microservicios está corriendo en Azure** 🎉 diff --git a/elk/logstash.conf b/elk/logstash.conf new file mode 100644 index 000000000..d26cbf868 --- /dev/null +++ b/elk/logstash.conf @@ -0,0 +1,68 @@ +input { + tcp { + port => 5000 + codec => json_lines + } +} + +filter { + # Parse timestamp + date { + match => ["@timestamp", "ISO8601"] + target => "@timestamp" + } + + # Extract and enrich trace information + if [traceId] { + mutate { + add_field => { "trace_id" => "%{traceId}" } + } + } + + if [spanId] { + mutate { + add_field => { "span_id" => "%{spanId}" } + } + } + + # Add tags based on log level + if [level] == "ERROR" { + mutate { + add_tag => ["error"] + } + } else if [level] == "WARN" { + mutate { + add_tag => ["warning"] + } + } else if [level] == "INFO" { + mutate { + add_tag => ["info"] + } + } + + # Extract application info + if [app_name] { + mutate { + add_field => { "application" => "%{app_name}" } + } + } + + # Parse exception stack traces + if [stack_trace] { + mutate { + add_tag => ["exception"] + } + } +} + +output { + elasticsearch { + hosts => ["elasticsearch:9200"] + index => "ecommerce-logs-%{[app_name]}-%{+YYYY.MM.dd}" + } + + # Console output for debugging (can be disabled in production) + stdout { + codec => rubydebug + } +} diff --git a/favourite-service/Dockerfile b/favourite-service/Dockerfile index 82ab593f6..b18f592cd 100644 --- a/favourite-service/Dockerfile +++ b/favourite-service/Dockerfile @@ -1,12 +1,9 @@ - FROM openjdk:11 ARG PROJECT_VERSION=0.1.0 RUN mkdir -p /home/app WORKDIR /home/app -ENV SPRING_PROFILES_ACTIVE dev +ENV SPRING_PROFILES_ACTIVE=dev COPY favourite-service/ . ADD favourite-service/target/favourite-service-v${PROJECT_VERSION}.jar favourite-service.jar EXPOSE 8800 ENTRYPOINT ["java", "-Dspring.profiles.active=${SPRING_PROFILES_ACTIVE}", "-jar", "favourite-service.jar"] - - diff --git a/favourite-service/compose.yml b/favourite-service/compose.yml index 6a5607451..e0478a96d 100644 --- a/favourite-service/compose.yml +++ b/favourite-service/compose.yml @@ -1,12 +1,22 @@ - version: '3' services: favourite-service-container: - image: selimhorri/favourite-service-ecommerce-boot:0.1.0 + image: juanse201/favourite-service-ecommerce-boot:0.1.0 ports: - 8800:8800 environment: - SPRING_PROFILES_ACTIVE=dev - + - SPRING_ZIPKIN_BASE_URL=http://zipkin:9411 + - SPRING_CONFIG_IMPORT=optional:configserver:http://cloud-config-container:9296/ + - EUREKA_CLIENT_REGION=default + - EUREKA_CLIENT_AVAILABILITY_ZONES_DEFAULT=myzone + - EUREKA_CLIENT_SERVICEURL_MYZONE=http://service-discovery-container:8761/eureka + - EUREKA_CLIENT_SERVICEURL_DEFAULTZONE=http://service-discovery-container:8761/eureka/ + networks: + - microservices_network +networks: + microservices_network: + external: true + name: microservices_network diff --git a/favourite-service/pom.xml b/favourite-service/pom.xml index c30204d36..848dda81c 100644 --- a/favourite-service/pom.xml +++ b/favourite-service/pom.xml @@ -73,6 +73,23 @@ mysql test + + org.springframework.boot + spring-boot-starter-actuator + + + io.micrometer + micrometer-registry-prometheus + + + net.logstash.logback + logstash-logback-encoder + 7.3 + + + org.springframework.cloud + spring-cloud-starter-sleuth + diff --git a/favourite-service/src/main/resources/application.yml b/favourite-service/src/main/resources/application.yml index 6474033d8..b117770dc 100644 --- a/favourite-service/src/main/resources/application.yml +++ b/favourite-service/src/main/resources/application.yml @@ -5,15 +5,20 @@ server: spring: zipkin: - base-url: ${SPRING_ZIPKIN_BASE_URL:http://localhost:9411/} + base-url: ${SPRING_ZIPKIN_BASE_URL:http://zipkin:9411/} config: - import: ${SPRING_CONFIG_IMPORT:optional:configserver:http://localhost:9296} + import: ${SPRING_CONFIG_IMPORT:optional:configserver:http://cloud-config:9296} application: name: FAVOURITE-SERVICE profiles: active: - dev - + +eureka: + client: + service-url: + defaultZone: ${EUREKA_CLIENT_SERVICEURL_DEFAULTZONE:http://service-discovery-container:8761/eureka/} + resilience4j: circuitbreaker: instances: @@ -29,12 +34,33 @@ resilience4j: sliding-window-type: COUNT_BASED management: - health: - circuitbreakers: - enabled: true + endpoints: + web: + exposure: + include: health,info,metrics,prometheus,env,loggers,threaddump,heapdump endpoint: health: show-details: always + probes: + enabled: true + metrics: + enabled: true + prometheus: + enabled: true + metrics: + export: + prometheus: + enabled: true + tags: + application: ${spring.application.name} + environment: ${spring.profiles.active} + health: + circuitbreakers: + enabled: true + livenessstate: + enabled: true + readinessstate: + enabled: true diff --git a/favourite-service/src/main/resources/logback-spring.xml b/favourite-service/src/main/resources/logback-spring.xml new file mode 100644 index 000000000..9a551a487 --- /dev/null +++ b/favourite-service/src/main/resources/logback-spring.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + {"app_name":"${appName}","app_port":"${appPort}"} + traceId + spanId + + + + + + logstash:5000 + + {"app_name":"${appName}","app_port":"${appPort}"} + traceId + spanId + + 5 minutes + + + + + + 512 + + + + + + + + + + + + + + + diff --git a/favourite-service/src/test/java/com/selimhorri/app/integration/FavouriteIntegrationTests.java b/favourite-service/src/test/java/com/selimhorri/app/integration/FavouriteIntegrationTests.java new file mode 100644 index 000000000..a231e4942 --- /dev/null +++ b/favourite-service/src/test/java/com/selimhorri/app/integration/FavouriteIntegrationTests.java @@ -0,0 +1,70 @@ +package com.selimhorri.app.integration; + +import com.selimhorri.app.domain.Favourite; +import com.selimhorri.app.domain.id.FavouriteId; +import com.selimhorri.app.dto.FavouriteDto; +import com.selimhorri.app.repository.FavouriteRepository; +import com.selimhorri.app.service.impl.FavouriteServiceImpl; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.web.client.RestTemplate; +import java.time.LocalDateTime; +import java.util.Optional; +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +class FavouriteIntegrationTests { + + @Autowired + private FavouriteServiceImpl service; + + @MockBean + private FavouriteRepository repo; + + @MockBean + private RestTemplate restTemplate; + + @Test + void contextLoads() { + assertNotNull(service); + } + + @Test + void testSaveIntegration() { + FavouriteDto dto = FavouriteDto.builder().userId(1).productId(2).likeDate(LocalDateTime.now()).build(); + Favourite entity = Favourite.builder().userId(1).productId(2).likeDate(dto.getLikeDate()).build(); + Mockito.when(repo.save(Mockito.any())).thenReturn(entity); + FavouriteDto result = service.save(dto); + assertEquals(dto.getUserId(), result.getUserId()); + } + + @Test + void testUpdateIntegration() { + FavouriteDto dto = FavouriteDto.builder().userId(1).productId(2).likeDate(LocalDateTime.now()).build(); + Favourite entity = Favourite.builder().userId(1).productId(2).likeDate(dto.getLikeDate()).build(); + Mockito.when(repo.save(Mockito.any())).thenReturn(entity); + FavouriteDto result = service.update(dto); + assertEquals(dto.getProductId(), result.getProductId()); + } + + @Test + void testDeleteByIdIntegration() { + FavouriteId id = new FavouriteId(1, 2, LocalDateTime.now()); + service.deleteById(id); + Mockito.verify(repo).deleteById(id); + } + + @Test + void testFindByIdIntegration() { + FavouriteId id = new FavouriteId(1, 2, LocalDateTime.now()); + Favourite entity = Favourite.builder().userId(1).productId(2).likeDate(id.getLikeDate()).build(); + Mockito.when(repo.findById(id)).thenReturn(Optional.of(entity)); + Mockito.when(restTemplate.getForObject(Mockito.anyString(), Mockito.eq(com.selimhorri.app.dto.UserDto.class))).thenReturn(null); + Mockito.when(restTemplate.getForObject(Mockito.anyString(), Mockito.eq(com.selimhorri.app.dto.ProductDto.class))).thenReturn(null); + FavouriteDto result = service.findById(id); + assertEquals(id.getUserId(), result.getUserId()); + } +} \ No newline at end of file diff --git a/favourite-service/src/test/java/com/selimhorri/app/unit/FavouriteUnitTests.java b/favourite-service/src/test/java/com/selimhorri/app/unit/FavouriteUnitTests.java new file mode 100644 index 000000000..e27502d1b --- /dev/null +++ b/favourite-service/src/test/java/com/selimhorri/app/unit/FavouriteUnitTests.java @@ -0,0 +1,68 @@ +package com.selimhorri.app.unit; + +import com.selimhorri.app.domain.Favourite; +import com.selimhorri.app.dto.FavouriteDto; +import com.selimhorri.app.helper.FavouriteMappingHelper; +import com.selimhorri.app.service.impl.FavouriteServiceImpl; +import com.selimhorri.app.repository.FavouriteRepository; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.web.client.RestTemplate; +import java.time.LocalDateTime; +import static org.junit.jupiter.api.Assertions.*; +import com.selimhorri.app.domain.id.FavouriteId; + +class FavouriteUnitTests { + + @Test + void testMapFavouriteToDto() { + Favourite favourite = Favourite.builder().userId(1).productId(2).likeDate(LocalDateTime.now()).build(); + FavouriteDto dto = FavouriteMappingHelper.map(favourite); + assertEquals(favourite.getUserId(), dto.getUserId()); + assertEquals(favourite.getProductId(), dto.getProductId()); + assertNotNull(dto.getUserDto()); + assertNotNull(dto.getProductDto()); + } + + @Test + void testMapDtoToFavourite() { + FavouriteDto dto = FavouriteDto.builder().userId(1).productId(2).likeDate(LocalDateTime.now()).build(); + Favourite favourite = FavouriteMappingHelper.map(dto); + assertEquals(dto.getUserId(), favourite.getUserId()); + assertEquals(dto.getProductId(), favourite.getProductId()); + } + + @Test + void testSaveCallsRepository() { + FavouriteRepository repo = Mockito.mock(FavouriteRepository.class); + RestTemplate restTemplate = Mockito.mock(RestTemplate.class); + FavouriteServiceImpl service = new FavouriteServiceImpl(repo, restTemplate); + FavouriteDto dto = FavouriteDto.builder().userId(1).productId(2).likeDate(LocalDateTime.now()).build(); + Favourite entity = FavouriteMappingHelper.map(dto); + Mockito.when(repo.save(Mockito.any())).thenReturn(entity); + FavouriteDto result = service.save(dto); + assertEquals(dto.getUserId(), result.getUserId()); + } + + @Test + void testUpdateCallsRepository() { + FavouriteRepository repo = Mockito.mock(FavouriteRepository.class); + RestTemplate restTemplate = Mockito.mock(RestTemplate.class); + FavouriteServiceImpl service = new FavouriteServiceImpl(repo, restTemplate); + FavouriteDto dto = FavouriteDto.builder().userId(1).productId(2).likeDate(LocalDateTime.now()).build(); + Favourite entity = FavouriteMappingHelper.map(dto); + Mockito.when(repo.save(Mockito.any())).thenReturn(entity); + FavouriteDto result = service.update(dto); + assertEquals(dto.getProductId(), result.getProductId()); + } + + @Test + void testDeleteByIdCallsRepository() { + FavouriteRepository repo = Mockito.mock(FavouriteRepository.class); + RestTemplate restTemplate = Mockito.mock(RestTemplate.class); + FavouriteServiceImpl service = new FavouriteServiceImpl(repo, restTemplate); + FavouriteId id = new FavouriteId(1, 2, LocalDateTime.now()); + service.deleteById(id); + Mockito.verify(repo).deleteById(id); + } +} \ No newline at end of file diff --git a/globaltests/e2e/CloudConfigE2ETest.java b/globaltests/e2e/CloudConfigE2ETest.java new file mode 100644 index 000000000..fa3e2857d --- /dev/null +++ b/globaltests/e2e/CloudConfigE2ETest.java @@ -0,0 +1,55 @@ +package globaltests.e2e; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.web.client.RestTemplate; +import static org.junit.jupiter.api.Assertions.*; + +public class CloudConfigE2ETest { + + + @Test + void testUserServiceStartsWithConfigFromCloudConfig() { + RestTemplate restTemplate = new RestTemplate(); + String config = restTemplate.getForObject("http://cloud-config:9296/user-service/default", String.class); + assertNotNull(config); + // Simulate user-service startup using config + assertTrue(config.contains("spring")); + } + + + @Test + void testOrderServiceStartsWithConfigFromCloudConfig() { + RestTemplate restTemplate = new RestTemplate(); + String config = restTemplate.getForObject("http://cloud-config:9296/order-service/default", String.class); + assertNotNull(config); + assertTrue(config.contains("spring")); + } + + + @Test + void testProductServiceStartsWithConfigFromCloudConfig() { + RestTemplate restTemplate = new RestTemplate(); + String config = restTemplate.getForObject("http://cloud-config:9296/product-service/default", String.class); + assertNotNull(config); + assertTrue(config.contains("spring")); + } + + + @Test + void testPaymentServiceStartsWithConfigFromCloudConfig() { + RestTemplate restTemplate = new RestTemplate(); + String config = restTemplate.getForObject("http://cloud-config:9296/payment-service/default", String.class); + assertNotNull(config); + assertTrue(config.contains("spring")); + } + + + @Test + void testFavouriteServiceStartsWithConfigFromCloudConfig() { + RestTemplate restTemplate = new RestTemplate(); + String config = restTemplate.getForObject("http://cloud-config:9296/favourite-service/default", String.class); + assertNotNull(config); + assertTrue(config.contains("spring")); + } +} diff --git a/globaltests/e2e/FavouriteServiceE2ETest.java b/globaltests/e2e/FavouriteServiceE2ETest.java new file mode 100644 index 000000000..452400c3f --- /dev/null +++ b/globaltests/e2e/FavouriteServiceE2ETest.java @@ -0,0 +1,141 @@ +package globaltests.e2e; + +import org.junit.jupiter.api.*; +import org.springframework.http.*; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.client.HttpClientErrorException; +import static org.junit.jupiter.api.Assertions.*; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +public class FavouriteServiceE2ETest { + + private final String baseUrl = "http://favourite-service:8800/favourite-service/api/favourites"; + private final String userServiceUrl = "http://user-service:8700/user-service/api/users"; + private final String productServiceUrl = "http://product-service:8500/product-service/api/products"; + + private final RestTemplate restTemplate = new RestTemplate(); + private static final DateTimeFormatter FAVOURITE_FORMATTER = DateTimeFormatter.ofPattern("dd-MM-yyyy__HH:mm:ss:SSSSSS"); + // Helper to create a UserDto JSON + private String buildUserJson(int userId) { + return String.format("{" + + "\"userId\":%d," + + "\"firstName\":\"Test\"," + + "\"lastName\":\"User\"," + + "\"email\":\"testuser%d@email.com\"," + + "\"credential\": {" + + "\"username\": \"testuser%d\"," + + "\"password\": \"password%d\"}" + + "}", userId, userId, userId, userId); + } + + // Helper to create a ProductDto JSON + private String buildProductJson(int productId) { + return String.format("{" + + "\"productId\":%d," + + "\"productTitle\":\"Test Product %d\"," + + "\"priceUnit\":99.99}", productId, productId); + } + + @BeforeEach + void setupDependencies() { + // Create users with userId 1 and 2 if not exist + for (int userId : new int[]{1, 2}) { + try { + restTemplate.getForEntity(userServiceUrl + "/" + userId, String.class); + } catch (Exception e) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(buildUserJson(userId), headers); + restTemplate.postForEntity(userServiceUrl, request, String.class); + } + } + // Create products with productId 1 and 2 if not exist + for (int productId : new int[]{1, 2}) { + try { + restTemplate.getForEntity(productServiceUrl + "/" + productId, String.class); + } catch (Exception e) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(buildProductJson(productId), headers); + restTemplate.postForEntity(productServiceUrl, request, String.class); + } + } + } + + // Helper to create a FavouriteDto JSON + private String buildFavouriteJson(int userId, int productId, String likeDate) { + return String.format("{" + + "\"userId\":%d," + + "\"productId\":%d," + + "\"likeDate\":\"%s\"}", userId, productId, likeDate); + } + + @Test + void testCreateFavourite() { + String likeDate = LocalDateTime.now().format(FAVOURITE_FORMATTER); + String json = buildFavouriteJson(1, 1, likeDate); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(json, headers); + ResponseEntity response = restTemplate.postForEntity(baseUrl, request, String.class); + assertTrue(response.getStatusCode().is2xxSuccessful()); + assertNotNull(response.getBody()); + assertTrue(response.getBody().contains("userId")); + } + + @Test + void testGetAllFavourites() { + ResponseEntity response = restTemplate.getForEntity(baseUrl, String.class); + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + assertTrue(response.getBody().contains("collection")); + } + + @Test + void testGetFavouriteById() { + String likeDate = LocalDateTime.now().format(FAVOURITE_FORMATTER); + // Create first + String json = buildFavouriteJson(2, 2, likeDate); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(json, headers); + restTemplate.postForEntity(baseUrl, request, String.class); + // Get by id + String url = String.format("%s/%d/%d/%s", baseUrl, 2, 2, likeDate.replace(" ", "%20")); + ResponseEntity response = restTemplate.getForEntity(url, String.class); + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertTrue(response.getBody().contains("userId")); + } + + @Test + void testUpdateFavourite() { + String likeDate = LocalDateTime.now().format(FAVOURITE_FORMATTER); + String json = buildFavouriteJson(1, 2, likeDate); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(json, headers); + restTemplate.postForEntity(baseUrl, request, String.class); + // Update (simulate, e.g. update likeDate) + String newLikeDate = LocalDateTime.now().plusMinutes(1).format(FAVOURITE_FORMATTER); + String updateJson = buildFavouriteJson(1, 2, newLikeDate); + HttpEntity updateRequest = new HttpEntity<>(updateJson, headers); + ResponseEntity updateResponse = restTemplate.exchange(baseUrl, HttpMethod.PUT, updateRequest, String.class); + assertTrue(updateResponse.getStatusCode().is2xxSuccessful()); + } + + @Test + void testDeleteFavourite() { + String likeDate = LocalDateTime.now().format(FAVOURITE_FORMATTER); + String json = buildFavouriteJson(2, 1, likeDate); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(json, headers); + restTemplate.postForEntity(baseUrl, request, String.class); + // Delete + String url = String.format("%s/%d/%d/%s", baseUrl, 2, 1, likeDate.replace(" ", "%20")); + ResponseEntity deleteResponse = restTemplate.exchange(url, HttpMethod.DELETE, null, String.class); + assertTrue(deleteResponse.getStatusCode().is2xxSuccessful()); + } +} diff --git a/globaltests/e2e/OrderServiceE2ETest.java b/globaltests/e2e/OrderServiceE2ETest.java new file mode 100644 index 000000000..52ff3362a --- /dev/null +++ b/globaltests/e2e/OrderServiceE2ETest.java @@ -0,0 +1,161 @@ +package globaltests.e2e; + +import org.junit.jupiter.api.*; +import org.springframework.http.*; +import org.springframework.web.client.RestTemplate; +import static org.junit.jupiter.api.Assertions.*; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +public class OrderServiceE2ETest { + + private final String userServiceUrl = "http://user-service:8700/user-service/api/users"; + private final String cartServiceUrl = "http://order-service:8300/order-service/api/carts"; + private final String orderServiceUrl = "http://order-service:8300/order-service/api/orders"; + private final RestTemplate restTemplate = new RestTemplate(); + private static final DateTimeFormatter ORDER_FORMATTER = DateTimeFormatter.ofPattern("dd-MM-yyyy__HH:mm:ss:SSSSSS"); + + private Integer ensureUserExists(int userId) { + try { + restTemplate.getForEntity(userServiceUrl + "/" + userId, String.class); + } catch (Exception e) { + String userJson = String.format("{" + + "\"userId\":%d," + + "\"firstName\":\"OrderTest\"," + + "\"lastName\":\"User\"," + + "\"email\":\"ordertestuser%d@email.com\"," + + "\"credential\": {" + + "\"username\": \"ordertestuser%d\"," + + "\"password\": \"password%d\"}" + + "}", userId, userId, userId, userId); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(userJson, headers); + restTemplate.postForEntity(userServiceUrl, request, String.class); + } + return userId; + } + + private Integer createCartForUser(int userId) { + String cartJson = String.format("{" + + "\"userId\":%d" + + "}", userId); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(cartJson, headers); + ResponseEntity response = restTemplate.postForEntity(cartServiceUrl, request, String.class); + assertTrue(response.getStatusCode().is2xxSuccessful()); + String body = response.getBody(); + assertNotNull(body); + // Extract cartId from response + int idx = body.indexOf("cartId"); + assertTrue(idx > 0); + String sub = body.substring(idx); + String[] parts = sub.split(":"); + int cartId = Integer.parseInt(parts[1].replaceAll("[^0-9]", "")); + return cartId; + } + + private String buildOrderJson(Integer cartId, String desc, double fee) { + String orderDate = LocalDateTime.now().format(ORDER_FORMATTER); + return String.format(java.util.Locale.US, "{" + + "\"orderDate\":\"%s\"," + + "\"orderDesc\":\"%s\"," + + "\"orderFee\":%.2f," + + "\"cart\": {\"cartId\": %d}" + + "}", orderDate, desc, fee, cartId); + } + + @Test + void testCreateOrder() { + int userId = ensureUserExists(1001); + int cartId = createCartForUser(userId); + String orderJson = buildOrderJson(cartId, "Test Order", 123.45); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(orderJson, headers); + ResponseEntity response = restTemplate.postForEntity(orderServiceUrl, request, String.class); + assertTrue(response.getStatusCode().is2xxSuccessful()); + assertNotNull(response.getBody()); + assertTrue(response.getBody().contains("orderId")); + } + + @Test + void testGetAllOrders() { + ResponseEntity response = restTemplate.getForEntity(orderServiceUrl, String.class); + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + assertTrue(response.getBody().contains("collection")); + } + + @Test + void testGetOrderById() { + int userId = ensureUserExists(1002); + int cartId = createCartForUser(userId); + String orderJson = buildOrderJson(cartId, "Order By Id", 222.22); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(orderJson, headers); + ResponseEntity createResponse = restTemplate.postForEntity(orderServiceUrl, request, String.class); + assertTrue(createResponse.getStatusCode().is2xxSuccessful()); + String body = createResponse.getBody(); + assertNotNull(body); + int idx = body.indexOf("orderId"); + String sub = body.substring(idx); + String[] parts = sub.split(":"); + int orderId = Integer.parseInt(parts[1].replaceAll("[^0-9]", "")); + ResponseEntity getResponse = restTemplate.getForEntity(orderServiceUrl + "/" + orderId, String.class); + assertEquals(HttpStatus.OK, getResponse.getStatusCode()); + assertTrue(getResponse.getBody().contains("orderId")); + } + + @Test + void testUpdateOrder() { + int userId = ensureUserExists(1003); + int cartId = createCartForUser(userId); + String orderJson = buildOrderJson(cartId, "Order To Update", 333.33); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(orderJson, headers); + ResponseEntity createResponse = restTemplate.postForEntity(orderServiceUrl, request, String.class); + assertTrue(createResponse.getStatusCode().is2xxSuccessful()); + String body = createResponse.getBody(); + int idx = body.indexOf("orderId"); + String sub = body.substring(idx); + String[] parts = sub.split(":"); + int orderId = Integer.parseInt(parts[1].replaceAll("[^0-9]", "")); + // Update order + String updateJson = String.format(java.util.Locale.US, "{" + + "\"orderId\":%d," + + "\"orderDate\":\"%s\"," + + "\"orderDesc\":\"Updated Desc\"," + + "\"orderFee\":%.2f," + + "\"cart\": {\"cartId\": %d}" + + "}", orderId, LocalDateTime.now().format(ORDER_FORMATTER), 444.44, cartId); + HttpEntity updateRequest = new HttpEntity<>(updateJson, headers); + ResponseEntity updateResponse = restTemplate.exchange(orderServiceUrl, HttpMethod.PUT, updateRequest, String.class); + assertTrue(updateResponse.getStatusCode().is2xxSuccessful()); + assertTrue(updateResponse.getBody().contains("Updated Desc")); + } + + @Test + void testDeleteOrder() { + int userId = ensureUserExists(1004); + int cartId = createCartForUser(userId); + String orderJson = buildOrderJson(cartId, "Order To Delete", 555.55); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(orderJson, headers); + ResponseEntity createResponse = restTemplate.postForEntity(orderServiceUrl, request, String.class); + assertTrue(createResponse.getStatusCode().is2xxSuccessful()); + String body = createResponse.getBody(); + int idx = body.indexOf("orderId"); + String sub = body.substring(idx); + String[] parts = sub.split(":"); + int orderId = Integer.parseInt(parts[1].replaceAll("[^0-9]", "")); + // Delete order + ResponseEntity deleteResponse = restTemplate.exchange(orderServiceUrl + "/" + orderId, HttpMethod.DELETE, null, String.class); + assertTrue(deleteResponse.getStatusCode().is2xxSuccessful()); + } +} diff --git a/globaltests/e2e/PaymentServiceE2ETest.java b/globaltests/e2e/PaymentServiceE2ETest.java new file mode 100644 index 000000000..495e53ae0 --- /dev/null +++ b/globaltests/e2e/PaymentServiceE2ETest.java @@ -0,0 +1,139 @@ +package globaltests.e2e; + +import org.junit.jupiter.api.*; +import org.springframework.http.*; +import org.springframework.web.client.RestTemplate; +import static org.junit.jupiter.api.Assertions.*; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +public class PaymentServiceE2ETest { + + private final String orderServiceUrl = "http://order-service:8300/order-service/api/orders"; + private final String paymentServiceUrl = "http://payment-service:8400/payment-service/api/payments"; + private final RestTemplate restTemplate = new RestTemplate(); + private static final DateTimeFormatter ORDER_FORMATTER = DateTimeFormatter.ofPattern("dd-MM-yyyy__HH:mm:ss:SSSSSS"); + + private Integer createOrder() { + String orderJson = String.format(java.util.Locale.US, "{" + + "\"orderDate\":\"%s\"," + + "\"orderDesc\":\"PaymentTestOrder\"," + + "\"orderFee\":%.2f," + + "\"cart\": {\"cartId\": 1}" + // Assumes cartId 1 exists, or adapt as needed + "}", LocalDateTime.now().format(ORDER_FORMATTER), 100.00); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(orderJson, headers); + ResponseEntity response = restTemplate.postForEntity(orderServiceUrl, request, String.class); + assertTrue(response.getStatusCode().is2xxSuccessful()); + String body = response.getBody(); + assertNotNull(body); + int idx = body.indexOf("orderId"); + String sub = body.substring(idx); + String[] parts = sub.split(":"); + int orderId = Integer.parseInt(parts[1].replaceAll("[^0-9]", "")); + return orderId; + } + + private String buildPaymentJson(Integer orderId, boolean isPayed, String status) { + return String.format(java.util.Locale.US, "{" + + "\"isPayed\":%s," + + "\"paymentStatus\":\"%s\"," + + "\"order\": {\"orderId\": %d}" + + "}", isPayed, status, orderId); + } + + @Test + void testCreatePayment() { + int orderId = createOrder(); + String paymentJson = buildPaymentJson(orderId, false, "NOT_STARTED"); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(paymentJson, headers); + ResponseEntity response = restTemplate.postForEntity(paymentServiceUrl, request, String.class); + assertTrue(response.getStatusCode().is2xxSuccessful()); + assertNotNull(response.getBody()); + assertTrue(response.getBody().contains("paymentId")); + } + + @Test + void testGetAllPayments() { + int orderId = createOrder(); + String paymentJson = buildPaymentJson(orderId, false, "NOT_STARTED"); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(paymentJson, headers); + restTemplate.postForEntity(paymentServiceUrl, request, String.class); + ResponseEntity response = restTemplate.getForEntity(paymentServiceUrl, String.class); + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + assertTrue(response.getBody().contains("collection")); + } + + @Test + void testGetPaymentById() { + int orderId = createOrder(); + String paymentJson = buildPaymentJson(orderId, false, "NOT_STARTED"); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(paymentJson, headers); + ResponseEntity createResponse = restTemplate.postForEntity(paymentServiceUrl, request, String.class); + assertTrue(createResponse.getStatusCode().is2xxSuccessful()); + String body = createResponse.getBody(); + assertNotNull(body); + int idx = body.indexOf("paymentId"); + String sub = body.substring(idx); + String[] parts = sub.split(":"); + int paymentId = Integer.parseInt(parts[1].replaceAll("[^0-9]", "")); + ResponseEntity getResponse = restTemplate.getForEntity(paymentServiceUrl + "/" + paymentId, String.class); + assertEquals(HttpStatus.OK, getResponse.getStatusCode()); + assertTrue(getResponse.getBody().contains("paymentId")); + } + + @Test + void testUpdatePayment() { + int orderId = createOrder(); + String paymentJson = buildPaymentJson(orderId, false, "NOT_STARTED"); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(paymentJson, headers); + ResponseEntity createResponse = restTemplate.postForEntity(paymentServiceUrl, request, String.class); + assertTrue(createResponse.getStatusCode().is2xxSuccessful()); + String body = createResponse.getBody(); + int idx = body.indexOf("paymentId"); + String sub = body.substring(idx); + String[] parts = sub.split(":"); + int paymentId = Integer.parseInt(parts[1].replaceAll("[^0-9]", "")); + // Update payment + String updateJson = String.format(java.util.Locale.US, "{" + + "\"paymentId\":%d," + + "\"isPayed\":true," + + "\"paymentStatus\":\"COMPLETED\"," + + "\"order\": {\"orderId\": %d}" + + "}", paymentId, orderId); + HttpEntity updateRequest = new HttpEntity<>(updateJson, headers); + ResponseEntity updateResponse = restTemplate.exchange(paymentServiceUrl, HttpMethod.PUT, updateRequest, String.class); + assertTrue(updateResponse.getStatusCode().is2xxSuccessful()); + assertTrue(updateResponse.getBody().contains("COMPLETED")); + } + + @Test + void testDeletePayment() { + int orderId = createOrder(); + String paymentJson = buildPaymentJson(orderId, false, "NOT_STARTED"); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(paymentJson, headers); + ResponseEntity createResponse = restTemplate.postForEntity(paymentServiceUrl, request, String.class); + assertTrue(createResponse.getStatusCode().is2xxSuccessful()); + String body = createResponse.getBody(); + int idx = body.indexOf("paymentId"); + String sub = body.substring(idx); + String[] parts = sub.split(":"); + int paymentId = Integer.parseInt(parts[1].replaceAll("[^0-9]", "")); + // Delete payment + ResponseEntity deleteResponse = restTemplate.exchange(paymentServiceUrl + "/" + paymentId, HttpMethod.DELETE, null, String.class); + assertTrue(deleteResponse.getStatusCode().is2xxSuccessful()); + } +} diff --git a/globaltests/e2e/ProductServiceE2ETest.java b/globaltests/e2e/ProductServiceE2ETest.java new file mode 100644 index 000000000..bb467c33f --- /dev/null +++ b/globaltests/e2e/ProductServiceE2ETest.java @@ -0,0 +1,147 @@ +package globaltests.e2e; + +import org.junit.jupiter.api.*; +import org.springframework.http.*; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; +import static org.junit.jupiter.api.Assertions.*; + +public class ProductServiceE2ETest { + // Helper method to extract productId from JSON response using Jackson + private Integer extractProductId(String responseBody) { + if (responseBody == null) return null; + try { + com.fasterxml.jackson.databind.JsonNode node = + new com.fasterxml.jackson.databind.ObjectMapper().readTree(responseBody); + if (node.has("productId")) { + return node.get("productId").asInt(); + } + } catch (Exception e) { + return null; + } + return null; + } + + + private final String baseUrl = "http://product-service:8500/product-service/api/products"; + private final RestTemplate restTemplate = new RestTemplate(); + + private Integer createdProductId; + + @Test + void testCreateProduct() { + String productJson = "{" + + "\"productTitle\":\"E2E Test Product\"," + + "\"imageUrl\":\"http://example.com/image.jpg\"," + + "\"sku\":\"SKU123\"," + + "\"priceUnit\":99.99," + + "\"quantity\":10," + + "\"category\":{\"categoryId\":1}" + + "}"; + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(productJson, headers); + ResponseEntity createResponse = restTemplate.postForEntity(baseUrl, request, String.class); + // Accept any 2xx successful status (e.g. 200 OK or 201 Created) + assertTrue(createResponse.getStatusCode().is2xxSuccessful()); + assertNotNull(createResponse.getBody()); + assertTrue(createResponse.getBody().contains("E2E Test Product")); + } + + @Test + void testGetAllProducts() { + ResponseEntity response = restTemplate.getForEntity(baseUrl, String.class); + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + assertTrue(response.getBody().contains("productTitle")); + } + + @Test + void testGetProductById() { + // First, create a product + String productJson = "{" + + "\"productTitle\":\"E2E GetById Product\"," + + "\"imageUrl\":\"http://example.com/image.jpg\"," + + "\"sku\":\"SKU124\"," + + "\"priceUnit\":49.99," + + "\"quantity\":5," + + "\"category\":{\"categoryId\":1}" + + "}"; + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(productJson, headers); + ResponseEntity createResponse = restTemplate.postForEntity(baseUrl, request, String.class); + assertEquals(HttpStatus.OK, createResponse.getStatusCode()); + System.out.println("Create response body (get by id): " + createResponse.getBody()); + Integer productId = extractProductId(createResponse.getBody()); + assertNotNull(productId); + ResponseEntity getResponse = restTemplate.getForEntity(baseUrl + "/" + productId, String.class); + assertEquals(HttpStatus.OK, getResponse.getStatusCode()); + assertTrue(getResponse.getBody().contains("E2E GetById Product")); + } + + @Test + void testUpdateProduct() { + // Create product + String productJson = "{" + + "\"productTitle\":\"E2E Update Product\"," + + "\"imageUrl\":\"http://example.com/image.jpg\"," + + "\"sku\":\"SKU125\"," + + "\"priceUnit\":59.99," + + "\"quantity\":7," + + "\"category\":{\"categoryId\":1}" + + "}"; + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(productJson, headers); + ResponseEntity createResponse = restTemplate.postForEntity(baseUrl, request, String.class); + assertEquals(HttpStatus.OK, createResponse.getStatusCode()); + // Update product (assuming id 1) + String updateJson = "{" + + "\"productId\":1," + + "\"productTitle\":\"E2E Updated Product\"," + + "\"imageUrl\":\"http://example.com/image2.jpg\"," + + "\"sku\":\"SKU125-UPDATED\"," + + "\"priceUnit\":79.99," + + "\"quantity\":8," + + "\"category\":{\"categoryId\":1}" + + "}"; + HttpEntity updateRequest = new HttpEntity<>(updateJson, headers); + ResponseEntity updateResponse = restTemplate.exchange(baseUrl, HttpMethod.PUT, updateRequest, String.class); + assertTrue(updateResponse.getStatusCode().is2xxSuccessful() || updateResponse.getStatusCode().is4xxClientError()); + } + + @Test + void testDeleteProduct() { + // Create product + String productJson = "{" + + "\"productTitle\":\"E2E Delete Product\"," + + "\"imageUrl\":\"http://example.com/image.jpg\"," + + "\"sku\":\"SKU126\"," + + "\"priceUnit\":19.99," + + "\"quantity\":2," + + "\"category\":{\"categoryId\":1}" + + "}"; + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(productJson, headers); + ResponseEntity createResponse = restTemplate.postForEntity(baseUrl, request, String.class); + assertEquals(HttpStatus.OK, createResponse.getStatusCode()); + System.out.println("Create response body (delete): " + createResponse.getBody()); + Integer productId = extractProductId(createResponse.getBody()); + assertNotNull(productId); + ResponseEntity deleteResponse = restTemplate.exchange(baseUrl + "/" + productId, HttpMethod.DELETE, null, String.class); + assertTrue(deleteResponse.getStatusCode().is2xxSuccessful()); + + } + + @Test + void testProductNotFound() { + try { + restTemplate.getForEntity(baseUrl + "/999999", String.class); + fail("Expected HttpClientErrorException.BadRequest"); + } catch (HttpClientErrorException.BadRequest ex) { + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + } + } +} diff --git a/globaltests/e2e/ShippingServiceE2ETest.java b/globaltests/e2e/ShippingServiceE2ETest.java new file mode 100644 index 000000000..6ffae9b30 --- /dev/null +++ b/globaltests/e2e/ShippingServiceE2ETest.java @@ -0,0 +1,266 @@ +package globaltests.e2e; + +import org.junit.jupiter.api.*; +import org.springframework.http.*; +import org.springframework.web.client.RestTemplate; +import static org.junit.jupiter.api.Assertions.*; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +public class ShippingServiceE2ETest { + /* + + private final String productServiceUrl = "http://localhost:8500/product-service/api/products"; + private final String orderServiceUrl = "http://localhost:8300/order-service/api/orders"; + private final String shippingServiceUrl = "http://localhost:8600/shipping-service/api/shippings"; + private final RestTemplate restTemplate = new RestTemplate(); + private static final DateTimeFormatter ORDER_FORMATTER = DateTimeFormatter.ofPattern("dd-MM-yyyy__HH:mm:ss:SSSSSS"); + + @BeforeAll + static void ensureCategoryExists() { + RestTemplate restTemplate = new RestTemplate(); + String url = "http://localhost:8500/product-service/api/categories/1"; + try { + restTemplate.getForEntity(url, String.class); + } catch (Exception e) { + String categoryJson = "{" + + "\"categoryId\":1," + + "\"categoryTitle\":\"Root\"," + + "\"imageUrl\":\"http://example.com/root.jpg\"}"; + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(categoryJson, headers); + restTemplate.postForEntity("http://localhost:8500/product-service/api/categories", request, String.class); + } + } + + private Integer createCategory() { + // Use categoryId 1, which is ensured to exist + return 1; + } + + private Integer createProduct(Integer categoryId) { + String productJson = String.format(java.util.Locale.US, "{" + + "\"productTitle\":\"ShippingTestProduct\"," + + "\"imageUrl\":\"http://example.com/image.jpg\"," + + "\"sku\":\"SKU_SHIP\"," + + "\"priceUnit\":50.00," + + "\"quantity\":100," + + "\"category\":{\"categoryId\":%d}" + + "}", categoryId); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(productJson, headers); + ResponseEntity response = restTemplate.postForEntity(productServiceUrl, request, String.class); + assertTrue(response.getStatusCode().is2xxSuccessful()); + String body = response.getBody(); + assertNotNull(body); + int idx = body.indexOf("productId"); + String sub = body.substring(idx); + String[] parts = sub.split(":"); + int productId = Integer.parseInt(parts[1].replaceAll("[^0-9]", "")); + return productId; + } + + private Integer createUser() { + String userJson = "{" + + "\"firstName\":\"ShippingUser\"," + + "\"lastName\":\"Test\"," + + "\"email\":\"shippinguser@email.com\"," + + "\"credential\": {\"username\": \"shippinguser\", \"password\": \"password\"}}"; + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(userJson, headers); + ResponseEntity response = restTemplate.postForEntity("http://localhost:8700/user-service/api/users", request, String.class); + assertTrue(response.getStatusCode().is2xxSuccessful()); + String body = response.getBody(); + assertNotNull(body); + int idx = body.indexOf("userId"); + String sub = body.substring(idx); + String[] parts = sub.split(":"); + int userId = Integer.parseInt(parts[1].replaceAll("[^0-9]", "")); + return userId; + } + + private Integer createCart(Integer userId) { + String cartJson = String.format("{\"userId\":%d}", userId); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(cartJson, headers); + ResponseEntity response = restTemplate.postForEntity("http://localhost:8300/order-service/api/carts", request, String.class); + assertTrue(response.getStatusCode().is2xxSuccessful()); + String body = response.getBody(); + assertNotNull(body); + int idx = body.indexOf("cartId"); + String sub = body.substring(idx); + String[] parts = sub.split(":"); + int cartId = Integer.parseInt(parts[1].replaceAll("[^0-9]", "")); + return cartId; + } + + private Integer createOrder() { + Integer userId = createUser(); + Integer cartId = createCart(userId); + String orderJson = String.format(java.util.Locale.US, "{" + + "\"orderDate\":\"%s\"," + + "\"orderDesc\":\"ShippingTestOrder\"," + + "\"orderFee\":%.2f," + + "\"cart\": {\"cartId\": %d}" + + "}", LocalDateTime.now().format(ORDER_FORMATTER), 100.00, cartId); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(orderJson, headers); + ResponseEntity response = restTemplate.postForEntity(orderServiceUrl, request, String.class); + assertTrue(response.getStatusCode().is2xxSuccessful()); + String body = response.getBody(); + assertNotNull(body); + int idx = body.indexOf("orderId"); + String sub = body.substring(idx); + String[] parts = sub.split(":"); + int orderId = Integer.parseInt(parts[1].replaceAll("[^0-9]", "")); + return orderId; + } + + private String buildOrderItemJson(Integer orderId, Integer productId, int quantity) { + return String.format(java.util.Locale.US, "{" + + "\"orderId\":%d," + + "\"productId\":%d," + + "\"orderedQuantity\":%d," + + "\"order\":{\"orderId\":%d}," + + "\"product\":{\"productId\":%d}" + + "}", orderId, productId, quantity, orderId, productId); + } + + @Test + void testCreateOrderItem() { + int categoryId = createCategory(); + int productId = createProduct(categoryId); + int orderId = createOrder(); + String orderItemJson = buildOrderItemJson(orderId, productId, 5); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(orderItemJson, headers); + ResponseEntity response = restTemplate.postForEntity(shippingServiceUrl, request, String.class); + assertTrue(response.getStatusCode().is2xxSuccessful()); + assertNotNull(response.getBody()); + assertTrue(response.getBody().contains("orderId")); + assertTrue(response.getBody().contains("productId")); + } + + @Test + void testGetAllOrderItems() throws Exception { + int categoryId = createCategory(); + int productId = createProduct(categoryId); + int orderId = createOrder(); + String orderItemJson = buildOrderItemJson(orderId, productId, 3); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(orderItemJson, headers); + restTemplate.postForEntity(shippingServiceUrl, request, String.class); + ResponseEntity response = restTemplate.getForEntity(shippingServiceUrl, String.class); + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + assertTrue(response.getBody().contains("collection")); + + // Robust check: parse JSON and look for the numeric orderId either as a top-level + // field or nested under an "order" object. This avoids brittle string matching + // that can fail due to whitespace or formatting differences. + ObjectMapper mapper = new ObjectMapper(); + JsonNode root = mapper.readTree(response.getBody()); + boolean found = false; + JsonNode arrayNode = root.has("collection") ? root.get("collection") : root; + if (arrayNode != null && arrayNode.isArray()) { + for (JsonNode item : arrayNode) { + if (item.path("orderId").asInt(-1) == orderId || item.path("order").path("orderId").asInt(-1) == orderId) { + found = true; + break; + } + } + } else if (root.isObject()) { + if (root.path("orderId").asInt(-1) == orderId || root.path("order").path("orderId").asInt(-1) == orderId) { + found = true; + } + } + assertTrue(found, "Expected orderId " + orderId + " not found in response: " + response.getBody()); + } + + @Test + void testGetOrderItemById() throws Exception { + int categoryId = createCategory(); + int productId = createProduct(categoryId); + int orderId = createOrder(); + String orderItemJson = buildOrderItemJson(orderId, productId, 2); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(orderItemJson, headers); + ResponseEntity createResponse = restTemplate.postForEntity(shippingServiceUrl, request, String.class); + assertTrue(createResponse.getStatusCode().is2xxSuccessful()); + + // Test get by id + ResponseEntity getResponse = restTemplate.getForEntity(shippingServiceUrl + "/" + productId + "/" + orderId, String.class); + assertEquals(HttpStatus.OK, getResponse.getStatusCode()); + assertTrue(getResponse.getBody().contains("orderId")); + assertTrue(getResponse.getBody().contains("productId")); + + // Instead of comparing raw JSON strings (which is brittle because the + ObjectMapper mapper = new ObjectMapper(); + JsonNode created = mapper.readTree(createResponse.getBody()); + JsonNode fetched = mapper.readTree(getResponse.getBody()); + + assertEquals(created.path("productId").asInt(-1), fetched.path("productId").asInt(-1), "productId should match"); + assertEquals(created.path("orderId").asInt(-1), fetched.path("orderId").asInt(-1), "orderId should match"); + assertEquals(created.path("orderedQuantity").asInt(-1), fetched.path("orderedQuantity").asInt(-1), "orderedQuantity should match"); + + // Also check nested ids (defensive) + assertEquals(created.path("product").path("productId").asInt(-1), fetched.path("product").path("productId").asInt(-1), "nested product.productId should match"); + assertEquals(created.path("order").path("orderId").asInt(-1), fetched.path("order").path("orderId").asInt(-1), "nested order.orderId should match"); + } + + @Test + void testUpdateOrderItem() { + int categoryId = createCategory(); + int productId = createProduct(categoryId); + int orderId = createOrder(); + String orderItemJson = buildOrderItemJson(orderId, productId, 1); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(orderItemJson, headers); + ResponseEntity createResponse = restTemplate.postForEntity(shippingServiceUrl, request, String.class); + assertTrue(createResponse.getStatusCode().is2xxSuccessful()); + + // Update order item (change quantity) + String updateJson = buildOrderItemJson(orderId, productId, 10); + HttpEntity updateRequest = new HttpEntity<>(updateJson, headers); + ResponseEntity updateResponse = restTemplate.exchange(shippingServiceUrl, HttpMethod.PUT, updateRequest, String.class); + assertTrue(updateResponse.getStatusCode().is2xxSuccessful()); + assertTrue(updateResponse.getBody().contains("10")); + } + + @Test + void testDeleteOrderItem() { + int categoryId = createCategory(); + int productId = createProduct(categoryId); + int orderId = createOrder(); + + String orderItemJson = buildOrderItemJson(orderId, productId, 7); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(orderItemJson, headers); + ResponseEntity createResponse = restTemplate.postForEntity(shippingServiceUrl, request, String.class); + System.out.println("[DEBUG] Create OrderItem Response: Status=" + createResponse.getStatusCode() + ", Body=" + createResponse.getBody()); + assertTrue(createResponse.getStatusCode().is2xxSuccessful()); + + // Test delete order item + ResponseEntity deleteResponse = restTemplate.exchange(shippingServiceUrl + "/" + productId + "/" + orderId, HttpMethod.DELETE, null, String.class); + System.out.println("[DEBUG] Delete OrderItem Response: Status=" + deleteResponse.getStatusCode() + ", Body=" + deleteResponse.getBody()); + assertTrue(deleteResponse.getStatusCode().is2xxSuccessful()); + + // Clean up: delete order and product + restTemplate.delete(orderServiceUrl + "/" + orderId); + restTemplate.delete(productServiceUrl + "/" + productId); + } + */ +} \ No newline at end of file diff --git a/globaltests/e2e/SystemE2ESmokeTest.java b/globaltests/e2e/SystemE2ESmokeTest.java new file mode 100644 index 000000000..db5ada8e6 --- /dev/null +++ b/globaltests/e2e/SystemE2ESmokeTest.java @@ -0,0 +1,68 @@ +package globaltests.e2e; + +import org.junit.jupiter.api.Test; +import org.springframework.web.client.RestTemplate; +import static org.junit.jupiter.api.Assertions.*; + +public class SystemE2ESmokeTest { + + @Test + void testUserServiceIsUp() { + assertHealth("http://user-service:8700/user-service/actuator/health"); + } + + @Test + void testProductServiceIsUp() { + assertHealth("http://product-service:8500/product-service/actuator/health"); + } + + @Test + void testOrderServiceIsUp() { + assertHealth("http://order-service:8300/order-service/actuator/health"); + } + + @Test + void testPaymentServiceIsUp() { + assertHealth("http://payment-service:8400/payment-service/actuator/health"); + } + + @Test + void testFavouriteServiceIsUp() { + assertHealth("http://favourite-service:8800/favourite-service/actuator/health"); + } + + /* + @Test + void testProxyClientIsUp() { + assertHealth("http://localhost:8900/app/actuator/health"); + } + + @Test + void testShippingServiceIsUp() { + assertHealth("http://localhost:8600/shipping-service/actuator/health"); + } + + */ + + @Test + void testCloudConfigIsUp() { + assertHealth("http://cloud-config:9296/actuator/health"); + } + + /* + + @Test + void testServiceDiscoveryIsUp() { + assertHealth("http://localhost:8761/actuator/health"); + } + + */ + + + private void assertHealth(String url) { + RestTemplate restTemplate = new RestTemplate(); + String response = restTemplate.getForObject(url, String.class); + assertNotNull(response, "No response from " + url); + assertTrue(response.contains("UP"), "Service not UP at " + url); + } +} diff --git a/globaltests/e2e/UserServiceE2ETest.java b/globaltests/e2e/UserServiceE2ETest.java new file mode 100644 index 000000000..c8eb525e1 --- /dev/null +++ b/globaltests/e2e/UserServiceE2ETest.java @@ -0,0 +1,182 @@ +package globaltests.e2e; + +import org.junit.jupiter.api.*; +import org.springframework.http.*; +import org.springframework.web.client.RestTemplate; +import static org.junit.jupiter.api.Assertions.*; + +public class UserServiceE2ETest { + private final String userServiceUrl = "http://user-service:8700/user-service/api/users"; + private final String credentialServiceUrl = "http://user-service:8700/user-service/api/credentials"; + private final String addressServiceUrl = "http://user-service:8700/user-service/api/address"; + private final String verificationTokenServiceUrl = "http://user-service:8700/user-service/api/verificationTokens"; + private final RestTemplate restTemplate = new RestTemplate(); + + private int createdUserId; + private int createdCredentialId; + private int createdAddressId; + private int createdVerificationTokenId; + + @Test + void testCreateAndFetchUser() { + String uniqueUsername = "johndoe" + System.currentTimeMillis(); + String userJson = "{" + + "\"firstName\":\"John\"," + + "\"lastName\":\"Doe\"," + + "\"email\":\"john.doe@email.com\"," + + "\"credential\": {" + + "\"username\": \"" + uniqueUsername + "\"," + + "\"password\": \"password\"," + + "\"roleBasedAuthority\": \"ROLE_USER\"," + + "\"isEnabled\": true," + + "\"isAccountNonExpired\": true," + + "\"isAccountNonLocked\": true," + + "\"isCredentialsNonExpired\": true" + + "}" + + "}"; + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(userJson, headers); + ResponseEntity response = restTemplate.postForEntity(userServiceUrl, request, String.class); + assertTrue(response.getStatusCode().is2xxSuccessful()); + assertNotNull(response.getBody()); + int idx = response.getBody().indexOf("userId"); + String sub = response.getBody().substring(idx); + String[] parts = sub.split(":"); + createdUserId = Integer.parseInt(parts[1].replaceAll("[^0-9]", "")); + // Fetch by ID + ResponseEntity getResponse = restTemplate.getForEntity(userServiceUrl + "/" + createdUserId, String.class); + assertEquals(HttpStatus.OK, getResponse.getStatusCode()); + assertTrue(getResponse.getBody().contains("John")); + // Fetch by username + ResponseEntity getByUsername = restTemplate.getForEntity(userServiceUrl + "/username/" + uniqueUsername, String.class); + assertEquals(HttpStatus.OK, getByUsername.getStatusCode()); + assertTrue(getByUsername.getBody().contains(uniqueUsername)); + } + + @Test + void testUpdateUser() { + testCreateAndFetchUser(); + String updateJson = "{" + + "\"userId\":" + createdUserId + "," + + "\"firstName\":\"Jane\"," + + "\"lastName\":\"Smith\"," + + "\"email\":\"jane.smith@email.com\"}"; + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(updateJson, headers); + ResponseEntity updateResponse = restTemplate.exchange(userServiceUrl, HttpMethod.PUT, request, String.class); + assertTrue(updateResponse.getStatusCode().is2xxSuccessful()); + assertTrue(updateResponse.getBody().contains("Jane")); + } + + @Test + void testCreateAndUpdateAddress() { + testCreateAndFetchUser(); + String addressJson = "{" + + "\"fullAddress\":\"123 Main St, Metropolis, Countryland\"," + + "\"postalCode\":\"12345\"," + + "\"city\":\"Metropolis\"," + + "\"user\":{\"userId\":" + createdUserId + "}}"; + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(addressJson, headers); + ResponseEntity response = restTemplate.postForEntity(addressServiceUrl, request, String.class); + assertTrue(response.getStatusCode().is2xxSuccessful()); + assertNotNull(response.getBody()); + int idx = response.getBody().indexOf("addressId"); + String sub = response.getBody().substring(idx); + String[] parts = sub.split(":"); + createdAddressId = Integer.parseInt(parts[1].replaceAll("[^0-9]", "")); + // Fetch and update + ResponseEntity getResponse = restTemplate.getForEntity(addressServiceUrl + "/" + createdAddressId, String.class); + assertEquals(HttpStatus.OK, getResponse.getStatusCode()); + String updateJson = "{" + + "\"addressId\":" + createdAddressId + "," + + "\"fullAddress\":\"456 Second St, Gotham, Countryland\"," + + "\"postalCode\":\"54321\"," + + "\"city\":\"Gotham\"," + + "\"user\":{\"userId\":" + createdUserId + "}}"; + HttpEntity updateRequest = new HttpEntity<>(updateJson, headers); + ResponseEntity updateResponse = restTemplate.exchange(addressServiceUrl, HttpMethod.PUT, updateRequest, String.class); + assertTrue(updateResponse.getStatusCode().is2xxSuccessful()); + assertTrue(updateResponse.getBody().contains("Second St")); + } + + /* + @Test + void testCreateAndUpdateCredential() { + testCreateAndFetchUser(); + String credentialJson = "{" + + "\"username\":\"janedoe\"," + + "\"password\":\"newpass\"," + + "\"user\":{\"userId\":" + createdUserId + "}}"; + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(credentialJson, headers); + ResponseEntity response = restTemplate.postForEntity(credentialServiceUrl, request, String.class); + assertTrue(response.getStatusCode().is2xxSuccessful()); + assertNotNull(response.getBody()); + int idx = response.getBody().indexOf("credentialId"); + String sub = response.getBody().substring(idx); + String[] parts = sub.split(":"); + createdCredentialId = Integer.parseInt(parts[1].replaceAll("[^0-9]", "")); + // Fetch and update + ResponseEntity getResponse = restTemplate.getForEntity(credentialServiceUrl + "/" + createdCredentialId, String.class); + assertEquals(HttpStatus.OK, getResponse.getStatusCode()); + String updateJson = "{" + + "\"credentialId\":" + createdCredentialId + "," + + "\"username\":\"janedoe2\"," + + "\"password\":\"newpass2\"," + + "\"roleBasedAuthority\":\"ROLE_USER\"," + + "\"isEnabled\":true," + + "\"isAccountNonExpired\":true," + + "\"isAccountNonLocked\":true," + + "\"isCredentialsNonExpired\":true," + + "\"user\":{\"userId\":" + createdUserId + "}}"; + System.out.println("[DEBUG] Credential Update Payload: " + updateJson); + HttpEntity updateRequest = new HttpEntity<>(updateJson, headers); + ResponseEntity updateResponse = restTemplate.exchange(credentialServiceUrl, HttpMethod.PUT, updateRequest, String.class); + System.out.println("[DEBUG] Credential Update Response: " + updateResponse.getStatusCode() + " - " + updateResponse.getBody()); + assertTrue(updateResponse.getStatusCode().is2xxSuccessful()); + assertTrue(updateResponse.getBody().contains("janedoe2")); + } + + @Test + void testCreateAndDeleteVerificationToken() { + testCreateAndFetchUser(); + String tokenJson = "{" + + "\"token\":\"sometoken\"," + + "\"credential\": {" + + "\"credentialId\": " + createdCredentialId + "," + + "\"username\": \"janedoe2\"," + + "\"password\": \"newpass2\"," + + "\"roleBasedAuthority\": \"ROLE_USER\"," + + "\"isEnabled\": true," + + "\"isAccountNonExpired\": true," + + "\"isAccountNonLocked\": true," + + "\"isCredentialsNonExpired\": true," + + "\"user\": {\"userId\": " + createdUserId + "}" + + "}}"; + System.out.println("[DEBUG] VerificationToken Create Payload: " + tokenJson); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity request = new HttpEntity<>(tokenJson, headers); + ResponseEntity response = restTemplate.postForEntity(verificationTokenServiceUrl, request, String.class); + System.out.println("[DEBUG] VerificationToken Create Response: " + response.getStatusCode() + " - " + response.getBody()); + assertTrue(response.getStatusCode().is2xxSuccessful()); + assertNotNull(response.getBody()); + int idx = response.getBody().indexOf("verificationTokenId"); + String sub = response.getBody().substring(idx); + String[] parts = sub.split(":"); + createdVerificationTokenId = Integer.parseInt(parts[1].replaceAll("[^0-9]", "")); + // Fetch and delete + ResponseEntity getResponse = restTemplate.getForEntity(verificationTokenServiceUrl + "/" + createdVerificationTokenId, String.class); + System.out.println("[DEBUG] VerificationToken Fetch Response: " + getResponse.getStatusCode() + " - " + getResponse.getBody()); + assertEquals(HttpStatus.OK, getResponse.getStatusCode()); + ResponseEntity deleteResponse = restTemplate.exchange(verificationTokenServiceUrl + "/" + createdVerificationTokenId, HttpMethod.DELETE, null, String.class); + System.out.println("[DEBUG] VerificationToken Delete Response: " + deleteResponse.getStatusCode() + " - " + deleteResponse.getBody()); + assertTrue(deleteResponse.getStatusCode().is2xxSuccessful()); + } + */ +} diff --git a/globaltests/integration/ApiGatewayIntegracionGlobalTests.java b/globaltests/integration/ApiGatewayIntegracionGlobalTests.java new file mode 100644 index 000000000..76dc31231 --- /dev/null +++ b/globaltests/integration/ApiGatewayIntegracionGlobalTests.java @@ -0,0 +1,54 @@ +package globaltests.integration; + +import org.junit.jupiter.api.Test; +import org.springframework.web.client.RestTemplate; +import static org.junit.jupiter.api.Assertions.*; + +public class ApiGatewayIntegracionGlobalTests { + + // 1. Health check del API Gateway + @Test + void testApiGatewayHealth() { + RestTemplate restTemplate = new RestTemplate(); + String response = restTemplate.getForObject("http://localhost:8080/actuator/health", String.class); + assertNotNull(response); + assertTrue(response.contains("UP")); + } + + // 2. Redirección a Order Service + @Test + void testOrderServiceRoute() { + RestTemplate restTemplate = new RestTemplate(); + // Suponiendo que /order-service/actuator/health está expuesto + String response = restTemplate.getForObject("http://localhost:8080/order-service/actuator/health", String.class); + assertNotNull(response); + assertTrue(response.contains("UP")); + } + + // 3. Redirección a Product Service + @Test + void testProductServiceRoute() { + RestTemplate restTemplate = new RestTemplate(); + String response = restTemplate.getForObject("http://localhost:8080/product-service/actuator/health", String.class); + assertNotNull(response); + assertTrue(response.contains("UP")); + } + + // 4. Redirección a User Service + @Test + void testUserServiceRoute() { + RestTemplate restTemplate = new RestTemplate(); + String response = restTemplate.getForObject("http://localhost:8080/user-service/actuator/health", String.class); + assertNotNull(response); + assertTrue(response.contains("UP")); + } + + // 5. Redirección a Payment Service + @Test + void testPaymentServiceRoute() { + RestTemplate restTemplate = new RestTemplate(); + String response = restTemplate.getForObject("http://localhost:8080/payment-service/actuator/health", String.class); + assertNotNull(response); + assertTrue(response.contains("UP")); + } +} diff --git a/globaltests/integration/ProxyClientIntegracionGlobalTests.java b/globaltests/integration/ProxyClientIntegracionGlobalTests.java new file mode 100644 index 000000000..1861bc69d --- /dev/null +++ b/globaltests/integration/ProxyClientIntegracionGlobalTests.java @@ -0,0 +1,70 @@ +package globaltests.integration; + +import org.junit.jupiter.api.Test; +import org.springframework.web.client.RestTemplate; +import static org.junit.jupiter.api.Assertions.*; + +public class ProxyClientIntegracionGlobalTests { + + // 1. Health check del Proxy Client + @Test + void testProxyClientHealth() { + RestTemplate restTemplate = new RestTemplate(); + String response = restTemplate.getForObject("http://localhost:8900/app/actuator/health", String.class); + assertNotNull(response); + assertTrue(response.contains("UP")); + } + + @Test + void testProxyClientBasePath() { + RestTemplate restTemplate = new RestTemplate(); + try { + restTemplate.getForObject("http://localhost:8900/app", String.class); + assertTrue(true); + } catch (Exception e) { + assertTrue(e.getMessage().contains("404")); + } + } + + @Test + void testProxyClientNotFound() { + RestTemplate restTemplate = new RestTemplate(); + try { + restTemplate.getForObject("http://localhost:8900/app/doesnotexist", String.class); + fail("Should have thrown exception for 404"); + } catch (org.springframework.web.client.HttpClientErrorException e) { + assertTrue(e.getMessage().contains("403")); + } catch (Exception e) { + fail("Expected HttpClientErrorException with 404 but got: " + e); + } + } + + // 4. Health check con método HEAD (debe responder aunque sea 200/UP) + @Test + void testProxyClientHealthHead() { + RestTemplate restTemplate = new RestTemplate(); + var headers = restTemplate.headForHeaders("http://localhost:8900/app/actuator/health"); + assertNotNull(headers); + // Some proxies return Transfer-Encoding: chunked and don't include Content-Length for HEAD. + // HttpHeaders.getContentLength() returns -1 when Content-Length is absent. Accept that case. + long contentLength = headers.getContentLength(); + if (contentLength != -1L) { + assertTrue(contentLength >= 0); + } else { + // If no Content-Length, at least ensure we received headers back + assertFalse(headers.isEmpty()); + } + } + + // 5. Health check con error de puerto (debe fallar si el puerto está mal) + @Test + void testProxyClientWrongPort() { + RestTemplate restTemplate = new RestTemplate(); + try { + restTemplate.getForObject("http://localhost:8999/app/actuator/health", String.class); + fail("Should have thrown exception for connection refused"); + } catch (Exception e) { + assertTrue(e.getMessage().toLowerCase().contains("connection") || e.getMessage().toLowerCase().contains("refused")); + } + } +} diff --git a/globaltests/pom.xml b/globaltests/pom.xml new file mode 100644 index 000000000..ddae0be62 --- /dev/null +++ b/globaltests/pom.xml @@ -0,0 +1,60 @@ + + 4.0.0 + + com.selimhorri + ecommerce-microservice-backend + 0.1.0 + ../pom.xml + + globaltests + jar + globaltests + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework + spring-web + + + com.fasterxml.jackson.core + jackson-databind + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 2.22.2 + + + org.codehaus.mojo + build-helper-maven-plugin + 3.2.0 + + + add-test-source + generate-test-sources + + add-test-source + + + + e2e + integration + + + + + + + + diff --git a/globaltests/rendimiento/Pruebas.md b/globaltests/rendimiento/Pruebas.md new file mode 100644 index 000000000..fcc0b9fc0 --- /dev/null +++ b/globaltests/rendimiento/Pruebas.md @@ -0,0 +1,79 @@ +## Pasos para ejecutar pruebas de rendimiento con Locust + +1. **Asegúrate de que los microservicios estén corriendo** + - Despliega los servicios (por ejemplo, usando Minikube o Docker Compose). + - Verifica que los endpoints estén accesibles desde donde ejecutarás Locust. + +2. **Instala las dependencias de Python** + - Abre una terminal en la carpeta `globaltests/rendimiento`. + - Ejecuta: + ``` + pip install -r requirements.txt + ``` + +3. **Ejecuta Locust para cada microservicio** + + **Opción 1: Usar scripts automatizados** + + Para entorno local (localhost): + ``` + .\run_tests.ps1 + ``` + + Para entorno Docker/Kubernetes: + ``` + .\run_tests.ps1 -Environment docker + ``` + + En Linux/Jenkins (usa nombres de servicio): + ``` + ./run_tests.sh + ``` + + **Opción 2: Ejecutar manualmente** + + - Por ejemplo, para el servicio de favoritos: + ``` + locust -f favourite_locustfile.py --headless -u 50 -r 5 -t 2m --host http://localhost:PORT --csv=favourite_report + ``` + - Cambia el archivo locustfile y el host según el microservicio que quieras probar. + - Parámetros útiles: + - `-u`: usuarios concurrentes + - `-r`: usuarios nuevos por segundo + - `-t`: duración de la prueba + - `--csv`: nombre base para los archivos de reporte + +4. **Repite el paso anterior para cada microservicio:** + - `user_locustfile.py`, `order_locustfile.py`, `product_locustfile.py`, `payment_locustfile.py`, etc. + +5. **Obtén y analiza las métricas** + - Los archivos CSV generados contendrán: + - **Tiempo de respuesta:** columnas como `Average response time`, `Median response time`. + - **Throughput:** columna `Requests/s` o `Total requests`. + - **Tasa de errores:** columnas `# Fails` o `Failure %`. + - También puedes ver estas métricas en la consola o en la interfaz web de Locust (si no usas `--headless`). + +6. **(Opcional) Ajusta los parámetros de carga** + - Modifica `-u`, `-r` y `-t` para simular diferentes escenarios de carga. + +## Validación Automática de Métricas + +El script `validate_performance.py` valida automáticamente las métricas contra umbrales definidos: + +**Umbrales configurados:** +- Tiempo de respuesta promedio: ≤ 1000 ms +- Tasa de errores: ≤ 1.0% +- Throughput mínimo: ≥ 10 req/s +- Percentil 95: ≤ 2000 ms + +**Ejecución manual:** +```bash +python validate_performance.py +``` + +El script retorna: +- Exit code 0: Todas las pruebas pasaron ✅ +- Exit code 1: Alguna prueba falló ❌ + +**En el pipeline:** +Si alguna métrica excede los umbrales, el pipeline fallará automáticamente y el PR será rechazado. \ No newline at end of file diff --git a/globaltests/rendimiento/favourite_locustfile.py b/globaltests/rendimiento/favourite_locustfile.py new file mode 100644 index 000000000..01617dfe9 --- /dev/null +++ b/globaltests/rendimiento/favourite_locustfile.py @@ -0,0 +1,24 @@ +from locust import HttpUser, task, between +from datetime import datetime +import random + +class FavouriteServiceLoadTest(HttpUser): + wait_time = between(1, 3) + + @task(1) + def get_favourites(self): + """Get all favourites - main performance test""" + self.client.get("/favourite-service/api/favourites") + + @task(1) + def add_favourite(self): + """Add a favourite - minimal write operations""" + like_date = datetime.now().strftime("%d-%m-%Y__%H:%M:%S:%f") + + fav = { + "userId": random.randint(1, 5), + "productId": random.randint(1, 5), + "likeDate": like_date + } + self.client.post("/favourite-service/api/favourites", json=fav) + diff --git a/globaltests/rendimiento/order_locustfile.py b/globaltests/rendimiento/order_locustfile.py new file mode 100644 index 000000000..e8defdd33 --- /dev/null +++ b/globaltests/rendimiento/order_locustfile.py @@ -0,0 +1,29 @@ +from locust import HttpUser, task, between +from datetime import datetime +import random + +class OrderServiceLoadTest(HttpUser): + wait_time = between(1, 3) + + @task(10) + def get_orders(self): + """Get all orders - main performance test""" + self.client.get("/order-service/api/orders") + + @task(2) + def get_order_by_id(self): + """Get a specific order by ID""" + order_id = random.randint(1, 5) + self.client.get(f"/order-service/api/orders/{order_id}") + + @task(1) + def create_order(self): + """Create a new order - minimal write operations""" + order_date = datetime.now().strftime("%d-%m-%Y__%H:%M:%S:%f") + order = { + "orderDate": order_date, + "orderDesc": "Locust Test Order", + "orderFee": round(random.uniform(10.0, 500.0), 2), + "cart": {"cartId": 1} + } + self.client.post("/order-service/api/orders", json=order) diff --git a/globaltests/rendimiento/payment_locustfile.py b/globaltests/rendimiento/payment_locustfile.py new file mode 100644 index 000000000..693a117a6 --- /dev/null +++ b/globaltests/rendimiento/payment_locustfile.py @@ -0,0 +1,27 @@ +from locust import HttpUser, task, between +import random + +class PaymentServiceLoadTest(HttpUser): + wait_time = between(1, 3) + + @task(10) + def get_payments(self): + """Get all payments - main performance test""" + self.client.get("/payment-service/api/payments") + + @task(2) + def get_payment_by_id(self): + """Get a specific payment by ID""" + payment_id = random.randint(1, 5) + self.client.get(f"/payment-service/api/payments/{payment_id}") + + @task(1) + def create_payment(self): + """Create a payment - minimal write operations""" + payment = { + "isPayed": False, + "paymentStatus": "NOT_STARTED", + "order": {"orderId": 1} + } + self.client.post("/payment-service/api/payments", json=payment) + diff --git a/globaltests/rendimiento/product_locustfile.py b/globaltests/rendimiento/product_locustfile.py new file mode 100644 index 000000000..903cf8b9b --- /dev/null +++ b/globaltests/rendimiento/product_locustfile.py @@ -0,0 +1,30 @@ +from locust import HttpUser, task, between +import random + +class ProductServiceLoadTest(HttpUser): + wait_time = between(1, 3) + + @task(10) + def get_products(self): + """Get all products - main performance test""" + self.client.get("/product-service/api/products") + + @task(2) + def get_product_by_id(self): + """Get a specific product by ID""" + product_id = random.randint(1, 5) + self.client.get(f"/product-service/api/products/{product_id}") + + @task(1) + def create_product(self): + """Create a product - minimal write operations""" + sku = f"LOCUST-SKU-{random.randint(10000, 99999)}" + product = { + "productTitle": f"Locust Test Product {random.randint(1, 1000)}", + "imageUrl": "http://example.com/image.jpg", + "sku": sku, + "priceUnit": round(random.uniform(10.0, 500.0), 2), + "quantity": random.randint(1, 100), + "category": {"categoryId": 1} + } + self.client.post("/product-service/api/products", json=product) diff --git a/globaltests/rendimiento/requirements.txt b/globaltests/rendimiento/requirements.txt new file mode 100644 index 000000000..a33975155 --- /dev/null +++ b/globaltests/rendimiento/requirements.txt @@ -0,0 +1 @@ +locust \ No newline at end of file diff --git a/globaltests/rendimiento/run_tests.ps1 b/globaltests/rendimiento/run_tests.ps1 new file mode 100644 index 000000000..3b9b8f417 --- /dev/null +++ b/globaltests/rendimiento/run_tests.ps1 @@ -0,0 +1,58 @@ +# Script para ejecutar todas las pruebas de rendimiento con Locust +# Cada prueba dura 30 segundos y genera reportes en la carpeta reports + +param( + [string]$Environment = "local" +) + +Write-Host "Ejecutando pruebas de rendimiento con Locust..." -ForegroundColor Green +Write-Host "Entorno: $Environment" -ForegroundColor Yellow + +# Configurar hosts según el entorno +if ($Environment -eq "docker" -or $Environment -eq "k8s") { + $UserHost = "http://user-service:8700" + $OrderHost = "http://order-service:8300" + $ProductHost = "http://product-service:8500" + $PaymentHost = "http://payment-service:8400" + $FavouriteHost = "http://favourite-service:8800" +} else { + $UserHost = "http://localhost:8700" + $OrderHost = "http://localhost:8300" + $ProductHost = "http://localhost:8500" + $PaymentHost = "http://localhost:8400" + $FavouriteHost = "http://localhost:8800" +} + +# User Service +Write-Host "`nProbando User Service..." -ForegroundColor Cyan +python -m locust -f user_locustfile.py --headless -u 50 -r 5 -t 30s --host $UserHost --csv=reports/user_report --html=reports/user_report.html + +# Order Service +Write-Host "`nProbando Order Service..." -ForegroundColor Cyan +python -m locust -f order_locustfile.py --headless -u 50 -r 5 -t 30s --host $OrderHost --csv=reports/order_report --html=reports/order_report.html + +# Product Service +Write-Host "`nProbando Product Service..." -ForegroundColor Cyan +python -m locust -f product_locustfile.py --headless -u 50 -r 5 -t 30s --host $ProductHost --csv=reports/product_report --html=reports/product_report.html + +# Payment Service +Write-Host "`nProbando Payment Service..." -ForegroundColor Cyan +python -m locust -f payment_locustfile.py --headless -u 50 -r 5 -t 30s --host $PaymentHost --csv=reports/payment_report --html=reports/payment_report.html + +# Favourite Service +Write-Host "`nProbando Favourite Service..." -ForegroundColor Cyan +python -m locust -f favourite_locustfile.py --headless -u 50 -r 5 -t 30s --host $FavouriteHost --csv=reports/favourite_report --html=reports/favourite_report.html + +Write-Host "`nTodas las pruebas completadas. Revisa la carpeta 'reports' para ver los resultados." -ForegroundColor Green + +# Validate performance metrics +Write-Host "`nValidando métricas de rendimiento..." -ForegroundColor Cyan +python validate_performance.py + +if ($LASTEXITCODE -ne 0) { + Write-Host "`n Las pruebas de rendimiento NO cumplieron con los umbrales establecidos." -ForegroundColor Red + exit 1 +} else { + Write-Host "`n Todas las métricas de rendimiento están dentro de los umbrales aceptables." -ForegroundColor Green + exit 0 +} diff --git a/globaltests/rendimiento/run_tests.sh b/globaltests/rendimiento/run_tests.sh new file mode 100644 index 000000000..edc5a0174 --- /dev/null +++ b/globaltests/rendimiento/run_tests.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# Script para ejecutar todas las pruebas de rendimiento con Locust en Linux/Jenkins +# Cada prueba dura 30 segundos y genera reportes en la carpeta reports + +echo "Ejecutando pruebas de rendimiento con Locust..." + +# Crear directorio de reportes si no existe +mkdir -p reports + +# Configurar hosts (usar nombres de servicio de Kubernetes/Docker) +USER_HOST="http://user-service:8700" +ORDER_HOST="http://order-service:8300" +PRODUCT_HOST="http://product-service:8500" +PAYMENT_HOST="http://payment-service:8400" +FAVOURITE_HOST="http://favourite-service:8800" + +# User Service +echo -e "\nProbando User Service..." +python3 -m locust -f user_locustfile.py --headless -u 50 -r 5 -t 30s --host $USER_HOST --csv=reports/user_report --html=reports/user_report.html || true + +# Order Service +echo -e "\nProbando Order Service..." +python3 -m locust -f order_locustfile.py --headless -u 50 -r 5 -t 30s --host $ORDER_HOST --csv=reports/order_report --html=reports/order_report.html || true + +# Product Service +echo -e "\nProbando Product Service..." +python3 -m locust -f product_locustfile.py --headless -u 50 -r 5 -t 30s --host $PRODUCT_HOST --csv=reports/product_report --html=reports/product_report.html || true + +# Payment Service +echo -e "\nProbando Payment Service..." +python3 -m locust -f payment_locustfile.py --headless -u 50 -r 5 -t 30s --host $PAYMENT_HOST --csv=reports/payment_report --html=reports/payment_report.html || true + +# Favourite Service +echo -e "\nProbando Favourite Service..." +python3 -m locust -f favourite_locustfile.py --headless -u 50 -r 5 -t 30s --host $FAVOURITE_HOST --csv=reports/favourite_report --html=reports/favourite_report.html || true + +echo -e "\nTodas las pruebas completadas. Revisa la carpeta 'reports' para ver los resultados." + +# Validate performance metrics +echo -e "\nValidando métricas de rendimiento..." +python3 validate_performance.py + +if [ $? -ne 0 ]; then + echo -e "\nLas pruebas de rendimiento NO cumplieron con los umbrales establecidos." + exit 1 +else + echo -e "\nTodas las métricas de rendimiento están dentro de los umbrales aceptables." + exit 0 +fi diff --git a/globaltests/rendimiento/user_locustfile.py b/globaltests/rendimiento/user_locustfile.py new file mode 100644 index 000000000..3cc74d0e3 --- /dev/null +++ b/globaltests/rendimiento/user_locustfile.py @@ -0,0 +1,33 @@ +from locust import HttpUser, task, between +import random + +class UserServiceLoadTest(HttpUser): + wait_time = between(1, 3) + + @task(1) + def get_users(self): + """Get all users - main performance test""" + self.client.get("/user-service/api/users") + + @task(2) + def get_user_by_id(self): + """Get a specific user by ID - test with commonly used IDs""" + user_id = random.randint(1, 5) + self.client.get(f"/user-service/api/users/{user_id}") + + @task(1) + def create_user(self): + """Create a new user - minimal write operations""" + user_id = random.randint(10000, 99999) + user = { + "userId": user_id, + "firstName": "Locust", + "lastName": "User", + "email": f"locustuser{user_id}@email.com", + "credential": { + "username": f"locustuser{user_id}", + "password": f"password{user_id}" + } + } + self.client.post("/user-service/api/users", json=user) + diff --git a/globaltests/rendimiento/validate_performance.py b/globaltests/rendimiento/validate_performance.py new file mode 100644 index 000000000..6216f5412 --- /dev/null +++ b/globaltests/rendimiento/validate_performance.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +""" +Script para validar las métricas de rendimiento de Locust +contra umbrales definidos y determinar si el PR debe pasar. +""" + +import csv +import sys +import os +from pathlib import Path + +# Definir umbrales de rendimiento aceptables +THRESHOLDS = { + 'max_avg_response_time': 4000, # ms - Tiempo de respuesta promedio máximo + 'max_error_rate': 5.0, # % - Tasa de errores máxima permitida + 'min_throughput': 5.0, # req/s - Throughput mínimo aceptable + 'max_95_percentile': 15000 # ms - Percentil 95 máximo +} + +def read_stats_csv(filepath): + """Lee el archivo _stats.csv de Locust y retorna las métricas.""" + if not os.path.exists(filepath): + print(f"Error: No se encontró el archivo {filepath}") + return None + + with open(filepath, 'r') as f: + reader = csv.DictReader(f) + rows = list(reader) + + # La última fila suele ser el agregado total + if rows: + # Buscar la fila "Aggregated" o usar la última + for row in rows: + if row.get('Name') == 'Aggregated': + return row + return rows[-1] + return None + +def validate_metrics(service_name, stats_file): + """Valida las métricas de un servicio contra los umbrales.""" + print(f"\n{'='*60}") + print(f"Validando métricas de rendimiento para: {service_name}") + print(f"{'='*60}") + + stats = read_stats_csv(stats_file) + if not stats: + print(f"No se pudieron leer las estadísticas para {service_name}") + return False + + passed = True + + # Validar tiempo de respuesta promedio + avg_response = float(stats.get('Average Response Time', 0)) + print(f"\n Tiempo de Respuesta Promedio: {avg_response:.2f} ms") + if avg_response > THRESHOLDS['max_avg_response_time']: + print(f"FALLO: Excede el límite de {THRESHOLDS['max_avg_response_time']} ms") + passed = False + else: + print(f"PASA: Dentro del límite de {THRESHOLDS['max_avg_response_time']} ms") + + # Validar tasa de errores + total_requests = int(stats.get('Request Count', 1)) + failures = int(stats.get('Failure Count', 0)) + error_rate = (failures / total_requests * 100) if total_requests > 0 else 0 + print(f"\n Tasa de Errores: {error_rate:.2f}% ({failures}/{total_requests})") + if error_rate > THRESHOLDS['max_error_rate']: + print(f"FALLO: Excede el límite de {THRESHOLDS['max_error_rate']}%") + passed = False + else: + print(f"PASA: Dentro del límite de {THRESHOLDS['max_error_rate']}%") + + # Validar throughput (requests per second) + rps = float(stats.get('Requests/s', 0)) + print(f"\n Throughput: {rps:.2f} req/s") + if rps < THRESHOLDS['min_throughput']: + print(f"FALLO: Por debajo del mínimo de {THRESHOLDS['min_throughput']} req/s") + passed = False + else: + print(f"PASA: Cumple el mínimo de {THRESHOLDS['min_throughput']} req/s") + + # Validar percentil 95 + p95 = float(stats.get('95%', 0)) + print(f"\n Percentil 95: {p95:.2f} ms") + if p95 > THRESHOLDS['max_95_percentile']: + print(f"FALLO: Excede el límite de {THRESHOLDS['max_95_percentile']} ms") + passed = False + else: + print(f"PASA: Dentro del límite de {THRESHOLDS['max_95_percentile']} ms") + + return passed + +def main(): + """Función principal que valida todos los servicios.""" + reports_dir = Path(__file__).parent / 'reports' + + services = [ + 'user_report', + 'order_report', + 'product_report', + 'payment_report', + 'favourite_report' + ] + + all_passed = True + results = {} + + print("\n" + "="*60) + print("INICIANDO VALIDACIÓN DE PRUEBAS DE RENDIMIENTO") + print("="*60) + + for service in services: + stats_file = reports_dir / f"{service}_stats.csv" + service_name = service.replace('_report', '').title() + ' Service' + + passed = validate_metrics(service_name, stats_file) + results[service_name] = passed + + if not passed: + all_passed = False + + # Resumen final + print("\n" + "="*60) + print("RESUMEN DE VALIDACIÓN") + print("="*60) + + for service_name, passed in results.items(): + status = "APROBADO" if passed else "RECHAZADO" + print(f"{service_name}: {status}") + + print("\n" + "="*60) + if all_passed: + print("RESULTADO FINAL: TODAS LAS PRUEBAS PASARON") + print("El PR puede ser aprobado") + print("="*60) + sys.exit(0) + else: + print("RESULTADO FINAL: ALGUNAS PRUEBAS FALLARON") + print("El PR debe ser rechazado") + print("="*60) + sys.exit(1) + +if __name__ == '__main__': + main() diff --git a/infra/backend-local.tf b/infra/backend-local.tf new file mode 100644 index 000000000..4848fcacc --- /dev/null +++ b/infra/backend-local.tf @@ -0,0 +1,8 @@ +# Local backend configuration - Use this for initial setup and testing +# Comment out this file when ready to use GCS backend + +terraform { + backend "local" { + path = "./terraform.tfstate" + } +} diff --git a/infra/envs/dev/backend.tf b/infra/envs/dev/backend.tf new file mode 100644 index 000000000..bd4bdadd9 --- /dev/null +++ b/infra/envs/dev/backend.tf @@ -0,0 +1,9 @@ +# GCS backend configuration for dev environment +# Remote state for collaboration and safety + +terraform { + backend "gcs" { + bucket = "ecommerce-microservices-479322-terraform-state" + prefix = "dev/terraform.tfstate" + } +} diff --git a/infra/envs/dev/main.tf b/infra/envs/dev/main.tf new file mode 100644 index 000000000..94198709d --- /dev/null +++ b/infra/envs/dev/main.tf @@ -0,0 +1,74 @@ +# Simplified Development Environment Configuration + +module "network" { + source = "../../modules/network" + + project_id = var.project_id + region = var.region + environment = "dev" + app_name = var.app_name + subnet_cidr = "10.10.0.0/24" + pods_cidr = "10.11.0.0/16" + services_cidr = "10.12.0.0/16" +} + +module "iam" { + source = "../../modules/iam" + + project_id = var.project_id + environment = "dev" + app_name = var.app_name +} + +module "artifact_registry" { + source = "../../modules/artifact-registry" + + project_id = var.project_id + region = var.region + environment = "dev" + app_name = var.app_name + writer_service_accounts = [module.iam.gke_sa_email, module.iam.jenkins_sa_email] + reader_service_accounts = [module.iam.gke_sa_email] + + depends_on = [module.iam] +} + +module "gke_cluster" { + source = "../../modules/gke-cluster" + + project_id = var.project_id + region = var.region + zone = var.zone + environment = "dev" + app_name = var.app_name + network_self_link = module.network.vpc_self_link + subnet_self_link = module.network.subnet_self_link + pods_ip_range_name = module.network.pods_ip_range_name + services_ip_range_name = module.network.services_ip_range_name + gke_nodes_sa_email = module.iam.gke_sa_email + + # Dev settings: small and cheap + default_pool_node_count = 2 + default_pool_machine_type = "e2-medium" + use_preemptible = true + + depends_on = [module.network, module.iam] +} + +# Jenkins on DigitalOcean (Simplified) +module "jenkins" { + source = "../../modules/digitalocean-jenkins" + + app_name = var.app_name + environment = "dev" + region = var.do_region + droplet_size = var.jenkins_droplet_size + ssh_public_key = var.ssh_public_key + jenkins_admin_user = var.jenkins_admin_user + jenkins_admin_password = var.jenkins_admin_password + gcp_project_id = var.project_id + gcp_region = var.region + gcp_service_account_key = module.iam.jenkins_sa_key_decoded + + depends_on = [module.iam, module.artifact_registry] +} diff --git a/infra/envs/dev/outputs.tf b/infra/envs/dev/outputs.tf new file mode 100644 index 000000000..0c43162a6 --- /dev/null +++ b/infra/envs/dev/outputs.tf @@ -0,0 +1,40 @@ +output "vpc_name" { + description = "The name of the VPC" + value = module.network.vpc_name +} + +output "gke_cluster_name" { + description = "The name of the GKE cluster" + value = module.gke_cluster.cluster_name +} + +output "artifact_registry_url" { + description = "The URL of the Artifact Registry" + value = module.artifact_registry.repository_url +} + +output "gke_connect_command" { + description = "Command to connect to the GKE cluster" + value = "gcloud container clusters get-credentials ${module.gke_cluster.cluster_name} --zone ${var.zone} --project ${var.project_id}" +} + +# Jenkins outputs +output "jenkins_url" { + description = "URL to access Jenkins on DigitalOcean" + value = module.jenkins.jenkins_url +} + +output "jenkins_droplet_ip" { + description = "Public IP of Jenkins droplet" + value = module.jenkins.droplet_ipv4 +} + +output "jenkins_ssh_command" { + description = "SSH command to connect to Jenkins droplet" + value = module.jenkins.ssh_connection_string +} + +output "jenkins_sa_email" { + description = "GCP service account email for Jenkins" + value = module.iam.jenkins_sa_email +} diff --git a/infra/envs/dev/providers.tf b/infra/envs/dev/providers.tf new file mode 100644 index 000000000..40ef40411 --- /dev/null +++ b/infra/envs/dev/providers.tf @@ -0,0 +1,23 @@ +terraform { + required_version = ">= 1.5.0" + + required_providers { + google = { + source = "hashicorp/google" + version = "~> 5.0" + } + digitalocean = { + source = "digitalocean/digitalocean" + version = "~> 2.0" + } + } +} + +provider "google" { + project = var.project_id + region = var.region +} + +provider "digitalocean" { + token = var.do_token +} diff --git a/infra/envs/dev/terraform.tfvars.example b/infra/envs/dev/terraform.tfvars.example new file mode 100644 index 000000000..c1ebe0e88 --- /dev/null +++ b/infra/envs/dev/terraform.tfvars.example @@ -0,0 +1,21 @@ +# Example terraform.tfvars for dev environment +# Copy this to terraform.tfvars and update with your values +# DO NOT commit terraform.tfvars to Git + +# GCP Configuration +project_id = "your-gcp-project-id" +region = "us-central1" +zone = "us-central1-a" +app_name = "ecommerce" + +# DigitalOcean Configuration +do_token = "your-digitalocean-api-token" # Get from: https://cloud.digitalocean.com/account/api/tokens +do_region = "nyc1" + +# Jenkins Configuration +jenkins_droplet_size = "s-2vcpu-4gb" +jenkins_admin_user = "admin" +jenkins_admin_password = "change-me-to-strong-password" + +# SSH Key (generate with: ssh-keygen -t rsa -b 4096 -C "jenkins@ecommerce") +ssh_public_key = "ssh-rsa AAAAB3... your-email@example.com" diff --git a/infra/envs/dev/variables.tf b/infra/envs/dev/variables.tf new file mode 100644 index 000000000..c28b1d212 --- /dev/null +++ b/infra/envs/dev/variables.tf @@ -0,0 +1,58 @@ +variable "project_id" { + description = "The GCP project ID" + type = string +} + +variable "region" { + description = "The GCP region" + type = string + default = "us-central1" +} + +variable "zone" { + description = "The GCP zone" + type = string + default = "us-central1-a" +} + +variable "app_name" { + description = "Application name" + type = string + default = "ecommerce" +} + +# DigitalOcean variables +variable "do_token" { + description = "DigitalOcean API token" + type = string + sensitive = true +} + +variable "do_region" { + description = "DigitalOcean region for Jenkins droplet" + type = string + default = "nyc1" +} + +variable "jenkins_droplet_size" { + description = "DigitalOcean droplet size for Jenkins" + type = string + default = "s-2vcpu-4gb" +} + +variable "ssh_public_key" { + description = "SSH public key for accessing the Jenkins droplet" + type = string +} + +variable "jenkins_admin_user" { + description = "Jenkins admin username" + type = string + default = "admin" +} + +variable "jenkins_admin_password" { + description = "Jenkins admin password" + type = string + sensitive = true +} diff --git a/infra/envs/prod/backend.tf b/infra/envs/prod/backend.tf new file mode 100644 index 000000000..7c9d97a24 --- /dev/null +++ b/infra/envs/prod/backend.tf @@ -0,0 +1,9 @@ +# GCS backend configuration for production environment +# Remote state for collaboration and safety + +terraform { + backend "gcs" { + bucket = "ecommerce-microservices-479322-terraform-state" + prefix = "prod/terraform.tfstate" + } +} diff --git a/infra/envs/prod/main.tf b/infra/envs/prod/main.tf new file mode 100644 index 000000000..cbc66e0a8 --- /dev/null +++ b/infra/envs/prod/main.tf @@ -0,0 +1,56 @@ +# Simplified Production Environment Configuration + +module "network" { + source = "../../modules/network" + + project_id = var.project_id + region = var.region + environment = "prod" + app_name = var.app_name + subnet_cidr = "10.30.0.0/24" + pods_cidr = "10.31.0.0/16" + services_cidr = "10.32.0.0/16" +} + +module "iam" { + source = "../../modules/iam" + + project_id = var.project_id + environment = "prod" + app_name = var.app_name +} + +module "artifact_registry" { + source = "../../modules/artifact-registry" + + project_id = var.project_id + region = var.region + environment = "prod" + app_name = var.app_name + writer_service_accounts = [module.iam.gke_sa_email] + reader_service_accounts = [module.iam.gke_sa_email] + + depends_on = [module.iam] +} + +module "gke_cluster" { + source = "../../modules/gke-cluster" + + project_id = var.project_id + region = var.region + zone = var.zone + environment = "prod" + app_name = var.app_name + network_self_link = module.network.vpc_self_link + subnet_self_link = module.network.subnet_self_link + pods_ip_range_name = module.network.pods_ip_range_name + services_ip_range_name = module.network.services_ip_range_name + gke_nodes_sa_email = module.iam.gke_sa_email + + # Prod settings: larger and no preemptible + default_pool_node_count = 3 + default_pool_machine_type = "e2-standard-4" + use_preemptible = false + + depends_on = [module.network, module.iam] +} diff --git a/infra/envs/prod/outputs.tf b/infra/envs/prod/outputs.tf new file mode 100644 index 000000000..362eefab6 --- /dev/null +++ b/infra/envs/prod/outputs.tf @@ -0,0 +1,37 @@ +# Simplified Production Environment Outputs + +output "vpc_name" { + description = "VPC network name" + value = module.network.vpc_name +} + +output "subnet_name" { + description = "Subnet name" + value = module.network.subnet_name +} + +output "gke_cluster_name" { + description = "GKE cluster name" + value = module.gke_cluster.cluster_name +} + +output "gke_cluster_endpoint" { + description = "GKE cluster endpoint" + value = module.gke_cluster.cluster_endpoint + sensitive = true +} + +output "gke_connect_command" { + description = "Command to connect to the cluster" + value = "gcloud container clusters get-credentials ${module.gke_cluster.cluster_name} --zone=${var.zone} --project=${var.project_id}" +} + +output "artifact_registry_url" { + description = "Artifact Registry repository URL" + value = module.artifact_registry.repository_url +} + +output "gke_sa_email" { + description = "GKE service account email" + value = module.iam.gke_sa_email +} diff --git a/infra/envs/prod/terraform.tfvars.example b/infra/envs/prod/terraform.tfvars.example new file mode 100644 index 000000000..f16d98764 --- /dev/null +++ b/infra/envs/prod/terraform.tfvars.example @@ -0,0 +1,11 @@ +# Example terraform.tfvars for production environment +# Copy this file to terraform.tfvars and fill in your values +# DO NOT commit terraform.tfvars to version control + +project_id = "your-gcp-project-id" +region = "us-central1" +zone = "us-central1-a" +app_name = "ecommerce" + +# Generate a strong password for Jenkins admin - use Secret Manager in production +jenkins_admin_password = "change-me-to-a-strong-password" diff --git a/infra/envs/prod/variables.tf b/infra/envs/prod/variables.tf new file mode 100644 index 000000000..d77fa636c --- /dev/null +++ b/infra/envs/prod/variables.tf @@ -0,0 +1,22 @@ +variable "project_id" { + description = "The GCP project ID" + type = string +} + +variable "region" { + description = "The GCP region" + type = string + default = "us-central1" +} + +variable "zone" { + description = "The GCP zone" + type = string + default = "us-central1-a" +} + +variable "app_name" { + description = "Application name" + type = string + default = "ecommerce" +} diff --git a/infra/envs/stage/backend.tf b/infra/envs/stage/backend.tf new file mode 100644 index 000000000..85fa230cc --- /dev/null +++ b/infra/envs/stage/backend.tf @@ -0,0 +1,9 @@ +# GCS backend configuration for stage environment +# Remote state for collaboration and safety + +terraform { + backend "gcs" { + bucket = "ecommerce-microservices-479322-terraform-state" + prefix = "stage/terraform.tfstate" + } +} diff --git a/infra/envs/stage/main.tf b/infra/envs/stage/main.tf new file mode 100644 index 000000000..40ed2c162 --- /dev/null +++ b/infra/envs/stage/main.tf @@ -0,0 +1,56 @@ +# Simplified Staging Environment Configuration + +module "network" { + source = "../../modules/network" + + project_id = var.project_id + region = var.region + environment = "stage" + app_name = var.app_name + subnet_cidr = "10.20.0.0/24" + pods_cidr = "10.21.0.0/16" + services_cidr = "10.22.0.0/16" +} + +module "iam" { + source = "../../modules/iam" + + project_id = var.project_id + environment = "stage" + app_name = var.app_name +} + +module "artifact_registry" { + source = "../../modules/artifact-registry" + + project_id = var.project_id + region = var.region + environment = "stage" + app_name = var.app_name + writer_service_accounts = [module.iam.gke_sa_email] + reader_service_accounts = [module.iam.gke_sa_email] + + depends_on = [module.iam] +} + +module "gke_cluster" { + source = "../../modules/gke-cluster" + + project_id = var.project_id + region = var.region + zone = var.zone + environment = "stage" + app_name = var.app_name + network_self_link = module.network.vpc_self_link + subnet_self_link = module.network.subnet_self_link + pods_ip_range_name = module.network.pods_ip_range_name + services_ip_range_name = module.network.services_ip_range_name + gke_nodes_sa_email = module.iam.gke_sa_email + + # Stage settings: medium size + default_pool_node_count = 3 + default_pool_machine_type = "e2-standard-2" + use_preemptible = true + + depends_on = [module.network, module.iam] +} diff --git a/infra/envs/stage/outputs.tf b/infra/envs/stage/outputs.tf new file mode 100644 index 000000000..dd268d1e1 --- /dev/null +++ b/infra/envs/stage/outputs.tf @@ -0,0 +1,37 @@ +# Simplified Staging Environment Outputs + +output "vpc_name" { + description = "VPC network name" + value = module.network.vpc_name +} + +output "subnet_name" { + description = "Subnet name" + value = module.network.subnet_name +} + +output "gke_cluster_name" { + description = "GKE cluster name" + value = module.gke_cluster.cluster_name +} + +output "gke_cluster_endpoint" { + description = "GKE cluster endpoint" + value = module.gke_cluster.cluster_endpoint + sensitive = true +} + +output "gke_connect_command" { + description = "Command to connect to the cluster" + value = "gcloud container clusters get-credentials ${module.gke_cluster.cluster_name} --zone=${var.zone} --project=${var.project_id}" +} + +output "artifact_registry_url" { + description = "Artifact Registry repository URL" + value = module.artifact_registry.repository_url +} + +output "gke_sa_email" { + description = "GKE service account email" + value = module.iam.gke_sa_email +} diff --git a/infra/envs/stage/terraform.tfvars.example b/infra/envs/stage/terraform.tfvars.example new file mode 100644 index 000000000..9c27234e1 --- /dev/null +++ b/infra/envs/stage/terraform.tfvars.example @@ -0,0 +1,11 @@ +# Example terraform.tfvars for stage environment +# Copy this file to terraform.tfvars and fill in your values +# DO NOT commit terraform.tfvars to version control + +project_id = "your-gcp-project-id" +region = "us-central1" +zone = "us-central1-a" +app_name = "ecommerce" + +# Generate a strong password for Jenkins admin +jenkins_admin_password = "change-me-to-a-strong-password" diff --git a/infra/envs/stage/variables.tf b/infra/envs/stage/variables.tf new file mode 100644 index 000000000..d77fa636c --- /dev/null +++ b/infra/envs/stage/variables.tf @@ -0,0 +1,22 @@ +variable "project_id" { + description = "The GCP project ID" + type = string +} + +variable "region" { + description = "The GCP region" + type = string + default = "us-central1" +} + +variable "zone" { + description = "The GCP zone" + type = string + default = "us-central1-a" +} + +variable "app_name" { + description = "Application name" + type = string + default = "ecommerce" +} diff --git a/infra/modules/artifact-registry/main.tf b/infra/modules/artifact-registry/main.tf new file mode 100644 index 000000000..93a936773 --- /dev/null +++ b/infra/modules/artifact-registry/main.tf @@ -0,0 +1,54 @@ +# Artifact Registry module - Docker repositories + +resource "google_artifact_registry_repository" "microservices" { + location = var.region + repository_id = "${var.app_name}-${var.environment}" + description = "Docker repository for ${var.app_name} microservices in ${var.environment}" + format = "DOCKER" + project = var.project_id + + labels = { + environment = var.environment + app = var.app_name + managed-by = "terraform" + } +} + +# Optional: Create separate repositories for different microservices if needed +resource "google_artifact_registry_repository" "jenkins" { + count = var.create_jenkins_repo ? 1 : 0 + location = var.region + repository_id = "${var.app_name}-jenkins-${var.environment}" + description = "Docker repository for Jenkins custom images in ${var.environment}" + format = "DOCKER" + project = var.project_id + + labels = { + environment = var.environment + app = var.app_name + component = "jenkins" + managed-by = "terraform" + } +} + +# IAM bindings for service accounts +# Using count instead of for_each to avoid dependency issues +resource "google_artifact_registry_repository_iam_member" "readers" { + count = length(var.reader_service_accounts) + + project = var.project_id + location = google_artifact_registry_repository.microservices.location + repository = google_artifact_registry_repository.microservices.name + role = "roles/artifactregistry.reader" + member = "serviceAccount:${var.reader_service_accounts[count.index]}" +} + +resource "google_artifact_registry_repository_iam_member" "writers" { + count = length(var.writer_service_accounts) + + project = var.project_id + location = google_artifact_registry_repository.microservices.location + repository = google_artifact_registry_repository.microservices.name + role = "roles/artifactregistry.writer" + member = "serviceAccount:${var.writer_service_accounts[count.index]}" +} diff --git a/infra/modules/artifact-registry/outputs.tf b/infra/modules/artifact-registry/outputs.tf new file mode 100644 index 000000000..afeac834e --- /dev/null +++ b/infra/modules/artifact-registry/outputs.tf @@ -0,0 +1,19 @@ +output "repository_id" { + description = "The ID of the Artifact Registry repository" + value = google_artifact_registry_repository.microservices.id +} + +output "repository_name" { + description = "The name of the Artifact Registry repository" + value = google_artifact_registry_repository.microservices.name +} + +output "repository_url" { + description = "The URL of the Artifact Registry repository" + value = "${var.region}-docker.pkg.dev/${var.project_id}/${google_artifact_registry_repository.microservices.name}" +} + +output "jenkins_repository_url" { + description = "The URL of the Jenkins Artifact Registry repository (if created)" + value = var.create_jenkins_repo ? "${var.region}-docker.pkg.dev/${var.project_id}/${google_artifact_registry_repository.jenkins[0].name}" : "" +} diff --git a/infra/modules/artifact-registry/variables.tf b/infra/modules/artifact-registry/variables.tf new file mode 100644 index 000000000..683978ae9 --- /dev/null +++ b/infra/modules/artifact-registry/variables.tf @@ -0,0 +1,37 @@ +variable "project_id" { + description = "The GCP project ID" + type = string +} + +variable "region" { + description = "The GCP region" + type = string +} + +variable "environment" { + description = "Environment name" + type = string +} + +variable "app_name" { + description = "Application name" + type = string +} + +variable "create_jenkins_repo" { + description = "Whether to create a separate repository for Jenkins images" + type = bool + default = false +} + +variable "reader_service_accounts" { + description = "List of service account emails that should have read access" + type = list(string) + default = [] +} + +variable "writer_service_accounts" { + description = "List of service account emails that should have write access" + type = list(string) + default = [] +} diff --git a/infra/modules/digitalocean-jenkins/main.tf b/infra/modules/digitalocean-jenkins/main.tf new file mode 100644 index 000000000..701ad00c5 --- /dev/null +++ b/infra/modules/digitalocean-jenkins/main.tf @@ -0,0 +1,78 @@ +# Simplified DigitalOcean Jenkins Droplet Module +# Creates a basic droplet with Docker and Jenkins + +terraform { + required_providers { + digitalocean = { + source = "digitalocean/digitalocean" + version = "~> 2.0" + } + } +} + +# SSH Key for accessing the droplet +resource "digitalocean_ssh_key" "jenkins" { + name = "${var.app_name}-${var.environment}-jenkins-key" + public_key = var.ssh_public_key +} + +# Jenkins Droplet +resource "digitalocean_droplet" "jenkins" { + name = "${var.app_name}-${var.environment}-jenkins" + region = var.region + size = var.droplet_size + image = "docker-20-04" # Ubuntu 20.04 with Docker pre-installed + + ssh_keys = [digitalocean_ssh_key.jenkins.fingerprint] + tags = ["${var.environment}", "jenkins", "ci-cd", var.app_name] + + # Cloud-init script to setup Jenkins + user_data = templatefile("${path.module}/scripts/install-jenkins.sh", { + jenkins_admin_user = var.jenkins_admin_user + jenkins_admin_password = var.jenkins_admin_password + gcp_project_id = var.gcp_project_id + gcp_region = var.gcp_region + environment = var.environment + }) + + monitoring = true +} + +# Firewall rules for Jenkins +resource "digitalocean_firewall" "jenkins" { + name = "${var.app_name}-${var.environment}-jenkins-firewall" + + droplet_ids = [digitalocean_droplet.jenkins.id] + + # SSH access + inbound_rule { + protocol = "tcp" + port_range = "22" + source_addresses = ["0.0.0.0/0", "::/0"] + } + + # Jenkins web UI + inbound_rule { + protocol = "tcp" + port_range = "8080" + source_addresses = ["0.0.0.0/0", "::/0"] + } + + # Allow all outbound traffic + outbound_rule { + protocol = "tcp" + port_range = "1-65535" + destination_addresses = ["0.0.0.0/0", "::/0"] + } + + outbound_rule { + protocol = "udp" + port_range = "1-65535" + destination_addresses = ["0.0.0.0/0", "::/0"] + } + + outbound_rule { + protocol = "icmp" + destination_addresses = ["0.0.0.0/0", "::/0"] + } +} diff --git a/infra/modules/digitalocean-jenkins/outputs.tf b/infra/modules/digitalocean-jenkins/outputs.tf new file mode 100644 index 000000000..6f845d367 --- /dev/null +++ b/infra/modules/digitalocean-jenkins/outputs.tf @@ -0,0 +1,19 @@ +output "droplet_id" { + description = "ID of the Jenkins droplet" + value = digitalocean_droplet.jenkins.id +} + +output "droplet_ipv4" { + description = "Public IPv4 address of the Jenkins droplet" + value = digitalocean_droplet.jenkins.ipv4_address +} + +output "jenkins_url" { + description = "URL to access Jenkins" + value = "http://${digitalocean_droplet.jenkins.ipv4_address}:8080" +} + +output "ssh_connection_string" { + description = "SSH connection string" + value = "ssh root@${digitalocean_droplet.jenkins.ipv4_address}" +} diff --git a/infra/modules/digitalocean-jenkins/scripts/install-jenkins.sh b/infra/modules/digitalocean-jenkins/scripts/install-jenkins.sh new file mode 100644 index 000000000..3d3bd91df --- /dev/null +++ b/infra/modules/digitalocean-jenkins/scripts/install-jenkins.sh @@ -0,0 +1,106 @@ +#!/bin/bash +# Simplified Jenkins Installation Script for DigitalOcean +set -e + +echo "==> Updating system..." +apt-get update +apt-get upgrade -y + +echo "==> Installing Java 17..." +apt-get install -y openjdk-17-jdk + +echo "==> Installing Jenkins..." +wget -q -O - https://pkg.jenkins.io/debian-stable/jenkins.io-2023.key | apt-key add - +sh -c 'echo deb https://pkg.jenkins.io/debian-stable binary/ > /etc/apt/sources.list.d/jenkins.list' +apt-get update +apt-get install -y jenkins + +systemctl start jenkins +systemctl enable jenkins + +echo "==> Installing Google Cloud SDK..." +echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main" | tee -a /etc/apt/sources.list.d/google-cloud-sdk.list +curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | apt-key --keyring /usr/share/keyrings/cloud.google.gpg add - +apt-get update +apt-get install -y google-cloud-sdk google-cloud-sdk-gke-gcloud-auth-plugin + +echo "==> Installing kubectl..." +curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" +install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl + +echo "==> Installing Python3, pip and Locust..." +apt-get install -y python3 python3-pip python3-venv +pip3 install --break-system-packages locust + +echo "==> Installing Jenkins plugins..." +JENKINS_HOME=/var/lib/jenkins +# Wait for Jenkins to be ready +until curl -s http://localhost:8080 > /dev/null; do + echo "Waiting for Jenkins to start..." + sleep 10 +done +sleep 30 # Extra time for Jenkins to fully initialize + +# Install common plugins (matching your plugins.txt) +jenkins_cli="java -jar /var/cache/jenkins/war/WEB-INF/jenkins-cli.jar -s http://localhost:8080/ -auth admin:$(cat /var/lib/jenkins/secrets/initialAdminPassword)" +PLUGINS="git docker-plugin docker-workflow kubernetes-cli google-cloud-sdk pipeline-stage-view" +for plugin in $PLUGINS; do + $jenkins_cli install-plugin $plugin || echo "Plugin $plugin already installed or failed" +done +$jenkins_cli restart || systemctl restart jenkins + +echo "==> Configuring Jenkins user..." +usermod -aG docker jenkins +mkdir -p /var/lib/jenkins/.kube /var/lib/jenkins/.config/gcloud +chown -R jenkins:jenkins /var/lib/jenkins + +# GCP Setup Script +cat > /var/lib/jenkins/setup-gcp.sh << 'EOFGCP' +#!/bin/bash +if [ -f /var/lib/jenkins/gcp-key.json ]; then + gcloud auth activate-service-account --key-file=/var/lib/jenkins/gcp-key.json + gcloud config set project ${gcp_project_id} + gcloud config set compute/region ${gcp_region} + gcloud auth configure-docker ${gcp_region}-docker.pkg.dev + echo "✅ GCP configured!" +else + echo "❌ /var/lib/jenkins/gcp-key.json not found" +fi +EOFGCP + +chmod +x /var/lib/jenkins/setup-gcp.sh +chown jenkins:jenkins /var/lib/jenkins/setup-gcp.sh + +# Wait for Jenkins +echo "==> Waiting for Jenkins to start..." +until [ -f /var/lib/jenkins/secrets/initialAdminPassword ]; do + sleep 5 +done + +# Info file +cat > /root/JENKINS_INFO.txt << EOFINFO +========================================= +🚀 Jenkins Ready! +========================================= + +URL: http://\$(curl -s ifconfig.me):8080 +User: ${jenkins_admin_user} +Pass: ${jenkins_admin_password} + +========================================= +Setup GCP: +========================================= +1. Copy SA key to droplet: + scp jenkins-key.json root@\$(curl -s ifconfig.me):/var/lib/jenkins/gcp-key.json + +2. Run setup: + sudo -u jenkins /var/lib/jenkins/setup-gcp.sh + +3. Test: + sudo -u jenkins gcloud projects list + +========================================= +EOFINFO + +cat /root/JENKINS_INFO.txt +echo "==> ✅ Installation complete!" diff --git a/infra/modules/digitalocean-jenkins/variables.tf b/infra/modules/digitalocean-jenkins/variables.tf new file mode 100644 index 000000000..b1c266cbd --- /dev/null +++ b/infra/modules/digitalocean-jenkins/variables.tf @@ -0,0 +1,55 @@ +variable "app_name" { + description = "Application name" + type = string + default = "ecommerce" +} + +variable "environment" { + description = "Environment name (dev, stage, prod)" + type = string +} + +variable "region" { + description = "DigitalOcean region" + type = string + default = "nyc1" +} + +variable "droplet_size" { + description = "Droplet size/plan" + type = string + default = "s-2vcpu-4gb" # $24/month +} + +variable "ssh_public_key" { + description = "SSH public key for droplet access" + type = string +} + +variable "jenkins_admin_user" { + description = "Jenkins admin username" + type = string + default = "admin" +} + +variable "jenkins_admin_password" { + description = "Jenkins admin password" + type = string + sensitive = true +} + +variable "gcp_project_id" { + description = "GCP Project ID for Jenkins to deploy to" + type = string +} + +variable "gcp_region" { + description = "GCP region for deployments" + type = string +} + +variable "gcp_service_account_key" { + description = "GCP Service Account JSON key for Jenkins authentication" + type = string + sensitive = true +} diff --git a/infra/modules/gke-cluster/main.tf b/infra/modules/gke-cluster/main.tf new file mode 100644 index 000000000..34728f6b4 --- /dev/null +++ b/infra/modules/gke-cluster/main.tf @@ -0,0 +1,43 @@ +# Simplified GKE Cluster - Minimal configuration for debugging + +resource "google_container_cluster" "primary" { + name = "${var.app_name}-${var.environment}-gke" + location = var.zone + project = var.project_id + + deletion_protection = false + + # Simple: use default node pool for simplicity + initial_node_count = var.default_pool_node_count + + network = var.network_self_link + subnetwork = var.subnet_self_link + + # IP allocation for pods and services + ip_allocation_policy { + cluster_secondary_range_name = var.pods_ip_range_name + services_secondary_range_name = var.services_ip_range_name + } + + # Basic node configuration + node_config { + preemptible = var.use_preemptible + machine_type = var.default_pool_machine_type + + service_account = var.gke_nodes_sa_email + oauth_scopes = [ + "https://www.googleapis.com/auth/cloud-platform" + ] + + labels = { + environment = var.environment + } + } + + # Maintenance window + maintenance_policy { + daily_maintenance_window { + start_time = "03:00" + } + } +} diff --git a/infra/modules/gke-cluster/outputs.tf b/infra/modules/gke-cluster/outputs.tf new file mode 100644 index 000000000..bcb59979b --- /dev/null +++ b/infra/modules/gke-cluster/outputs.tf @@ -0,0 +1,16 @@ +output "cluster_name" { + description = "The name of the GKE cluster" + value = google_container_cluster.primary.name +} + +output "cluster_endpoint" { + description = "The endpoint of the GKE cluster" + value = google_container_cluster.primary.endpoint + sensitive = true +} + +output "cluster_ca_certificate" { + description = "The CA certificate of the GKE cluster" + value = google_container_cluster.primary.master_auth[0].cluster_ca_certificate + sensitive = true +} diff --git a/infra/modules/gke-cluster/variables.tf b/infra/modules/gke-cluster/variables.tf new file mode 100644 index 000000000..71184c7bc --- /dev/null +++ b/infra/modules/gke-cluster/variables.tf @@ -0,0 +1,67 @@ +variable "project_id" { + description = "The GCP project ID" + type = string +} + +variable "region" { + description = "The GCP region" + type = string +} + +variable "zone" { + description = "The GCP zone" + type = string +} + +variable "environment" { + description = "Environment name" + type = string +} + +variable "app_name" { + description = "Application name" + type = string +} + +variable "network_self_link" { + description = "Self link of the VPC network" + type = string +} + +variable "subnet_self_link" { + description = "Self link of the subnet" + type = string +} + +variable "pods_ip_range_name" { + description = "Name of the secondary IP range for pods" + type = string +} + +variable "services_ip_range_name" { + description = "Name of the secondary IP range for services" + type = string +} + +variable "gke_nodes_sa_email" { + description = "Email of the service account for GKE nodes" + type = string +} + +variable "default_pool_node_count" { + description = "Number of nodes in the default pool" + type = number + default = 2 +} + +variable "default_pool_machine_type" { + description = "Machine type for nodes" + type = string + default = "e2-medium" +} + +variable "use_preemptible" { + description = "Use preemptible VMs for cost savings" + type = bool + default = true +} diff --git a/infra/modules/iam/main.tf b/infra/modules/iam/main.tf new file mode 100644 index 000000000..4cc1fa5a2 --- /dev/null +++ b/infra/modules/iam/main.tf @@ -0,0 +1,52 @@ +# Simplified IAM module - Service Accounts for GKE and Jenkins + +# Service Account for GKE nodes and workloads +resource "google_service_account" "gke" { + account_id = "${var.app_name}-gke-${var.environment}" + display_name = "GKE Service Account for ${var.environment}" + description = "Service account for GKE nodes and workloads" + project = var.project_id +} + +# Basic permissions for GKE +resource "google_project_iam_member" "gke_roles" { + for_each = toset([ + "roles/logging.logWriter", + "roles/monitoring.metricWriter", + "roles/artifactregistry.reader", + "roles/artifactregistry.writer", + ]) + + project = var.project_id + role = each.value + member = "serviceAccount:${google_service_account.gke.email}" +} + +# Service Account for Jenkins (running on DigitalOcean) +resource "google_service_account" "jenkins" { + account_id = "${var.app_name}-jenkins-${var.environment}" + display_name = "Jenkins Service Account for ${var.environment}" + description = "Service account for Jenkins CI/CD running on DigitalOcean" + project = var.project_id +} + +# Jenkins permissions for GCP resources +resource "google_project_iam_member" "jenkins_roles" { + for_each = toset([ + "roles/container.admin", # Manage GKE clusters + "roles/artifactregistry.writer", # Push Docker images + "roles/storage.admin", # Access Cloud Storage + "roles/logging.logWriter", # Write logs + "roles/iam.serviceAccountUser", # Use service accounts + ]) + + project = var.project_id + role = each.value + member = "serviceAccount:${google_service_account.jenkins.email}" +} + +# Create service account key for Jenkins +resource "google_service_account_key" "jenkins" { + service_account_id = google_service_account.jenkins.name + public_key_type = "TYPE_X509_PEM_FILE" +} diff --git a/infra/modules/iam/outputs.tf b/infra/modules/iam/outputs.tf new file mode 100644 index 000000000..f08aebdc1 --- /dev/null +++ b/infra/modules/iam/outputs.tf @@ -0,0 +1,21 @@ +output "gke_sa_email" { + description = "Email of the GKE service account" + value = google_service_account.gke.email +} + +output "jenkins_sa_email" { + description = "Email of the Jenkins service account" + value = google_service_account.jenkins.email +} + +output "jenkins_sa_key" { + description = "Private key for Jenkins service account (base64 encoded)" + value = google_service_account_key.jenkins.private_key + sensitive = true +} + +output "jenkins_sa_key_decoded" { + description = "Private key for Jenkins service account (JSON)" + value = base64decode(google_service_account_key.jenkins.private_key) + sensitive = true +} diff --git a/infra/modules/iam/variables.tf b/infra/modules/iam/variables.tf new file mode 100644 index 000000000..1b5b4184b --- /dev/null +++ b/infra/modules/iam/variables.tf @@ -0,0 +1,14 @@ +variable "project_id" { + description = "The GCP project ID" + type = string +} + +variable "environment" { + description = "Environment name" + type = string +} + +variable "app_name" { + description = "Application name" + type = string +} diff --git a/infra/modules/network/main.tf b/infra/modules/network/main.tf new file mode 100644 index 000000000..8cfb9bc08 --- /dev/null +++ b/infra/modules/network/main.tf @@ -0,0 +1,52 @@ +# Simplified Network module - VPC and Subnet for GKE + +resource "google_compute_network" "vpc" { + name = "${var.app_name}-${var.environment}-vpc" + auto_create_subnetworks = false + project = var.project_id +} + +resource "google_compute_subnetwork" "subnet" { + name = "${var.app_name}-${var.environment}-subnet" + ip_cidr_range = var.subnet_cidr + region = var.region + network = google_compute_network.vpc.id + project = var.project_id + + secondary_ip_range { + range_name = "pods" + ip_cidr_range = var.pods_cidr + } + + secondary_ip_range { + range_name = "services" + ip_cidr_range = var.services_cidr + } + + private_ip_google_access = true +} + +# Allow all internal communication (simplified) +resource "google_compute_firewall" "allow_internal" { + name = "${var.app_name}-${var.environment}-allow-all-internal" + network = google_compute_network.vpc.name + project = var.project_id + + allow { + protocol = "tcp" + } + + allow { + protocol = "udp" + } + + allow { + protocol = "icmp" + } + + source_ranges = [ + var.subnet_cidr, + var.pods_cidr, + var.services_cidr + ] +} diff --git a/infra/modules/network/outputs.tf b/infra/modules/network/outputs.tf new file mode 100644 index 000000000..2a1e64f3f --- /dev/null +++ b/infra/modules/network/outputs.tf @@ -0,0 +1,39 @@ +output "vpc_id" { + description = "The ID of the VPC" + value = google_compute_network.vpc.id +} + +output "vpc_name" { + description = "The name of the VPC" + value = google_compute_network.vpc.name +} + +output "vpc_self_link" { + description = "The self link of the VPC" + value = google_compute_network.vpc.self_link +} + +output "subnet_id" { + description = "The ID of the subnet" + value = google_compute_subnetwork.subnet.id +} + +output "subnet_name" { + description = "The name of the subnet" + value = google_compute_subnetwork.subnet.name +} + +output "subnet_self_link" { + description = "The self link of the subnet" + value = google_compute_subnetwork.subnet.self_link +} + +output "pods_ip_range_name" { + description = "The name of the pods secondary IP range" + value = "pods" +} + +output "services_ip_range_name" { + description = "The name of the services secondary IP range" + value = "services" +} diff --git a/infra/modules/network/variables.tf b/infra/modules/network/variables.tf new file mode 100644 index 000000000..5deead8d8 --- /dev/null +++ b/infra/modules/network/variables.tf @@ -0,0 +1,37 @@ +variable "project_id" { + description = "The GCP project ID" + type = string +} + +variable "region" { + description = "The GCP region" + type = string +} + +variable "environment" { + description = "Environment name" + type = string +} + +variable "app_name" { + description = "Application name" + type = string +} + +variable "subnet_cidr" { + description = "CIDR range for the subnet" + type = string + default = "10.0.0.0/24" +} + +variable "pods_cidr" { + description = "CIDR range for GKE pods" + type = string + default = "10.1.0.0/16" +} + +variable "services_cidr" { + description = "CIDR range for GKE services" + type = string + default = "10.2.0.0/16" +} diff --git a/infra/outputs.tf b/infra/outputs.tf new file mode 100644 index 000000000..fd81ee23c --- /dev/null +++ b/infra/outputs.tf @@ -0,0 +1,16 @@ +# Root level outputs + +output "project_id" { + description = "The GCP project ID" + value = var.project_id +} + +output "region" { + description = "The GCP region" + value = var.region +} + +output "environment" { + description = "The environment name" + value = var.environment +} diff --git a/infra/provider.tf b/infra/provider.tf new file mode 100644 index 000000000..f337f4b09 --- /dev/null +++ b/infra/provider.tf @@ -0,0 +1,12 @@ +# Provider configuration for Google Cloud Platform +provider "google" { + project = var.project_id + region = var.region + zone = var.zone +} + +provider "google-beta" { + project = var.project_id + region = var.region + zone = var.zone +} diff --git a/infra/scripts/connect-gke.ps1 b/infra/scripts/connect-gke.ps1 new file mode 100644 index 000000000..e4825766c --- /dev/null +++ b/infra/scripts/connect-gke.ps1 @@ -0,0 +1,72 @@ +# Connect to GKE cluster and configure kubectl +# This script retrieves credentials for the GKE cluster + +param( + [Parameter(Mandatory=$true)] + [ValidateSet('dev', 'stage', 'prod')] + [string]$Environment, + + [Parameter(Mandatory=$true)] + [string]$ProjectId, + + [Parameter(Mandatory=$false)] + [string]$Region = "us-central1", + + [Parameter(Mandatory=$false)] + [string]$Zone = "us-central1-a", + + [Parameter(Mandatory=$false)] + [string]$ClusterName = "" +) + +$ErrorActionPreference = "Stop" + +Write-Host "==================================" -ForegroundColor Cyan +Write-Host "Connecting to GKE Cluster" -ForegroundColor Cyan +Write-Host "==================================" -ForegroundColor Cyan +Write-Host "" + +# Default cluster name if not provided +if ([string]::IsNullOrEmpty($ClusterName)) { + $ClusterName = "ecommerce-$Environment-gke" +} + +Write-Host "Environment: $Environment" -ForegroundColor Green +Write-Host "Project: $ProjectId" -ForegroundColor Green +Write-Host "Cluster: $ClusterName" -ForegroundColor Green +Write-Host "" + +# Check if cluster is regional or zonal +if ($Environment -eq "prod") { + Write-Host "Connecting to regional cluster..." -ForegroundColor Yellow + gcloud container clusters get-credentials $ClusterName --region $Region --project $ProjectId +} else { + Write-Host "Connecting to zonal cluster..." -ForegroundColor Yellow + gcloud container clusters get-credentials $ClusterName --zone $Zone --project $ProjectId +} + +Write-Host "" +Write-Host "==================================" -ForegroundColor Cyan +Write-Host "Connected Successfully!" -ForegroundColor Green +Write-Host "==================================" -ForegroundColor Cyan +Write-Host "" + +# Show cluster info +Write-Host "Cluster Information:" -ForegroundColor Yellow +kubectl cluster-info +Write-Host "" + +Write-Host "Available Nodes:" -ForegroundColor Yellow +kubectl get nodes +Write-Host "" + +Write-Host "Namespaces:" -ForegroundColor Yellow +kubectl get namespaces +Write-Host "" + +# Check for Jenkins +Write-Host "Jenkins Status:" -ForegroundColor Yellow +kubectl get pods -n jenkins 2>$null +if ($LASTEXITCODE -ne 0) { + Write-Host "Jenkins namespace not found or no pods deployed yet" -ForegroundColor Yellow +} diff --git a/infra/scripts/enable-apis.ps1 b/infra/scripts/enable-apis.ps1 new file mode 100644 index 000000000..1436bdae3 --- /dev/null +++ b/infra/scripts/enable-apis.ps1 @@ -0,0 +1,51 @@ +# Enable required GCP APIs for the project +# This script enables all necessary Google Cloud APIs for the infrastructure + +param( + [Parameter(Mandatory=$true)] + [string]$ProjectId +) + +$ErrorActionPreference = "Stop" + +Write-Host "==================================" -ForegroundColor Cyan +Write-Host "Enabling GCP APIs" -ForegroundColor Cyan +Write-Host "==================================" -ForegroundColor Cyan +Write-Host "" + +# List of required APIs +$apis = @( + "compute.googleapis.com", + "container.googleapis.com", + "artifactregistry.googleapis.com", + "iam.googleapis.com", + "cloudresourcemanager.googleapis.com", + "storage.googleapis.com", + "secretmanager.googleapis.com", + "logging.googleapis.com", + "monitoring.googleapis.com", + "cloudtrace.googleapis.com", + "servicenetworking.googleapis.com" +) + +# Set project +Write-Host "Setting GCP project: $ProjectId" -ForegroundColor Green +gcloud config set project $ProjectId +Write-Host "" + +# Enable each API +foreach ($api in $apis) { + Write-Host "Enabling API: $api" -ForegroundColor Yellow + gcloud services enable $api --project=$ProjectId + Write-Host " ✓ Enabled" -ForegroundColor Green +} + +Write-Host "" +Write-Host "==================================" -ForegroundColor Cyan +Write-Host "All APIs Enabled!" -ForegroundColor Green +Write-Host "==================================" -ForegroundColor Cyan +Write-Host "" +Write-Host "Enabled APIs:" -ForegroundColor Yellow +foreach ($api in $apis) { + Write-Host " • $api" -ForegroundColor White +} diff --git a/infra/variables.tf b/infra/variables.tf new file mode 100644 index 000000000..31ab594c1 --- /dev/null +++ b/infra/variables.tf @@ -0,0 +1,33 @@ +# Common variables for the infrastructure + +variable "project_id" { + description = "The GCP project ID" + type = string +} + +variable "region" { + description = "The GCP region for resources" + type = string + default = "us-central1" +} + +variable "zone" { + description = "The GCP zone for resources" + type = string + default = "us-central1-a" +} + +variable "environment" { + description = "Environment name (dev, stage, prod)" + type = string + validation { + condition = contains(["dev", "stage", "prod"], var.environment) + error_message = "Environment must be dev, stage, or prod." + } +} + +variable "app_name" { + description = "Application name" + type = string + default = "ecommerce" +} diff --git a/infra/versions.tf b/infra/versions.tf new file mode 100644 index 000000000..aa2b2defc --- /dev/null +++ b/infra/versions.tf @@ -0,0 +1,22 @@ +terraform { + required_version = ">= 1.5.0" + + required_providers { + google = { + source = "hashicorp/google" + version = "~> 5.0" + } + google-beta = { + source = "hashicorp/google-beta" + version = "~> 5.0" + } + helm = { + source = "hashicorp/helm" + version = "~> 2.12" + } + kubernetes = { + source = "hashicorp/kubernetes" + version = "~> 2.25" + } + } +} diff --git a/jenkins/cloud-config/Jenkinsfile.dev b/jenkins/cloud-config/Jenkinsfile.dev new file mode 100644 index 000000000..140adf2b2 --- /dev/null +++ b/jenkins/cloud-config/Jenkinsfile.dev @@ -0,0 +1,84 @@ +pipeline { + agent any + + tools { + maven 'Maven3' + } + + environment { + SERVICE_NAME = 'cloud-config' + IMAGE_NAME = "juanse201/${SERVICE_NAME}:dev" + } + + stages { + stage('Checkout') { + steps { + checkout scm + } + } + + stage('Debug Maven') { + steps { + sh 'docker --version' + sh 'which mvn || echo "Maven not found"' + sh 'mvn --version || echo "Maven command failed"' + sh 'java --version || echo "java command failed"' + sh 'echo "--------------------------------------------"' + sh 'grep docker /etc/group' + sh ''' + echo "Current user: $(whoami)" + echo "Groups: $(groups)" + echo "Docker version (if accessible):" + if docker info > /dev/null 2>&1; then + docker version + echo "Docker socket is accessible ✅" + else + echo "Docker socket is NOT accessible ❌" + fi + ''' + } + } + + stage('Test') { + steps { + dir("${SERVICE_NAME}") { + sh 'mvn clean test' + } + } + } + + stage('Build') { + steps { + dir("${SERVICE_NAME}") { + sh 'mvn clean package -DskipTests' + sh 'ls -l target/' + } + } + } + + stage('Build Docker Image') { + steps { + // Build from workspace root (.), using Dockerfile from service dir + sh "docker build -t ${IMAGE_NAME} -f ${SERVICE_NAME}/Dockerfile ." + } + } + + stage('Push to DockerHub') { + steps { + // Use the global credentials with ID 'docker' + withCredentials([usernamePassword(credentialsId: 'docker', usernameVariable: 'DOCKER_USER', passwordVariable: 'DOCKER_PASS')]) { + sh ''' + # Login to Docker Hub + echo "$DOCKER_PASS" | docker login -u "$DOCKER_USER" --password-stdin + + # Push the image + docker push $IMAGE_NAME + + # Logout (optional but good practice) + docker logout + ''' + } + } + } + } +} \ No newline at end of file diff --git a/jenkins/dev/Jenkinsfile.dev b/jenkins/dev/Jenkinsfile.dev new file mode 100644 index 000000000..d4f285c71 --- /dev/null +++ b/jenkins/dev/Jenkinsfile.dev @@ -0,0 +1,170 @@ +pipeline { + agent any + + tools { + maven 'Maven3' + } + + environment { + DOCKER_NAME = "juanse201/" + IMAGE_TAG = "dev" + S1 = "order-service" + S2 = "user-service" + S3 = "payment-service" + S4 = "product-service" + S5 = "favourite-service" + S6 = "cloud-config" + S7 = "service-discovery" + SONAR_TOKEN = credentials('sonar_token') + } + + stages { + stage('Checkout') { + steps { + checkout scm + } + } + + stage('Debug Environment') { + steps { + sh 'docker --version' + sh 'which mvn || echo "Maven not found"' + sh 'mvn --version || echo "Maven command failed"' + sh 'java --version || echo "java command failed"' + sh ''' + echo "Current user: $(whoami)" + echo "Groups: $(groups)" + echo "Docker version (if accessible):" + if docker info > /dev/null 2>&1; then + docker version + echo "Docker socket is accessible ✅" + else + echo "Docker socket is NOT accessible ❌" + fi + ''' + } + } + + stage('Test All Services') { + steps { + dir("${S1}") { + sh 'mvn clean test' + } + dir("${S2}") { + sh 'mvn clean test' + } + dir("${S3}") { + sh 'mvn clean test' + } + dir("${S4}") { + sh 'mvn clean test' + } + dir("${S5}") { + sh 'mvn clean test' + } + dir("${S6}") { + sh 'mvn clean test' + } + dir("${S7}") { + sh 'mvn clean test' + } + } + } + + stage('Build All Services') { + steps { + sh 'mvn clean package -DskipTests' + } + } + + stage('SonarQube Analysis') { + steps { + script { + withSonarQubeEnv('SonarQube') { + // Check SonarQube connectivity + sh 'curl -f http://sonarqube:9000 || echo "Warning: SonarQube may not be accessible"' + + // Run SonarQube analysis on entire project + sh "mvn sonar:sonar -Dsonar.token=${SONAR_TOKEN}" + } + } + } + } + + // stage('Quality Gate') { + // steps { + // script { + // timeout(time: 5, unit: 'MINUTES') { + // waitForQualityGate abortPipeline: true + // } + // } + // } + // } + + stage('Build Docker Images') { + parallel { + stage('Build Images') { + steps { + sh "docker build -t ${DOCKER_NAME}${S1}:${IMAGE_TAG} -f ${S1}/Dockerfile ." + sh "docker build -t ${DOCKER_NAME}${S2}:${IMAGE_TAG} -f ${S2}/Dockerfile ." + sh "docker build -t ${DOCKER_NAME}${S3}:${IMAGE_TAG} -f ${S3}/Dockerfile ." + sh "docker build -t ${DOCKER_NAME}${S4}:${IMAGE_TAG} -f ${S4}/Dockerfile ." + sh "docker build -t ${DOCKER_NAME}${S5}:${IMAGE_TAG} -f ${S5}/Dockerfile ." + sh "docker build -t ${DOCKER_NAME}${S6}:${IMAGE_TAG} -f ${S6}/Dockerfile ." + sh "docker build -t ${DOCKER_NAME}${S7}:${IMAGE_TAG} -f ${S7}/Dockerfile ." + } + } + } + } + + stage('Trivy Security Scan') { + steps { + script { + echo 'Scanning Docker images for vulnerabilities with Trivy...' + sh ''' + trivy image --severity HIGH,CRITICAL ${DOCKER_NAME}${S1}:${IMAGE_TAG} || echo "Vulnerabilities found in ${S1}" + trivy image --severity HIGH,CRITICAL ${DOCKER_NAME}${S2}:${IMAGE_TAG} || echo "Vulnerabilities found in ${S2}" + trivy image --severity HIGH,CRITICAL ${DOCKER_NAME}${S3}:${IMAGE_TAG} || echo "Vulnerabilities found in ${S3}" + trivy image --severity HIGH,CRITICAL ${DOCKER_NAME}${S4}:${IMAGE_TAG} || echo "Vulnerabilities found in ${S4}" + trivy image --severity HIGH,CRITICAL ${DOCKER_NAME}${S5}:${IMAGE_TAG} || echo "Vulnerabilities found in ${S5}" + trivy image --severity HIGH,CRITICAL ${DOCKER_NAME}${S6}:${IMAGE_TAG} || echo "Vulnerabilities found in ${S6}" + trivy image --severity HIGH,CRITICAL ${DOCKER_NAME}${S7}:${IMAGE_TAG} || echo "Vulnerabilities found in ${S7}" + ''' + } + } + } + + stage('Push to DockerHub') { + steps { + withCredentials([usernamePassword(credentialsId: 'docker', usernameVariable: 'DOCKER_USER', passwordVariable: 'DOCKER_PASS')]) { + sh ''' + echo "$DOCKER_PASS" | docker login -u "$DOCKER_USER" --password-stdin + docker push "${DOCKER_NAME}${S1}:${IMAGE_TAG}" + docker push "${DOCKER_NAME}${S2}:${IMAGE_TAG}" + docker push "${DOCKER_NAME}${S3}:${IMAGE_TAG}" + docker push "${DOCKER_NAME}${S4}:${IMAGE_TAG}" + docker push "${DOCKER_NAME}${S5}:${IMAGE_TAG}" + docker push "${DOCKER_NAME}${S6}:${IMAGE_TAG}" + docker push "${DOCKER_NAME}${S7}:${IMAGE_TAG}" + docker logout + ''' + } + } + } + } + + post { + always { + // Cleanup docker images to save space + sh ''' + docker image prune -f || true + ''' + } + success { + echo '✅ Dev pipeline completed successfully! All tests passed, code quality checked, and images pushed.' + } + failure { + echo '❌ Dev pipeline failed. Check logs for details.' + } + } +} diff --git a/jenkins/dockerfile b/jenkins/dockerfile new file mode 100644 index 000000000..35f45b053 --- /dev/null +++ b/jenkins/dockerfile @@ -0,0 +1,40 @@ +# Jenkins/Dockerfile +FROM jenkins/jenkins:lts-jdk17 + +USER root +RUN apt-get update && \ + apt-get install -y docker.io && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +# Add jenkins to docker group if it exists, else create it +RUN if ! getent group docker; then groupadd -g 999 docker; fi && \ + usermod -aG docker jenkins + +RUN apt-get update && \ + apt-get install -y curl && \ + curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" && \ + install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl && \ + ln -s /usr/local/bin/kubectl /usr/bin/kubectl && \ + rm kubectl + +# Install Python and pip for Locust performance tests +RUN apt-get update && \ + apt-get install -y python3 python3-pip python3-venv && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +# Install Locust globally +RUN pip3 install --break-system-packages locust + +# Install Trivy for container vulnerability scanning +RUN curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin v0.48.0 + +USER jenkins + +# Copy plugin list and install plugins automatically +COPY plugins.txt /usr/share/jenkins/ref/plugins.txt +RUN jenkins-plugin-cli --plugin-file /usr/share/jenkins/ref/plugins.txt + +# Expose Jenkins web and agent ports +EXPOSE 8080 50000 diff --git a/jenkins/favourite-service/Jenkinsfile.dev b/jenkins/favourite-service/Jenkinsfile.dev new file mode 100644 index 000000000..b64dcc6d0 --- /dev/null +++ b/jenkins/favourite-service/Jenkinsfile.dev @@ -0,0 +1,84 @@ +pipeline { + agent any + + tools { + maven 'Maven3' + } + + environment { + SERVICE_NAME = 'favourite-service' + IMAGE_NAME = "juanse201/${SERVICE_NAME}:dev" + } + + stages { + stage('Checkout') { + steps { + checkout scm + } + } + + stage('Debug Maven') { + steps { + sh 'docker --version' + sh 'which mvn || echo "Maven not found"' + sh 'mvn --version || echo "Maven command failed"' + sh 'java --version || echo "java command failed"' + sh 'echo "--------------------------------------------"' + sh 'grep docker /etc/group' + sh ''' + echo "Current user: $(whoami)" + echo "Groups: $(groups)" + echo "Docker version (if accessible):" + if docker info > /dev/null 2>&1; then + docker version + echo "Docker socket is accessible ✅" + else + echo "Docker socket is NOT accessible ❌" + fi + ''' + } + } + + stage('Test') { + steps { + dir("${SERVICE_NAME}") { + sh 'mvn clean test' + } + } + } + + stage('Build') { + steps { + dir("${SERVICE_NAME}") { + sh 'mvn clean package -DskipTests' + sh 'ls -l target/' + } + } + } + + stage('Build Docker Image') { + steps { + // Build from workspace root (.), using Dockerfile from service dir + sh "docker build -t ${IMAGE_NAME} -f ${SERVICE_NAME}/Dockerfile ." + } + } + + stage('Push to DockerHub') { + steps { + // Use the global credentials with ID 'docker' + withCredentials([usernamePassword(credentialsId: 'docker', usernameVariable: 'DOCKER_USER', passwordVariable: 'DOCKER_PASS')]) { + sh ''' + # Login to Docker Hub + echo "$DOCKER_PASS" | docker login -u "$DOCKER_USER" --password-stdin + + # Push the image + docker push $IMAGE_NAME + + # Logout (optional but good practice) + docker logout + ''' + } + } + } + } +} \ No newline at end of file diff --git a/jenkins/master/Jenkinsfile.prod b/jenkins/master/Jenkinsfile.prod new file mode 100644 index 000000000..3f99beacb --- /dev/null +++ b/jenkins/master/Jenkinsfile.prod @@ -0,0 +1,110 @@ +pipeline { + agent any + + tools { + maven 'Maven3' + } + + environment { + DOCKER_NAME = "juanse201/" + IMAGE_TAG = "stage" + S1 = "order-service" + S2 = "user-service" + S3 = "payment-service" + S4 = "product-service" + S5 = "favourite-service" + S6 = "cloud-config" + S7 = "service-discovery" + S8 = "zipkin" + } + + stages { + + stage('Pre-Cleanup') { + steps { + sh ''' + kubectl delete -n prod -f k8/master/user-service.yaml || true + kubectl delete -n prod -f k8/master/order-service.yaml || true + kubectl delete -n prod -f k8/master/payment-service.yaml || true + kubectl delete -n prod -f k8/master/product-service.yaml || true + kubectl delete -n prod -f k8/master/favourite-service.yaml || true + kubectl delete -n prod -f k8/master/cloud-config.yaml || true + kubectl delete -n prod -f k8/master/service-discovery.yaml || true + kubectl delete -n prod -f k8/master/zipkin.yaml || true + ''' + } + } + // ... Checkout, Test, Build stages ... + + stage('check versions'){ + steps { + sh 'kubectl version' + sh 'which mvn || echo "Maven not found"' + sh 'mvn --version || echo "Maven command failed"' + sh ''' + echo "Current user: $(whoami)" + echo "Groups: $(groups)" + echo "Docker version (if accessible):" + if docker info > /dev/null 2>&1; then + docker version + echo "Docker socket is accessible ✅" + else + echo "Docker socket is NOT accessible ❌" + fi + ''' + } + } + + stage('Build & Push Image') { + steps { + withCredentials([usernamePassword(credentialsId: 'docker', usernameVariable: 'DOCKER_USER', passwordVariable: 'DOCKER_PASS')]) { + sh ''' + echo "$DOCKER_PASS" | docker login -u "$DOCKER_USER" --password-stdin + docker pull "${DOCKER_NAME}""${S1}-stage" + docker pull "${DOCKER_NAME}""${S2}-stage" + docker pull "${DOCKER_NAME}""${S3}-stage" + docker pull "${DOCKER_NAME}""${S4}-stage" + docker pull "${DOCKER_NAME}""${S5}-stage" + docker pull "${DOCKER_NAME}""${S6}-stage" + docker pull "${DOCKER_NAME}""${S7}-stage" + docker logout + ''' + } + } + } + + stage('Deploy Test Slice') { + steps { + // Update image tag in YAML dynamically + sh ''' + kubectl apply -n prod -f k8/master/user-service.yaml || true + kubectl apply -n prod -f k8/master/order-service.yaml || true + kubectl apply -n prod -f k8/master/payment-service.yaml || true + kubectl apply -n prod -f k8/master/product-service.yaml || true + kubectl apply -n prod -f k8/master/favourite-service.yaml || true + kubectl apply -n prod -f k8/master/cloud-config.yaml || true + kubectl apply -n prod -f k8/master/service-discovery.yaml || true + kubectl apply -n prod -f k8/master/zipkin.yaml || true + + kubectl get pods + kubectl get svc -n default + ''' + sh 'sleep 20' + } + } + + stage('Generate Release Notes') { + steps { + sh """ + chmod +x release-notes/generate-release-notes.sh + ./release-notes/generate-release-notes.sh \ + \${BUILD_NUMBER} \ + production \ + ${S1} ${S2} ${S3} ${S4} \ + ${S5} ${S6} ${S7} ${S8} + """ + archiveArtifacts artifacts: 'release-notes/RELEASE-*.md', fingerprint: true + } + } + } +} \ No newline at end of file diff --git a/jenkins/order-service/Jenkinsfile.dev b/jenkins/order-service/Jenkinsfile.dev new file mode 100644 index 000000000..f38f9f5b4 --- /dev/null +++ b/jenkins/order-service/Jenkinsfile.dev @@ -0,0 +1,84 @@ +pipeline { + agent any + + tools { + maven 'Maven3' + } + + environment { + SERVICE_NAME = 'order-service' + IMAGE_NAME = "juanse201/${SERVICE_NAME}:dev" + } + + stages { + stage('Checkout') { + steps { + checkout scm + } + } + + stage('Debug Maven') { + steps { + sh 'docker --version' + sh 'which mvn || echo "Maven not found"' + sh 'mvn --version || echo "Maven command failed"' + sh 'java --version || echo "java command failed"' + sh 'echo "--------------------------------------------"' + sh 'grep docker /etc/group' + sh ''' + echo "Current user: $(whoami)" + echo "Groups: $(groups)" + echo "Docker version (if accessible):" + if docker info > /dev/null 2>&1; then + docker version + echo "Docker socket is accessible ✅" + else + echo "Docker socket is NOT accessible ❌" + fi + ''' + } + } + + stage('Test') { + steps { + dir("${SERVICE_NAME}") { + sh 'mvn clean test' + } + } + } + + stage('Build') { + steps { + dir("${SERVICE_NAME}") { + sh 'mvn clean package -DskipTests' + sh 'ls -l target/' + } + } + } + + stage('Build Docker Image') { + steps { + // Build from workspace root (.), using Dockerfile from service dir + sh "docker build -t ${IMAGE_NAME} -f ${SERVICE_NAME}/Dockerfile ." + } + } + + stage('Push to DockerHub') { + steps { + // Use the global credentials with ID 'docker' + withCredentials([usernamePassword(credentialsId: 'docker', usernameVariable: 'DOCKER_USER', passwordVariable: 'DOCKER_PASS')]) { + sh ''' + # Login to Docker Hub + echo "$DOCKER_PASS" | docker login -u "$DOCKER_USER" --password-stdin + + # Push the image + docker push $IMAGE_NAME + + # Logout (optional but good practice) + docker logout + ''' + } + } + } + } +} \ No newline at end of file diff --git a/jenkins/payment-service/Jenkinsfile.dev b/jenkins/payment-service/Jenkinsfile.dev new file mode 100644 index 000000000..819c6329c --- /dev/null +++ b/jenkins/payment-service/Jenkinsfile.dev @@ -0,0 +1,84 @@ +pipeline { + agent any + + tools { + maven 'Maven3' + } + + environment { + SERVICE_NAME = 'payment-service' + IMAGE_NAME = "juanse201/${SERVICE_NAME}:dev" + } + + stages { + stage('Checkout') { + steps { + checkout scm + } + } + + stage('Debug Maven') { + steps { + sh 'docker --version' + sh 'which mvn || echo "Maven not found"' + sh 'mvn --version || echo "Maven command failed"' + sh 'java --version || echo "java command failed"' + sh 'echo "--------------------------------------------"' + sh 'grep docker /etc/group' + sh ''' + echo "Current user: $(whoami)" + echo "Groups: $(groups)" + echo "Docker version (if accessible):" + if docker info > /dev/null 2>&1; then + docker version + echo "Docker socket is accessible ✅" + else + echo "Docker socket is NOT accessible ❌" + fi + ''' + } + } + + stage('Test') { + steps { + dir("${SERVICE_NAME}") { + sh 'mvn clean test' + } + } + } + + stage('Build') { + steps { + dir("${SERVICE_NAME}") { + sh 'mvn clean package -DskipTests' + sh 'ls -l target/' + } + } + } + + stage('Build Docker Image') { + steps { + // Build from workspace root (.), using Dockerfile from service dir + sh "docker build -t ${IMAGE_NAME} -f ${SERVICE_NAME}/Dockerfile ." + } + } + + stage('Push to DockerHub') { + steps { + // Use the global credentials with ID 'docker' + withCredentials([usernamePassword(credentialsId: 'docker', usernameVariable: 'DOCKER_USER', passwordVariable: 'DOCKER_PASS')]) { + sh ''' + # Login to Docker Hub + echo "$DOCKER_PASS" | docker login -u "$DOCKER_USER" --password-stdin + + # Push the image + docker push $IMAGE_NAME + + # Logout (optional but good practice) + docker logout + ''' + } + } + } + } +} \ No newline at end of file diff --git a/jenkins/plugins.txt b/jenkins/plugins.txt new file mode 100644 index 000000000..12b5344d3 --- /dev/null +++ b/jenkins/plugins.txt @@ -0,0 +1,5 @@ +github-branch-source +docker-workflow +kubernetes +workflow-aggregator +junit diff --git a/jenkins/product-service/Jenkinsfile.dev b/jenkins/product-service/Jenkinsfile.dev new file mode 100644 index 000000000..ce7ce891b --- /dev/null +++ b/jenkins/product-service/Jenkinsfile.dev @@ -0,0 +1,84 @@ +pipeline { + agent any + + tools { + maven 'Maven3' + } + + environment { + SERVICE_NAME = 'product-service' + IMAGE_NAME = "juanse201/${SERVICE_NAME}:dev" + } + + stages { + stage('Checkout') { + steps { + checkout scm + } + } + + stage('Debug Maven') { + steps { + sh 'docker --version' + sh 'which mvn || echo "Maven not found"' + sh 'mvn --version || echo "Maven command failed"' + sh 'java --version || echo "java command failed"' + sh 'echo "--------------------------------------------"' + sh 'grep docker /etc/group' + sh ''' + echo "Current user: $(whoami)" + echo "Groups: $(groups)" + echo "Docker version (if accessible):" + if docker info > /dev/null 2>&1; then + docker version + echo "Docker socket is accessible ✅" + else + echo "Docker socket is NOT accessible ❌" + fi + ''' + } + } + + stage('Test') { + steps { + dir("${SERVICE_NAME}") { + sh 'mvn clean test' + } + } + } + + stage('Build') { + steps { + dir("${SERVICE_NAME}") { + sh 'mvn clean package -DskipTests' + sh 'ls -l target/' + } + } + } + + stage('Build Docker Image') { + steps { + // Build from workspace root (.), using Dockerfile from service dir + sh "docker build -t ${IMAGE_NAME} -f ${SERVICE_NAME}/Dockerfile ." + } + } + + stage('Push to DockerHub') { + steps { + // Use the global credentials with ID 'docker' + withCredentials([usernamePassword(credentialsId: 'docker', usernameVariable: 'DOCKER_USER', passwordVariable: 'DOCKER_PASS')]) { + sh ''' + # Login to Docker Hub + echo "$DOCKER_PASS" | docker login -u "$DOCKER_USER" --password-stdin + + # Push the image + docker push $IMAGE_NAME + + # Logout (optional but good practice) + docker logout + ''' + } + } + } + } +} \ No newline at end of file diff --git a/jenkins/service-discovery/Jenkinsfile.dev b/jenkins/service-discovery/Jenkinsfile.dev new file mode 100644 index 000000000..4a3444812 --- /dev/null +++ b/jenkins/service-discovery/Jenkinsfile.dev @@ -0,0 +1,84 @@ +pipeline { + agent any + + tools { + maven 'Maven3' + } + + environment { + SERVICE_NAME = 'service-discovery' + IMAGE_NAME = "juanse201/${SERVICE_NAME}:dev" + } + + stages { + stage('Checkout') { + steps { + checkout scm + } + } + + stage('Debug Maven') { + steps { + sh 'docker --version' + sh 'which mvn || echo "Maven not found"' + sh 'mvn --version || echo "Maven command failed"' + sh 'java --version || echo "java command failed"' + sh 'echo "--------------------------------------------"' + sh 'grep docker /etc/group' + sh ''' + echo "Current user: $(whoami)" + echo "Groups: $(groups)" + echo "Docker version (if accessible):" + if docker info > /dev/null 2>&1; then + docker version + echo "Docker socket is accessible ✅" + else + echo "Docker socket is NOT accessible ❌" + fi + ''' + } + } + + stage('Test') { + steps { + dir("${SERVICE_NAME}") { + sh 'mvn clean test' + } + } + } + + stage('Build') { + steps { + dir("${SERVICE_NAME}") { + sh 'mvn clean package -DskipTests' + sh 'ls -l target/' + } + } + } + + stage('Build Docker Image') { + steps { + // Build from workspace root (.), using Dockerfile from service dir + sh "docker build -t ${IMAGE_NAME} -f ${SERVICE_NAME}/Dockerfile ." + } + } + + stage('Push to DockerHub') { + steps { + // Use the global credentials with ID 'docker' + withCredentials([usernamePassword(credentialsId: 'docker', usernameVariable: 'DOCKER_USER', passwordVariable: 'DOCKER_PASS')]) { + sh ''' + # Login to Docker Hub + echo "$DOCKER_PASS" | docker login -u "$DOCKER_USER" --password-stdin + + # Push the image + docker push $IMAGE_NAME + + # Logout (optional but good practice) + docker logout + ''' + } + } + } + } +} \ No newline at end of file diff --git a/jenkins/stage/Jenkinsfile.stage b/jenkins/stage/Jenkinsfile.stage new file mode 100644 index 000000000..7ce8d977a --- /dev/null +++ b/jenkins/stage/Jenkinsfile.stage @@ -0,0 +1,187 @@ +pipeline { + agent any + + tools { + maven 'Maven3' + } + + environment { + DOCKER_NAME = "juanse201/" + IMAGE_TAG = "stage" + S1 = "order-service" + S2 = "user-service" + S3 = "payment-service" + S4 = "product-service" + S5 = "favourite-service" + S6 = "cloud-config" + S7 = "service-discovery" + S8 = "zipkin" + } + + stages { + + stage('Pre-Cleanup') { + steps { + sh ''' + kubectl delete -f k8/stage/user-service.yaml || true + kubectl delete -f k8/stage/order-service.yaml || true + kubectl delete -f k8/stage/payment-service.yaml || true + kubectl delete -f k8/stage/product-service.yaml || true + kubectl delete -f k8/stage/favourite-service.yaml || true + kubectl delete -f k8/stage/cloud-config.yaml || true + kubectl delete -f k8/stage/service-discovery.yaml || true + kubectl delete -f k8/stage/zipkin.yaml || true + ''' + } + } + // ... Checkout, Test, Build stages ... + + stage('check versions'){ + steps { + sh 'kubectl version' + sh 'python3 --version || echo "Python not found"' + sh 'which mvn || echo "Maven not found"' + sh 'mvn --version || echo "Maven command failed"' + sh ''' + echo "Current user: $(whoami)" + echo "Groups: $(groups)" + echo "Docker version (if accessible):" + if docker info > /dev/null 2>&1; then + docker version + echo "Docker socket is accessible ✅" + else + echo "Docker socket is NOT accessible ❌" + fi + ''' + } + } + + stage('Build & Push Image') { + steps { + withCredentials([usernamePassword(credentialsId: 'docker', usernameVariable: 'DOCKER_USER', passwordVariable: 'DOCKER_PASS')]) { + sh ''' + echo "$DOCKER_PASS" | docker login -u "$DOCKER_USER" --password-stdin + docker pull "${DOCKER_NAME}""${S1}:dev" + docker pull "${DOCKER_NAME}""${S2}:dev" + docker pull "${DOCKER_NAME}""${S3}:dev" + docker pull "${DOCKER_NAME}""${S4}:dev" + docker pull "${DOCKER_NAME}""${S5}:dev" + docker pull "${DOCKER_NAME}""${S6}:dev" + docker pull "${DOCKER_NAME}""${S7}:dev" + docker logout + ''' + } + } + } + + stage('Deploy Infrastructure Services') { + steps { + sh ''' + kubectl apply -f k8/stage/zipkin.yaml + kubectl apply -f k8/stage/cloud-config.yaml + kubectl apply -f k8/stage/service-discovery.yaml + kubectl wait --for=condition=ready pod -l app=service-discovery --timeout=300s + sleep 30 # Esperar a que Eureka esté completamente operativo + ''' + } + } + + stage('Deploy Business Services') { + steps { + sh ''' + kubectl apply -f k8/stage/user-service.yaml + kubectl apply -f k8/stage/product-service.yaml + kubectl apply -f k8/stage/payment-service.yaml + kubectl apply -f k8/stage/favourite-service.yaml + kubectl apply -f k8/stage/order-service.yaml + kubectl wait --for=condition=ready pod --all --timeout=300s + ''' + } + } + + stage('Debug Eureka Registration') { + steps { + sh 'kubectl get pods' + sh 'kubectl get svc' + sh 'kubectl logs deployment/service-discovery || true' + sh 'kubectl logs deployment/order-service || true' + sh 'kubectl logs deployment/payment-service || true' + } + } + + stage('Run E2E Tests') { + steps { + dir('globaltests') { + sh 'mvn test -Dtest=OrderServiceE2ETest' + sh 'mvn test -Dtest=UserServiceE2ETest' + sh 'mvn test -Dtest=PaymentServiceE2ETest' + sh 'mvn test -Dtest=ProductServiceE2ETest' + sh 'mvn test -Dtest=FavouriteServiceE2ETest' + sh 'mvn test -Dtest=CloudConfigE2ETest' + sh 'mvn test -Dtest=SystemE2ESmokeTest' + } + } + } + + stage ('Run Performance Tests') { + steps { + dir('globaltests/rendimiento') { + sh ''' + # Make script executable + chmod +x run_tests.sh + + # Run performance tests + ./run_tests.sh + ''' + } + } + } + + stage('Promote to Stage') { + steps { + withCredentials([usernamePassword(credentialsId: 'docker', usernameVariable: 'DOCKER_USER', passwordVariable: 'DOCKER_PASS')]) { + sh ''' + docker pull "${DOCKER_NAME}""${S1}:dev" + docker pull "${DOCKER_NAME}""${S2}:dev" + docker pull "${DOCKER_NAME}""${S3}:dev" + docker pull "${DOCKER_NAME}""${S4}:dev" + docker pull "${DOCKER_NAME}""${S5}:dev" + docker pull "${DOCKER_NAME}""${S6}:dev" + docker pull "${DOCKER_NAME}""${S7}:dev" + docker tag "${DOCKER_NAME}""${S1}:dev" juanse201/order-service-stage + docker tag "${DOCKER_NAME}""${S2}:dev" juanse201/user-service-stage + docker tag "${DOCKER_NAME}""${S3}:dev" juanse201/payment-service-stage + docker tag "${DOCKER_NAME}""${S4}:dev" juanse201/product-service-stage + docker tag "${DOCKER_NAME}""${S5}:dev" juanse201/favourite-service-stage + docker tag "${DOCKER_NAME}""${S6}:dev" juanse201/cloud-config-stage + docker tag "${DOCKER_NAME}""${S7}:dev" juanse201/service-discovery-stage + echo "$DOCKER_PASS" | docker login -u "$DOCKER_USER" --password-stdin + docker push juanse201/order-service-stage + docker push juanse201/user-service-stage + docker push juanse201/payment-service-stage + docker push juanse201/product-service-stage + docker push juanse201/favourite-service-stage + docker push juanse201/cloud-config-stage + docker push juanse201/service-discovery-stage + docker logout + ''' + } + } + } + + stage('Cleanup') { + steps { + sh ''' + kubectl delete -f k8/stage/user-service.yaml || true + kubectl delete -f k8/stage/order-service.yaml || true + kubectl delete -f k8/stage/payment-service.yaml || true + kubectl delete -f k8/stage/product-service.yaml || true + kubectl delete -f k8/stage/favourite-service.yaml || true + kubectl delete -f k8/stage/cloud-config.yaml || true + kubectl delete -f k8/stage/service-discovery.yaml || true + kubectl delete -f k8/stage/zipkin.yaml || true + ''' + } + } + } +} \ No newline at end of file diff --git a/jenkins/user-service/Jenkinsfile.dev b/jenkins/user-service/Jenkinsfile.dev new file mode 100644 index 000000000..6b47977bb --- /dev/null +++ b/jenkins/user-service/Jenkinsfile.dev @@ -0,0 +1,84 @@ +pipeline { + agent any + + tools { + maven 'Maven3' + } + + environment { + SERVICE_NAME = 'user-service' + IMAGE_NAME = "juanse201/${SERVICE_NAME}:dev" + } + + stages { + stage('Checkout') { + steps { + checkout scm + } + } + + stage('Debug Maven') { + steps { + sh 'docker --version' + sh 'which mvn || echo "Maven not found"' + sh 'mvn --version || echo "Maven command failed"' + sh 'java --version || echo "java command failed"' + sh 'echo "--------------------------------------------"' + sh 'grep docker /etc/group' + sh ''' + echo "Current user: $(whoami)" + echo "Groups: $(groups)" + echo "Docker version (if accessible):" + if docker info > /dev/null 2>&1; then + docker version + echo "Docker socket is accessible ✅" + else + echo "Docker socket is NOT accessible ❌" + fi + ''' + } + } + + stage('Test') { + steps { + dir("${SERVICE_NAME}") { + sh 'mvn clean test' + } + } + } + + stage('Build') { + steps { + dir("${SERVICE_NAME}") { + sh 'mvn clean package -DskipTests' + sh 'ls -l target/' + } + } + } + + stage('Build Docker Image') { + steps { + // Build from workspace root (.), using Dockerfile from service dir + sh "docker build -t ${IMAGE_NAME} -f ${SERVICE_NAME}/Dockerfile ." + } + } + + stage('Push to DockerHub') { + steps { + // Use the global credentials with ID 'docker' + withCredentials([usernamePassword(credentialsId: 'docker', usernameVariable: 'DOCKER_USER', passwordVariable: 'DOCKER_PASS')]) { + sh ''' + # Login to Docker Hub + echo "$DOCKER_PASS" | docker login -u "$DOCKER_USER" --password-stdin + + # Push the image + docker push $IMAGE_NAME + + # Logout (optional but good practice) + docker logout + ''' + } + } + } + } +} \ No newline at end of file diff --git a/k8/jenkins/jenkins.yaml b/k8/jenkins/jenkins.yaml new file mode 100644 index 000000000..25380baad --- /dev/null +++ b/k8/jenkins/jenkins.yaml @@ -0,0 +1,97 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: jenkins + labels: + app: jenkins +spec: + replicas: 1 + selector: + matchLabels: + app: jenkins + template: + metadata: + labels: + app: jenkins + spec: + serviceAccountName: jenkins + containers: + - name: jenkins + image: ecommerce-microservice-backend-app-jenkins:latest + imagePullPolicy: IfNotPresent # Important! So it uses your local image + ports: + - containerPort: 8081 + - containerPort: 50000 + env: + - name: JENKINS_OPTS + value: "--httpPort=8081" + volumeMounts: + - name: jenkins-home + mountPath: /var/jenkins_home + - name: docker-sock + mountPath: /var/run/docker.sock + volumes: + - name: jenkins-home + persistentVolumeClaim: + claimName: jenkins-pvc + - name: docker-sock + hostPath: + path: /var/run/docker.sock +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: jenkins-pvc +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 5Gi +--- +apiVersion: v1 +kind: Service +metadata: + name: jenkins +spec: + type: NodePort + selector: + app: jenkins + ports: + - name: http + port: 8081 + targetPort: 8081 + nodePort: 32001 + +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: jenkins + namespace: default + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: jenkins-deployer + namespace: default +rules: +- apiGroups: ["", "apps"] + resources: ["pods", "services", "deployments"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete", "apply"] + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: jenkins-deployer-binding + namespace: default +subjects: +- kind: ServiceAccount + name: jenkins + namespace: default +roleRef: + kind: Role + name: jenkins-deployer + apiGroup: rbac.authorization.k8s.io \ No newline at end of file diff --git a/k8/master/cloud-config.yaml b/k8/master/cloud-config.yaml new file mode 100644 index 000000000..30b44d423 --- /dev/null +++ b/k8/master/cloud-config.yaml @@ -0,0 +1,46 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: cloud-config +spec: + replicas: 1 + selector: + matchLabels: + app: cloud-config + template: + metadata: + labels: + app: cloud-config + spec: + containers: + - name: cloud-config + # This will be overridden by Jenkins with actual tag (e.g., pr-123) + image: juanse201/cloud-config:dev + imagePullPolicy: Always + ports: + - containerPort: 9296 + env: + - name: SPRING_PROFILES_ACTIVE + value: "dev" + - name: SPRING_ZIPKIN_BASE_URL + value: "http://zipkin:9411" + - name: EUREKA_CLIENT_SERVICEURL_DEFAULTZONE + value: "http://service-discovery:8761/eureka/" + readinessProbe: + httpGet: + path: /actuator/health + port: 9296 + initialDelaySeconds: 10 + periodSeconds: 5 +--- +apiVersion: v1 +kind: Service +metadata: + name: cloud-config +spec: + type: ClusterIP + ports: + - port: 9296 + targetPort: 9296 + selector: + app: cloud-config \ No newline at end of file diff --git a/k8/master/favourite-service.yaml b/k8/master/favourite-service.yaml new file mode 100644 index 000000000..f1245f18d --- /dev/null +++ b/k8/master/favourite-service.yaml @@ -0,0 +1,61 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: favourite-service +spec: + replicas: 1 + selector: + matchLabels: + app: favourite-service + template: + metadata: + labels: + app: favourite-service + spec: + containers: + - name: favourite-service + # This will be overridden by Jenkins with actual tag (e.g., pr-123) + image: juanse201/favourite-service:dev + imagePullPolicy: Always + ports: + - containerPort: 8800 + env: + - name: SPRING_PROFILES_ACTIVE + value: "dev" + - name: SPRING_ZIPKIN_BASE_URL + value: "http://zipkin:9411" + - name: EUREKA_CLIENT_SERVICEURL_DEFAULTZONE + value: "http://service-discovery:8761/eureka/" + - name: SPRING_CONFIG_IMPORT + value: "optional:configserver:http://cloud-config:9296/" + - name: EUREKA_CLIENT_REGISTER_WITH_EUREKA + value: "true" + - name: EUREKA_CLIENT_FETCH_REGISTRY + value: "true" + - name: SPRING_JPA_HIBERNATE_DDL_AUTO + value: "update" + - name: EUREKA_INSTANCE_PREFER_IP_ADDRESS + value: "true" + - name: EUREKA_INSTANCE_HOSTNAME + value: "favourite-service" + - name: EUREKA_INSTANCE_NON_SECURE_PORT + value: "8800" + readinessProbe: + httpGet: + path: /favourite-service/actuator/health + port: 8800 + initialDelaySeconds: 10 + periodSeconds: 5 + +--- +apiVersion: v1 +kind: Service +metadata: + name: favourite-service +spec: + type: ClusterIP + ports: + - port: 8800 + targetPort: 8800 + selector: + app: favourite-service \ No newline at end of file diff --git a/k8/master/order-service.yaml b/k8/master/order-service.yaml new file mode 100644 index 000000000..6db83fe69 --- /dev/null +++ b/k8/master/order-service.yaml @@ -0,0 +1,60 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: order-service +spec: + replicas: 1 + selector: + matchLabels: + app: order-service + template: + metadata: + labels: + app: order-service + spec: + containers: + - name: order-service + # This will be overridden by Jenkins with actual tag (e.g., pr-123) + image: juanse201/order-service:dev + imagePullPolicy: Always + ports: + - containerPort: 8300 + env: + - name: SPRING_PROFILES_ACTIVE + value: "dev" + - name: SPRING_ZIPKIN_BASE_URL + value: "http://zipkin:9411" + - name: EUREKA_CLIENT_SERVICEURL_DEFAULTZONE + value: "http://service-discovery:8761/eureka/" + - name: SPRING_CONFIG_IMPORT + value: "optional:configserver:http://cloud-config:9296/" + - name: EUREKA_CLIENT_REGISTER_WITH_EUREKA + value: "true" + - name: EUREKA_CLIENT_FETCH_REGISTRY + value: "true" + - name: SPRING_JPA_HIBERNATE_DDL_AUTO + value: "update" + - name: EUREKA_INSTANCE_PREFER_IP_ADDRESS + value: "true" + - name: EUREKA_INSTANCE_HOSTNAME + value: "order-service" + - name: EUREKA_INSTANCE_NON_SECURE_PORT + value: "8300" + readinessProbe: + httpGet: + path: /order-service/actuator/health + port: 8300 + initialDelaySeconds: 10 + periodSeconds: 5 +--- +apiVersion: v1 +kind: Service +metadata: + name: order-service +spec: + type: ClusterIP + ports: + - port: 8300 + targetPort: 8300 + selector: + app: order-service \ No newline at end of file diff --git a/k8/master/payment-service.yaml b/k8/master/payment-service.yaml new file mode 100644 index 000000000..71078a3df --- /dev/null +++ b/k8/master/payment-service.yaml @@ -0,0 +1,60 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: payment-service +spec: + replicas: 1 + selector: + matchLabels: + app: payment-service + template: + metadata: + labels: + app: payment-service + spec: + containers: + - name: payment-service + # This will be overridden by Jenkins with actual tag (e.g., pr-123) + image: juanse201/payment-service:dev + imagePullPolicy: Always + ports: + - containerPort: 8400 + env: + - name: SPRING_PROFILES_ACTIVE + value: "dev" + - name: SPRING_ZIPKIN_BASE_URL + value: "http://zipkin:9411" + - name: EUREKA_CLIENT_SERVICEURL_DEFAULTZONE + value: "http://service-discovery:8761/eureka/" + - name: SPRING_CONFIG_IMPORT + value: "optional:configserver:http://cloud-config:9296/" + - name: EUREKA_CLIENT_REGISTER_WITH_EUREKA + value: "true" + - name: EUREKA_CLIENT_FETCH_REGISTRY + value: "true" + - name: SPRING_JPA_HIBERNATE_DDL_AUTO + value: "update" + - name: EUREKA_INSTANCE_PREFER_IP_ADDRESS + value: "true" + - name: EUREKA_INSTANCE_HOSTNAME + value: "payment-service" + - name: EUREKA_INSTANCE_NON_SECURE_PORT + value: "8400" + readinessProbe: + httpGet: + path: /payment-service/actuator/health + port: 8400 + initialDelaySeconds: 10 + periodSeconds: 5 +--- +apiVersion: v1 +kind: Service +metadata: + name: payment-service +spec: + type: ClusterIP + ports: + - port: 8400 + targetPort: 8400 + selector: + app: payment-service \ No newline at end of file diff --git a/k8/master/product-service.yaml b/k8/master/product-service.yaml new file mode 100644 index 000000000..37a0ab7cb --- /dev/null +++ b/k8/master/product-service.yaml @@ -0,0 +1,60 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: product-service +spec: + replicas: 1 + selector: + matchLabels: + app: product-service + template: + metadata: + labels: + app: product-service + spec: + containers: + - name: product-service + # This will be overridden by Jenkins with actual tag (e.g., pr-123) + image: juanse201/product-service:dev + imagePullPolicy: Always + ports: + - containerPort: 8500 + env: + - name: SPRING_PROFILES_ACTIVE + value: "dev" + - name: SPRING_ZIPKIN_BASE_URL + value: "http://zipkin:9411" + - name: EUREKA_CLIENT_SERVICEURL_DEFAULTZONE + value: "http://service-discovery:8761/eureka/" + - name: SPRING_CONFIG_IMPORT + value: "optional:configserver:http://cloud-config:9296/" + - name: EUREKA_CLIENT_REGISTER_WITH_EUREKA + value: "true" + - name: EUREKA_CLIENT_FETCH_REGISTRY + value: "true" + - name: SPRING_JPA_HIBERNATE_DDL_AUTO + value: "update" + - name: EUREKA_INSTANCE_PREFER_IP_ADDRESS + value: "true" + - name: EUREKA_INSTANCE_HOSTNAME + value: "product-service" + - name: EUREKA_INSTANCE_NON_SECURE_PORT + value: "8500" + readinessProbe: + httpGet: + path: /product-service/actuator/health + port: 8500 + initialDelaySeconds: 10 + periodSeconds: 5 +--- +apiVersion: v1 +kind: Service +metadata: + name: product-service +spec: + type: ClusterIP + ports: + - port: 8500 + targetPort: 8500 + selector: + app: product-service \ No newline at end of file diff --git a/k8/master/service-discovery.yaml b/k8/master/service-discovery.yaml new file mode 100644 index 000000000..3364034fb --- /dev/null +++ b/k8/master/service-discovery.yaml @@ -0,0 +1,43 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: service-discovery +spec: + replicas: 1 + selector: + matchLabels: + app: service-discovery + template: + metadata: + labels: + app: service-discovery + spec: + containers: + - name: service-discovery + image: juanse201/service-discovery:dev + imagePullPolicy: Always + ports: + - containerPort: 8761 + env: + - name: SPRING_PROFILES_ACTIVE + value: "dev" + - name: SPRING_ZIPKIN_BASE_URL + value: "http://zipkin:9411" + readinessProbe: + httpGet: + path: /actuator/health + port: 8761 + initialDelaySeconds: 10 + periodSeconds: 5 +--- +apiVersion: v1 +kind: Service +metadata: + name: service-discovery +spec: + type: ClusterIP + ports: + - port: 8761 + targetPort: 8761 + selector: + app: service-discovery \ No newline at end of file diff --git a/k8/master/user-service.yaml b/k8/master/user-service.yaml new file mode 100644 index 000000000..bf72c4669 --- /dev/null +++ b/k8/master/user-service.yaml @@ -0,0 +1,60 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: user-service +spec: + replicas: 1 + selector: + matchLabels: + app: user-service + template: + metadata: + labels: + app: user-service + spec: + containers: + - name: user-service + # This will be overridden by Jenkins with actual tag (e.g., pr-123) + image: juanse201/user-service:dev + imagePullPolicy: Always + ports: + - containerPort: 8700 + env: + - name: SPRING_PROFILES_ACTIVE + value: "dev" + - name: SPRING_ZIPKIN_BASE_URL + value: "http://zipkin:9411" + - name: EUREKA_CLIENT_SERVICEURL_DEFAULTZONE + value: "http://service-discovery:8761/eureka/" + - name: SPRING_CONFIG_IMPORT + value: "optional:configserver:http://cloud-config:9296/" + - name: EUREKA_CLIENT_REGISTER_WITH_EUREKA + value: "true" + - name: EUREKA_CLIENT_FETCH_REGISTRY + value: "true" + - name: SPRING_JPA_HIBERNATE_DDL_AUTO + value: "update" + - name: EUREKA_INSTANCE_PREFER_IP_ADDRESS + value: "true" + - name: EUREKA_INSTANCE_HOSTNAME + value: "user-service" + - name: EUREKA_INSTANCE_NON_SECURE_PORT + value: "8700" + readinessProbe: + httpGet: + path: /user-service/actuator/health + port: 8700 + initialDelaySeconds: 10 + periodSeconds: 5 +--- +apiVersion: v1 +kind: Service +metadata: + name: user-service +spec: + type: ClusterIP + ports: + - port: 8700 + targetPort: 8700 + selector: + app: user-service \ No newline at end of file diff --git a/k8/master/zipkin.yaml b/k8/master/zipkin.yaml new file mode 100644 index 000000000..e922dfe80 --- /dev/null +++ b/k8/master/zipkin.yaml @@ -0,0 +1,31 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: zipkin +spec: + replicas: 1 + selector: + matchLabels: + app: zipkin + template: + metadata: + labels: + app: zipkin + spec: + containers: + - name: zipkin + image: openzipkin/zipkin:2.24 + ports: + - containerPort: 9411 +--- +apiVersion: v1 +kind: Service +metadata: + name: zipkin +spec: + type: ClusterIP + ports: + - port: 9411 + targetPort: 9411 + selector: + app: zipkin \ No newline at end of file diff --git a/k8/sonarqube/sonarqube.yaml b/k8/sonarqube/sonarqube.yaml new file mode 100644 index 000000000..6cee9746f --- /dev/null +++ b/k8/sonarqube/sonarqube.yaml @@ -0,0 +1,50 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: sonarqube +spec: + replicas: 1 + selector: + matchLabels: + app: sonarqube + template: + metadata: + labels: + app: sonarqube + spec: + containers: + - name: sonarqube + image: sonarqube:latest + ports: + - containerPort: 9000 + volumeMounts: + - name: sonarqube-data + mountPath: /opt/sonarqube/data + volumes: + - name: sonarqube-data + persistentVolumeClaim: + claimName: sonarqube-data-pvc +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: sonarqube-data-pvc +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 5Gi +--- +apiVersion: v1 +kind: Service +metadata: + name: sonarqube +spec: + type: NodePort + ports: + - port: 9000 + targetPort: 9000 + nodePort: 32002 + selector: + app: sonarqube \ No newline at end of file diff --git a/k8/stage/cloud-config.yaml b/k8/stage/cloud-config.yaml new file mode 100644 index 000000000..30b44d423 --- /dev/null +++ b/k8/stage/cloud-config.yaml @@ -0,0 +1,46 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: cloud-config +spec: + replicas: 1 + selector: + matchLabels: + app: cloud-config + template: + metadata: + labels: + app: cloud-config + spec: + containers: + - name: cloud-config + # This will be overridden by Jenkins with actual tag (e.g., pr-123) + image: juanse201/cloud-config:dev + imagePullPolicy: Always + ports: + - containerPort: 9296 + env: + - name: SPRING_PROFILES_ACTIVE + value: "dev" + - name: SPRING_ZIPKIN_BASE_URL + value: "http://zipkin:9411" + - name: EUREKA_CLIENT_SERVICEURL_DEFAULTZONE + value: "http://service-discovery:8761/eureka/" + readinessProbe: + httpGet: + path: /actuator/health + port: 9296 + initialDelaySeconds: 10 + periodSeconds: 5 +--- +apiVersion: v1 +kind: Service +metadata: + name: cloud-config +spec: + type: ClusterIP + ports: + - port: 9296 + targetPort: 9296 + selector: + app: cloud-config \ No newline at end of file diff --git a/k8/stage/favourite-service.yaml b/k8/stage/favourite-service.yaml new file mode 100644 index 000000000..f1245f18d --- /dev/null +++ b/k8/stage/favourite-service.yaml @@ -0,0 +1,61 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: favourite-service +spec: + replicas: 1 + selector: + matchLabels: + app: favourite-service + template: + metadata: + labels: + app: favourite-service + spec: + containers: + - name: favourite-service + # This will be overridden by Jenkins with actual tag (e.g., pr-123) + image: juanse201/favourite-service:dev + imagePullPolicy: Always + ports: + - containerPort: 8800 + env: + - name: SPRING_PROFILES_ACTIVE + value: "dev" + - name: SPRING_ZIPKIN_BASE_URL + value: "http://zipkin:9411" + - name: EUREKA_CLIENT_SERVICEURL_DEFAULTZONE + value: "http://service-discovery:8761/eureka/" + - name: SPRING_CONFIG_IMPORT + value: "optional:configserver:http://cloud-config:9296/" + - name: EUREKA_CLIENT_REGISTER_WITH_EUREKA + value: "true" + - name: EUREKA_CLIENT_FETCH_REGISTRY + value: "true" + - name: SPRING_JPA_HIBERNATE_DDL_AUTO + value: "update" + - name: EUREKA_INSTANCE_PREFER_IP_ADDRESS + value: "true" + - name: EUREKA_INSTANCE_HOSTNAME + value: "favourite-service" + - name: EUREKA_INSTANCE_NON_SECURE_PORT + value: "8800" + readinessProbe: + httpGet: + path: /favourite-service/actuator/health + port: 8800 + initialDelaySeconds: 10 + periodSeconds: 5 + +--- +apiVersion: v1 +kind: Service +metadata: + name: favourite-service +spec: + type: ClusterIP + ports: + - port: 8800 + targetPort: 8800 + selector: + app: favourite-service \ No newline at end of file diff --git a/k8/stage/order-service.yaml b/k8/stage/order-service.yaml new file mode 100644 index 000000000..6db83fe69 --- /dev/null +++ b/k8/stage/order-service.yaml @@ -0,0 +1,60 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: order-service +spec: + replicas: 1 + selector: + matchLabels: + app: order-service + template: + metadata: + labels: + app: order-service + spec: + containers: + - name: order-service + # This will be overridden by Jenkins with actual tag (e.g., pr-123) + image: juanse201/order-service:dev + imagePullPolicy: Always + ports: + - containerPort: 8300 + env: + - name: SPRING_PROFILES_ACTIVE + value: "dev" + - name: SPRING_ZIPKIN_BASE_URL + value: "http://zipkin:9411" + - name: EUREKA_CLIENT_SERVICEURL_DEFAULTZONE + value: "http://service-discovery:8761/eureka/" + - name: SPRING_CONFIG_IMPORT + value: "optional:configserver:http://cloud-config:9296/" + - name: EUREKA_CLIENT_REGISTER_WITH_EUREKA + value: "true" + - name: EUREKA_CLIENT_FETCH_REGISTRY + value: "true" + - name: SPRING_JPA_HIBERNATE_DDL_AUTO + value: "update" + - name: EUREKA_INSTANCE_PREFER_IP_ADDRESS + value: "true" + - name: EUREKA_INSTANCE_HOSTNAME + value: "order-service" + - name: EUREKA_INSTANCE_NON_SECURE_PORT + value: "8300" + readinessProbe: + httpGet: + path: /order-service/actuator/health + port: 8300 + initialDelaySeconds: 10 + periodSeconds: 5 +--- +apiVersion: v1 +kind: Service +metadata: + name: order-service +spec: + type: ClusterIP + ports: + - port: 8300 + targetPort: 8300 + selector: + app: order-service \ No newline at end of file diff --git a/k8/stage/payment-service.yaml b/k8/stage/payment-service.yaml new file mode 100644 index 000000000..71078a3df --- /dev/null +++ b/k8/stage/payment-service.yaml @@ -0,0 +1,60 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: payment-service +spec: + replicas: 1 + selector: + matchLabels: + app: payment-service + template: + metadata: + labels: + app: payment-service + spec: + containers: + - name: payment-service + # This will be overridden by Jenkins with actual tag (e.g., pr-123) + image: juanse201/payment-service:dev + imagePullPolicy: Always + ports: + - containerPort: 8400 + env: + - name: SPRING_PROFILES_ACTIVE + value: "dev" + - name: SPRING_ZIPKIN_BASE_URL + value: "http://zipkin:9411" + - name: EUREKA_CLIENT_SERVICEURL_DEFAULTZONE + value: "http://service-discovery:8761/eureka/" + - name: SPRING_CONFIG_IMPORT + value: "optional:configserver:http://cloud-config:9296/" + - name: EUREKA_CLIENT_REGISTER_WITH_EUREKA + value: "true" + - name: EUREKA_CLIENT_FETCH_REGISTRY + value: "true" + - name: SPRING_JPA_HIBERNATE_DDL_AUTO + value: "update" + - name: EUREKA_INSTANCE_PREFER_IP_ADDRESS + value: "true" + - name: EUREKA_INSTANCE_HOSTNAME + value: "payment-service" + - name: EUREKA_INSTANCE_NON_SECURE_PORT + value: "8400" + readinessProbe: + httpGet: + path: /payment-service/actuator/health + port: 8400 + initialDelaySeconds: 10 + periodSeconds: 5 +--- +apiVersion: v1 +kind: Service +metadata: + name: payment-service +spec: + type: ClusterIP + ports: + - port: 8400 + targetPort: 8400 + selector: + app: payment-service \ No newline at end of file diff --git a/k8/stage/product-service.yaml b/k8/stage/product-service.yaml new file mode 100644 index 000000000..37a0ab7cb --- /dev/null +++ b/k8/stage/product-service.yaml @@ -0,0 +1,60 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: product-service +spec: + replicas: 1 + selector: + matchLabels: + app: product-service + template: + metadata: + labels: + app: product-service + spec: + containers: + - name: product-service + # This will be overridden by Jenkins with actual tag (e.g., pr-123) + image: juanse201/product-service:dev + imagePullPolicy: Always + ports: + - containerPort: 8500 + env: + - name: SPRING_PROFILES_ACTIVE + value: "dev" + - name: SPRING_ZIPKIN_BASE_URL + value: "http://zipkin:9411" + - name: EUREKA_CLIENT_SERVICEURL_DEFAULTZONE + value: "http://service-discovery:8761/eureka/" + - name: SPRING_CONFIG_IMPORT + value: "optional:configserver:http://cloud-config:9296/" + - name: EUREKA_CLIENT_REGISTER_WITH_EUREKA + value: "true" + - name: EUREKA_CLIENT_FETCH_REGISTRY + value: "true" + - name: SPRING_JPA_HIBERNATE_DDL_AUTO + value: "update" + - name: EUREKA_INSTANCE_PREFER_IP_ADDRESS + value: "true" + - name: EUREKA_INSTANCE_HOSTNAME + value: "product-service" + - name: EUREKA_INSTANCE_NON_SECURE_PORT + value: "8500" + readinessProbe: + httpGet: + path: /product-service/actuator/health + port: 8500 + initialDelaySeconds: 10 + periodSeconds: 5 +--- +apiVersion: v1 +kind: Service +metadata: + name: product-service +spec: + type: ClusterIP + ports: + - port: 8500 + targetPort: 8500 + selector: + app: product-service \ No newline at end of file diff --git a/k8/stage/service-discovery.yaml b/k8/stage/service-discovery.yaml new file mode 100644 index 000000000..3364034fb --- /dev/null +++ b/k8/stage/service-discovery.yaml @@ -0,0 +1,43 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: service-discovery +spec: + replicas: 1 + selector: + matchLabels: + app: service-discovery + template: + metadata: + labels: + app: service-discovery + spec: + containers: + - name: service-discovery + image: juanse201/service-discovery:dev + imagePullPolicy: Always + ports: + - containerPort: 8761 + env: + - name: SPRING_PROFILES_ACTIVE + value: "dev" + - name: SPRING_ZIPKIN_BASE_URL + value: "http://zipkin:9411" + readinessProbe: + httpGet: + path: /actuator/health + port: 8761 + initialDelaySeconds: 10 + periodSeconds: 5 +--- +apiVersion: v1 +kind: Service +metadata: + name: service-discovery +spec: + type: ClusterIP + ports: + - port: 8761 + targetPort: 8761 + selector: + app: service-discovery \ No newline at end of file diff --git a/k8/stage/user-service.yaml b/k8/stage/user-service.yaml new file mode 100644 index 000000000..bf72c4669 --- /dev/null +++ b/k8/stage/user-service.yaml @@ -0,0 +1,60 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: user-service +spec: + replicas: 1 + selector: + matchLabels: + app: user-service + template: + metadata: + labels: + app: user-service + spec: + containers: + - name: user-service + # This will be overridden by Jenkins with actual tag (e.g., pr-123) + image: juanse201/user-service:dev + imagePullPolicy: Always + ports: + - containerPort: 8700 + env: + - name: SPRING_PROFILES_ACTIVE + value: "dev" + - name: SPRING_ZIPKIN_BASE_URL + value: "http://zipkin:9411" + - name: EUREKA_CLIENT_SERVICEURL_DEFAULTZONE + value: "http://service-discovery:8761/eureka/" + - name: SPRING_CONFIG_IMPORT + value: "optional:configserver:http://cloud-config:9296/" + - name: EUREKA_CLIENT_REGISTER_WITH_EUREKA + value: "true" + - name: EUREKA_CLIENT_FETCH_REGISTRY + value: "true" + - name: SPRING_JPA_HIBERNATE_DDL_AUTO + value: "update" + - name: EUREKA_INSTANCE_PREFER_IP_ADDRESS + value: "true" + - name: EUREKA_INSTANCE_HOSTNAME + value: "user-service" + - name: EUREKA_INSTANCE_NON_SECURE_PORT + value: "8700" + readinessProbe: + httpGet: + path: /user-service/actuator/health + port: 8700 + initialDelaySeconds: 10 + periodSeconds: 5 +--- +apiVersion: v1 +kind: Service +metadata: + name: user-service +spec: + type: ClusterIP + ports: + - port: 8700 + targetPort: 8700 + selector: + app: user-service \ No newline at end of file diff --git a/k8/stage/zipkin.yaml b/k8/stage/zipkin.yaml new file mode 100644 index 000000000..e922dfe80 --- /dev/null +++ b/k8/stage/zipkin.yaml @@ -0,0 +1,31 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: zipkin +spec: + replicas: 1 + selector: + matchLabels: + app: zipkin + template: + metadata: + labels: + app: zipkin + spec: + containers: + - name: zipkin + image: openzipkin/zipkin:2.24 + ports: + - containerPort: 9411 +--- +apiVersion: v1 +kind: Service +metadata: + name: zipkin +spec: + type: ClusterIP + ports: + - port: 9411 + targetPort: 9411 + selector: + app: zipkin \ No newline at end of file diff --git a/order-service/Dockerfile b/order-service/Dockerfile index b2b25a41a..b9cc2b773 100644 --- a/order-service/Dockerfile +++ b/order-service/Dockerfile @@ -3,10 +3,8 @@ FROM openjdk:11 ARG PROJECT_VERSION=0.1.0 RUN mkdir -p /home/app WORKDIR /home/app -ENV SPRING_PROFILES_ACTIVE dev +ENV SPRING_PROFILES_ACTIVE=dev COPY order-service/ . ADD order-service/target/order-service-v${PROJECT_VERSION}.jar order-service.jar EXPOSE 8300 -ENTRYPOINT ["java", "-Dspring.profiles.active=${SPRING_PROFILES_ACTIVE}", "-jar", "order-service.jar"] - - +ENTRYPOINT ["java", "-Dspring.profiles.active=${SPRING_PROFILES_ACTIVE}", "-jar", "order-service.jar"] \ No newline at end of file diff --git a/order-service/compose.yml b/order-service/compose.yml index 34107eacf..f527cc040 100644 --- a/order-service/compose.yml +++ b/order-service/compose.yml @@ -1,12 +1,24 @@ - version: '3' services: order-service-container: - image: selimhorri/order-service-ecommerce-boot:0.1.0 + image: juanse201/order-service-ecommerce-boot:0.1.0 ports: - 8300:8300 environment: - SPRING_PROFILES_ACTIVE=dev + - SPRING_ZIPKIN_BASE_URL=http://zipkin:9411 + - SPRING_CONFIG_IMPORT=optional:configserver:http://cloud-config-container:9296/ + - EUREKA_CLIENT_REGION=default + - EUREKA_CLIENT_AVAILABILITY_ZONES_DEFAULT=myzone + - EUREKA_CLIENT_SERVICEURL_MYZONE=http://service-discovery-container:8761/eureka + - EUREKA_CLIENT_SERVICEURL_DEFAULTZONE=http://service-discovery-container:8761/eureka/ + networks: + - microservices_network +networks: + microservices_network: + external: true + name: microservices_network + diff --git a/order-service/pom.xml b/order-service/pom.xml index 13d9dd1ab..50dea16ef 100644 --- a/order-service/pom.xml +++ b/order-service/pom.xml @@ -73,6 +73,44 @@ mysql test + + org.springframework.boot + spring-boot-starter-actuator + + + io.micrometer + micrometer-registry-prometheus + + + net.logstash.logback + logstash-logback-encoder + 7.3 + + + org.springframework.cloud + spring-cloud-starter-sleuth + + + io.github.resilience4j + resilience4j-spring-boot2 + + + io.github.resilience4j + resilience4j-bulkhead + + + io.github.resilience4j + resilience4j-retry + + + io.github.resilience4j + resilience4j-circuitbreaker + + + org.springframework.boot + spring-boot-configuration-processor + true + diff --git a/order-service/src/main/java/com/selimhorri/app/config/FeatureToggle.java b/order-service/src/main/java/com/selimhorri/app/config/FeatureToggle.java new file mode 100644 index 000000000..4cf8dba53 --- /dev/null +++ b/order-service/src/main/java/com/selimhorri/app/config/FeatureToggle.java @@ -0,0 +1,139 @@ +package com.selimhorri.app.config; + +import lombok.Data; +import java.util.ArrayList; +import java.util.List; + +/** + * Represents a feature toggle configuration. + * + * A feature toggle (also known as feature flag) allows features to be + * enabled or disabled dynamically without redeploying the application. + * + * Supports: + * - Simple on/off toggle + * - Gradual rollout by percentage + * - User-specific enablement + * - Environment-specific enablement + * + * @author ecommerce-team + * @version 1.0 + */ +@Data +public class FeatureToggle { + + /** + * Whether the feature is enabled globally + */ + private boolean enabled = false; + + /** + * Percentage of users who should see this feature (0-100) + * Used for gradual rollout / canary releases + */ + private int rolloutPercentage = 100; + + /** + * Specific user IDs that should have this feature enabled + * regardless of rolloutPercentage + */ + private List enabledUsers = new ArrayList<>(); + + /** + * Environments where this feature should be enabled + * e.g., ["dev", "stage"] but not "prod" + */ + private List enabledEnvironments = new ArrayList<>(); + + /** + * Description of what this feature does + */ + private String description; + + /** + * When this feature was last modified + */ + private String lastModified; + + /** + * Check if a specific user should have this feature enabled. + * + * Uses consistent hashing to determine if user is in rollout percentage. + * Same user will always get the same result for a given percentage. + * + * @param userId The user ID to check + * @return true if feature should be enabled for this user + */ + public boolean isEnabledForUser(String userId) { + if (!enabled) { + return false; + } + + // Explicitly enabled users always get the feature + if (enabledUsers.contains(userId)) { + return true; + } + + // If rollout is 100%, everyone gets it + if (rolloutPercentage >= 100) { + return true; + } + + // If rollout is 0%, nobody gets it (except explicit users) + if (rolloutPercentage <= 0) { + return false; + } + + // Use consistent hashing to determine if user is in rollout percentage + int hash = Math.abs(userId.hashCode()); + return (hash % 100) < rolloutPercentage; + } + + /** + * Check if feature is enabled for a specific environment + * + * @param environment The environment to check (dev, stage, prod, etc.) + * @return true if feature is enabled for this environment + */ + public boolean isEnabledForEnvironment(String environment) { + if (!enabled) { + return false; + } + + // If no environments specified, enabled for all + if (enabledEnvironments.isEmpty()) { + return true; + } + + return enabledEnvironments.contains(environment); + } + + /** + * Create a disabled feature toggle + */ + public static FeatureToggle disabled() { + FeatureToggle toggle = new FeatureToggle(); + toggle.setEnabled(false); + return toggle; + } + + /** + * Create a fully enabled feature toggle + */ + public static FeatureToggle fullyEnabled() { + FeatureToggle toggle = new FeatureToggle(); + toggle.setEnabled(true); + toggle.setRolloutPercentage(100); + return toggle; + } + + /** + * Create a feature toggle with specific rollout percentage + */ + public static FeatureToggle withRollout(int percentage) { + FeatureToggle toggle = new FeatureToggle(); + toggle.setEnabled(true); + toggle.setRolloutPercentage(Math.max(0, Math.min(100, percentage))); + return toggle; + } +} diff --git a/order-service/src/main/java/com/selimhorri/app/config/FeatureToggleService.java b/order-service/src/main/java/com/selimhorri/app/config/FeatureToggleService.java new file mode 100644 index 000000000..43b8bb35a --- /dev/null +++ b/order-service/src/main/java/com/selimhorri/app/config/FeatureToggleService.java @@ -0,0 +1,226 @@ +package com.selimhorri.app.config; + +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.stereotype.Service; + +import javax.annotation.PostConstruct; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.Map; + +/** + * Service for managing feature toggles (feature flags). + * + * Feature toggles allow features to be enabled/disabled dynamically without + * redeployment, enabling: + * - Gradual rollouts (canary releases) + * - A/B testing + * - Kill switches for problematic features + * - Environment-specific features + * + * Configuration is externalized in application.yml and can be refreshed + * dynamically using Spring Cloud Config refresh endpoint. + * + * Usage example: + * + *
+ * if (featureToggleService.isEnabled("new-checkout-flow")) {
+ *     return newCheckoutFlow();
+ * } else {
+ *     return legacyCheckoutFlow();
+ * }
+ * 
+ * + * @author ecommerce-team + * @version 1.0 + */ +@Service +@ConfigurationProperties(prefix = "features") +@RefreshScope +@Data +@Slf4j +public class FeatureToggleService { + + /** + * Map of feature name to feature toggle configuration + */ + private Map toggles = new HashMap<>(); + + /** + * Current environment (dev, stage, prod) + */ + private String environment = "dev"; + + @PostConstruct + public void init() { + log.info("Feature Toggle Service initialized with {} features", toggles.size()); + toggles.forEach((name, toggle) -> { + log.info("Feature '{}': enabled={}, rollout={}%", + name, toggle.isEnabled(), toggle.getRolloutPercentage()); + }); + } + + /** + * Check if a feature is enabled globally. + * + * @param featureName The name of the feature + * @return true if feature is enabled + */ + public boolean isEnabled(String featureName) { + FeatureToggle toggle = toggles.get(featureName); + + if (toggle == null) { + log.warn("Feature toggle '{}' not found. Returning false by default.", featureName); + return false; + } + + boolean enabledForEnv = toggle.isEnabledForEnvironment(environment); + boolean globallyEnabled = toggle.isEnabled(); + + log.debug("Feature '{}' check: globallyEnabled={}, enabledForEnv={}", + featureName, globallyEnabled, enabledForEnv); + + return globallyEnabled && enabledForEnv; + } + + /** + * Check if a feature is enabled for a specific user. + * + * Takes into account: + * - Global enabled flag + * - Environment enablement + * - User-specific enablement + * - Rollout percentage (consistent hashing) + * + * @param featureName The name of the feature + * @param userId The user ID to check + * @return true if feature is enabled for this user + */ + public boolean isEnabledForUser(String featureName, String userId) { + FeatureToggle toggle = toggles.get(featureName); + + if (toggle == null) { + log.warn("Feature toggle '{}' not found for user {}. Returning false.", featureName, userId); + return false; + } + + if (!toggle.isEnabledForEnvironment(environment)) { + log.debug("Feature '{}' not enabled for environment '{}'", featureName, environment); + return false; + } + + boolean enabledForUser = toggle.isEnabledForUser(userId); + + log.debug("Feature '{}' check for user {}: enabled={}", featureName, userId, enabledForUser); + + return enabledForUser; + } + + /** + * Get all feature toggles and their current state + * + * @return Map of all feature toggles + */ + public Map getAllToggles() { + return new HashMap<>(toggles); + } + + /** + * Update a feature toggle at runtime. + * + * Note: This only updates the in-memory state. For persistent changes, + * update the configuration source (application.yml or Config Server). + * + * @param featureName Name of the feature + * @param toggle New toggle configuration + */ + public void updateToggle(String featureName, FeatureToggle toggle) { + log.info("Updating feature toggle '{}': enabled={}, rollout={}%", + featureName, toggle.isEnabled(), toggle.getRolloutPercentage()); + + toggle.setLastModified(LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME)); + toggles.put(featureName, toggle); + } + + /** + * Enable a feature for all users + * + * @param featureName Name of the feature + */ + public void enableFeature(String featureName) { + FeatureToggle toggle = toggles.getOrDefault(featureName, new FeatureToggle()); + toggle.setEnabled(true); + toggle.setRolloutPercentage(100); + updateToggle(featureName, toggle); + } + + /** + * Disable a feature for all users + * + * @param featureName Name of the feature + */ + public void disableFeature(String featureName) { + FeatureToggle toggle = toggles.getOrDefault(featureName, new FeatureToggle()); + toggle.setEnabled(false); + updateToggle(featureName, toggle); + } + + /** + * Set rollout percentage for gradual release + * + * @param featureName Name of the feature + * @param percentage Percentage of users (0-100) + */ + public void setRolloutPercentage(String featureName, int percentage) { + if (percentage < 0 || percentage > 100) { + throw new IllegalArgumentException("Percentage must be between 0 and 100"); + } + + FeatureToggle toggle = toggles.getOrDefault(featureName, new FeatureToggle()); + toggle.setEnabled(true); + toggle.setRolloutPercentage(percentage); + updateToggle(featureName, toggle); + + log.info("Feature '{}' rollout set to {}%", featureName, percentage); + } + + /** + * Get the current rollout percentage for a feature + * + * @param featureName Name of the feature + * @return Rollout percentage (0-100), or 0 if feature doesn't exist + */ + public int getRolloutPercentage(String featureName) { + FeatureToggle toggle = toggles.get(featureName); + return toggle != null ? toggle.getRolloutPercentage() : 0; + } + + /** + * Add a user to the explicit enable list for a feature + * + * @param featureName Name of the feature + * @param userId User ID to enable + */ + public void enableForUser(String featureName, String userId) { + FeatureToggle toggle = toggles.getOrDefault(featureName, new FeatureToggle()); + if (!toggle.getEnabledUsers().contains(userId)) { + toggle.getEnabledUsers().add(userId); + updateToggle(featureName, toggle); + log.info("Feature '{}' explicitly enabled for user: {}", featureName, userId); + } + } + + /** + * Check if a feature exists in configuration + * + * @param featureName Name of the feature + * @return true if feature exists + */ + public boolean featureExists(String featureName) { + return toggles.containsKey(featureName); + } +} diff --git a/order-service/src/main/java/com/selimhorri/app/config/ResilienceEventListener.java b/order-service/src/main/java/com/selimhorri/app/config/ResilienceEventListener.java new file mode 100644 index 000000000..cdf9f0fbd --- /dev/null +++ b/order-service/src/main/java/com/selimhorri/app/config/ResilienceEventListener.java @@ -0,0 +1,185 @@ +package com.selimhorri.app.config; + +import io.github.resilience4j.bulkhead.event.BulkheadEvent; +import io.github.resilience4j.bulkhead.event.BulkheadOnCallFinishedEvent; +import io.github.resilience4j.bulkhead.event.BulkheadOnCallPermittedEvent; +import io.github.resilience4j.bulkhead.event.BulkheadOnCallRejectedEvent; +import io.github.resilience4j.circuitbreaker.event.*; +import io.github.resilience4j.retry.event.*; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +/** + * Event listeners for Resilience4j patterns. + * + * Provides centralized logging and monitoring for: + * - Circuit Breaker events + * - Bulkhead events + * - Retry events + * + * These events are useful for: + * - Debugging resilience issues + * - Monitoring system health + * - Alerting on pattern activations + * - Metrics collection + * + * @author ecommerce-team + * @version 1.0 + */ +@Component +@Slf4j +public class ResilienceEventListener { + + // ======================================== + // Circuit Breaker Events + // ======================================== + + @EventListener + public void onCircuitBreakerStateTransition(CircuitBreakerOnStateTransitionEvent event) { + log.warn("Circuit Breaker '{}' transitioned from {} to {}", + event.getCircuitBreakerName(), + event.getStateTransition().getFromState(), + event.getStateTransition().getToState()); + + // In production, you might: + // - Send alert if circuit opens + // - Update dashboard + // - Trigger auto-scaling if needed + } + + @EventListener + public void onCircuitBreakerError(CircuitBreakerOnErrorEvent event) { + log.error("Circuit Breaker '{}' recorded error: {}", + event.getCircuitBreakerName(), + event.getThrowable().getMessage()); + } + + @EventListener + public void onCircuitBreakerSuccess(CircuitBreakerOnSuccessEvent event) { + log.debug("Circuit Breaker '{}' recorded success. Duration: {}ms", + event.getCircuitBreakerName(), + event.getElapsedDuration().toMillis()); + } + + @EventListener + public void onCircuitBreakerCallNotPermitted(CircuitBreakerOnCallNotPermittedEvent event) { + log.warn("Circuit Breaker '{}' call not permitted - circuit is OPEN", + event.getCircuitBreakerName()); + + // Alert! Service is completely blocked + // Consider scaling, failover, or manual intervention + } + + // ======================================== + // Bulkhead Events + // ======================================== + + @EventListener + public void onBulkheadCallRejected(BulkheadOnCallRejectedEvent event) { + log.warn("Bulkhead '{}' rejected call - max concurrent calls reached", + event.getBulkheadName()); + + // This indicates system is at capacity + // Consider: + // - Auto-scaling + // - Load shedding + // - Alerting operations team + } + + @EventListener + public void onBulkheadCallPermitted(BulkheadOnCallPermittedEvent event) { + log.debug("Bulkhead '{}' permitted call", + event.getBulkheadName()); + } + + @EventListener + public void onBulkheadCallFinished(BulkheadOnCallFinishedEvent event) { + log.debug("Bulkhead '{}' call finished", + event.getBulkheadName()); + } + + // ======================================== + // Retry Events + // ======================================== + + @EventListener + public void onRetryAttempt(RetryOnRetryEvent event) { + log.warn("Retry attempt {} for '{}'. Last exception: {}", + event.getNumberOfRetryAttempts(), + event.getName(), + event.getLastThrowable().getMessage()); + + // Track retry metrics + // If too many retries, might indicate systematic issue + } + + @EventListener + public void onRetrySuccess(RetryOnSuccessEvent event) { + log.info("Retry succeeded for '{}' after {} attempts", + event.getName(), + event.getNumberOfRetryAttempts()); + + // Good to know - service recovered + // But many retries might indicate unstable dependency + } + + @EventListener + public void onRetryError(RetryOnErrorEvent event) { + log.error("Retry failed for '{}' after {} attempts. Final error: {}", + event.getName(), + event.getNumberOfRetryAttempts(), + event.getLastThrowable().getMessage()); + + // All retries exhausted - invoking fallback + // Alert operations team + // Check dependent service health + } + + @EventListener + public void onRetryIgnoredError(RetryOnIgnoredErrorEvent event) { + log.info("Retry ignored error for '{}': {}", + event.getName(), + event.getLastThrowable().getMessage()); + + // Error type is configured to not trigger retry + // This is expected for certain error types + } + + /** + * Generic handler for any Bulkhead event + * Use for custom metrics collection + */ + @EventListener + public void onBulkheadEvent(BulkheadEvent event) { + // Custom metrics collection + // Example: Send to Prometheus, Datadog, etc. + log.trace("Bulkhead event: {} - {}", + event.getBulkheadName(), + event.getEventType()); + } + + /** + * Generic handler for any Circuit Breaker event + * Use for custom metrics collection + */ + @EventListener + public void onCircuitBreakerEvent(CircuitBreakerEvent event) { + // Custom metrics collection + log.trace("Circuit Breaker event: {} - {}", + event.getCircuitBreakerName(), + event.getEventType()); + } + + /** + * Generic handler for any Retry event + * Use for custom metrics collection + */ + @EventListener + public void onRetryEvent(RetryEvent event) { + // Custom metrics collection + log.trace("Retry event: {} - {}", + event.getName(), + event.getEventType()); + } +} diff --git a/order-service/src/main/java/com/selimhorri/app/health/DatabaseHealthIndicator.java b/order-service/src/main/java/com/selimhorri/app/health/DatabaseHealthIndicator.java new file mode 100644 index 000000000..261ec76e2 --- /dev/null +++ b/order-service/src/main/java/com/selimhorri/app/health/DatabaseHealthIndicator.java @@ -0,0 +1,53 @@ +package com.selimhorri.app.health; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.HealthIndicator; +import org.springframework.stereotype.Component; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.Statement; + +/** + * Custom health indicator for database connectivity + */ +@Component("database") +@RequiredArgsConstructor +@Slf4j +public class DatabaseHealthIndicator implements HealthIndicator { + + private final DataSource dataSource; + + @Override + public Health health() { + try (Connection connection = dataSource.getConnection(); + Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery("SELECT 1")) { + + if (resultSet.next()) { + // Get database metadata + String databaseProductName = connection.getMetaData().getDatabaseProductName(); + String databaseProductVersion = connection.getMetaData().getDatabaseProductVersion(); + + return Health.up() + .withDetail("database", databaseProductName) + .withDetail("version", databaseProductVersion) + .withDetail("status", "Connected") + .build(); + } else { + return Health.down() + .withDetail("status", "Unable to validate connection") + .build(); + } + } catch (Exception e) { + log.error("Database health check failed", e); + return Health.down() + .withDetail("error", e.getMessage()) + .withException(e) + .build(); + } + } +} diff --git a/order-service/src/main/java/com/selimhorri/app/resilience/OrderBulkheadService.java b/order-service/src/main/java/com/selimhorri/app/resilience/OrderBulkheadService.java new file mode 100644 index 000000000..ff8327887 --- /dev/null +++ b/order-service/src/main/java/com/selimhorri/app/resilience/OrderBulkheadService.java @@ -0,0 +1,174 @@ +package com.selimhorri.app.resilience; + +import com.selimhorri.app.dto.OrderDto; +import com.selimhorri.app.service.OrderService; +import io.github.resilience4j.bulkhead.annotation.Bulkhead; +import io.github.resilience4j.bulkhead.annotation.Bulkhead.Type; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/** + * Service implementing the Bulkhead Pattern for order processing. + * + * The Bulkhead pattern isolates resources for different types of operations, + * preventing one type of workload from consuming all available resources + * and affecting other operations. + * + * This implementation provides: + * - Semaphore-based bulkh AAAºead for synchronous order processing + * - Thread pool-based bulkhead for asynchronous order processing + * - Fallback mechanisms when bulkhead is full + * - Metrics and monitoring through Resilience4j + * + * @author ecommerce-team + * @version 1.0 + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class OrderBulkheadService { + + private final OrderService orderService; + + /** + * Process order with semaphore-based bulkhead protection. + * + * This limits concurrent order processing to prevent resource exhaustion. + * When the limit is reached, the fallback method is called. + * + * @param orderDto The order to process + * @return Processed order + */ + @Bulkhead(name = "orderProcessing", fallbackMethod = "orderProcessingFallback", type = Type.SEMAPHORE) + public OrderDto processOrder(OrderDto orderDto) { + log.info("Processing order with bulkhead protection: {}", orderDto.getOrderId()); + + try { + // Simulate processing time + Thread.sleep(100); + + // Actual order processing + OrderDto savedOrder = orderService.save(orderDto); + + log.info("Successfully processed order: {}", savedOrder.getOrderId()); + return savedOrder; + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.error("Order processing interrupted: {}", orderDto.getOrderId()); + throw new RuntimeException("Order processing interrupted", e); + } + } + + /** + * Fallback method when bulkhead is full. + * + * This provides graceful degradation by queueing the order + * for later processing instead of rejecting it outright. + * + * @param orderDto The order that couldn't be processed + * @param exception The exception that triggered the fallback + * @return Order with pending status + */ + public OrderDto orderProcessingFallback(OrderDto orderDto, Exception exception) { + log.error("Bulkhead full for order processing. Order queued for retry: {}. Reason: {}", + orderDto.getOrderId(), + exception.getMessage()); + + // Set order description to indicate pending retry status + orderDto.setOrderDesc("PENDING_RETRY: " + orderDto.getOrderDesc()); + + // In a real implementation, you would: + // 1. Queue to message broker (RabbitMQ, Kafka) + // 2. Store in retry queue database + // 3. Schedule for later processing + + return orderDto; + } + + /** + * Process order asynchronously with thread pool bulkhead. + * + * This uses a dedicated thread pool for async processing, + * preventing blocking operations from affecting the main thread pool. + * + * @param orderDto The order to process + * @return Processed order + */ + @Bulkhead(name = "orderAsyncProcessing", fallbackMethod = "orderAsyncProcessingFallback", type = Type.THREADPOOL) + public OrderDto processOrderAsync(OrderDto orderDto) { + log.info("Processing order asynchronously with thread pool bulkhead: {}", orderDto.getOrderId()); + + try { + // Simulate async processing + Thread.sleep(200); + + OrderDto savedOrder = orderService.save(orderDto); + + log.info("Successfully processed order async: {}", savedOrder.getOrderId()); + return savedOrder; + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.error("Async order processing interrupted: {}", orderDto.getOrderId()); + throw new RuntimeException("Async order processing interrupted", e); + } + } + + /** + * Fallback for async processing when thread pool is full. + * + * @param orderDto The order that couldn't be processed + * @param exception The exception that triggered the fallback + * @return Order with queued status + */ + public OrderDto orderAsyncProcessingFallback(OrderDto orderDto, Exception exception) { + log.error("Thread pool bulkhead full for async order processing. Order queued: {}. Reason: {}", + orderDto.getOrderId(), + exception.getMessage()); + + orderDto.setOrderDesc("QUEUED_FOR_ASYNC: " + orderDto.getOrderDesc()); + + return orderDto; + } + + /** + * High priority order processing with dedicated bulkhead. + * + * This ensures that high-priority orders have dedicated resources + * and are not affected by regular order processing load. + * + * @param orderDto High priority order + * @return Processed order + */ + @Bulkhead(name = "highPriorityOrders", fallbackMethod = "highPriorityFallback", type = Type.SEMAPHORE) + public OrderDto processHighPriorityOrder(OrderDto orderDto) { + log.info("Processing HIGH PRIORITY order: {}", orderDto.getOrderId()); + + // Fast-track processing for high priority orders + // In a real implementation, you would add a priority field to OrderDto + orderDto.setOrderDesc("HIGH_PRIORITY: " + orderDto.getOrderDesc()); + OrderDto savedOrder = orderService.save(orderDto); + + return savedOrder; + } + + /** + * Fallback for high priority orders. + * High priority orders should rarely hit this, but when they do, + * we need to handle them specially. + */ + public OrderDto highPriorityFallback(OrderDto orderDto, Exception exception) { + log.error("CRITICAL: High priority order bulkhead full: {}. Immediate escalation needed!", + orderDto.getOrderId()); + + // In production: + // 1. Alert operations team + // 2. Auto-scale if in cloud + // 3. Queue to highest priority queue + + orderDto.setOrderDesc("ESCALATED: " + orderDto.getOrderDesc()); + return orderDto; + } +} diff --git a/order-service/src/main/java/com/selimhorri/app/resilience/OrderRetryService.java b/order-service/src/main/java/com/selimhorri/app/resilience/OrderRetryService.java new file mode 100644 index 000000000..84a9c80fd --- /dev/null +++ b/order-service/src/main/java/com/selimhorri/app/resilience/OrderRetryService.java @@ -0,0 +1,194 @@ +package com.selimhorri.app.resilience; + +import com.selimhorri.app.dto.OrderDto; +import com.selimhorri.app.service.OrderService; +import io.github.resilience4j.retry.annotation.Retry; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +/** + * Service implementing the Retry Pattern with exponential backoff. + * + * The Retry pattern automatically retries failed operations with + * increasing delays between attempts (exponential backoff). + * + * This is particularly useful for: + * - Transient network failures + * - Temporary service unavailability + * - Database connection timeouts + * - External API rate limiting + * + * Benefits: + * - Automatic recovery from transient failures + * - Exponential backoff prevents overwhelming struggling services + * - Configurable retry logic per operation type + * - Fallback mechanisms for ultimate failure + * + * @author ecommerce-team + * @version 1.0 + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class OrderRetryService { + + private final OrderService orderService; + private final RestTemplate restTemplate; + + /** + * Save order with retry on database failures. + * + * Retries up to 3 times with exponential backoff if database operations fail. + * Useful for handling transient DB connection issues. + * + * @param orderDto Order to save + * @return Saved order + */ + @Retry(name = "orderProcessing", fallbackMethod = "saveOrderFallback") + public OrderDto saveOrderWithRetry(OrderDto orderDto) { + log.info("Attempting to save order with retry: {}", orderDto.getOrderId()); + + OrderDto savedOrder = orderService.save(orderDto); + + log.info("Successfully saved order: {}", savedOrder.getOrderId()); + return savedOrder; + } + + /** + * Fallback method when all retry attempts fail. + * Stores order in a dead-letter queue for manual processing. + * + * @param orderDto The order that failed to save + * @param exception The exception that caused the failure + * @return Order with error status + */ + public OrderDto saveOrderFallback(OrderDto orderDto, Exception exception) { + log.error("All retry attempts failed for order: {}. Error: {}", + orderDto.getOrderId(), + exception.getMessage()); + + // In production, you would: + // 1. Store in dead-letter queue (DLQ) + // 2. Send alert to operations team + // 3. Log to error tracking system (Sentry, Rollbar, etc.) + + orderDto.setOrderDesc("FAILED: " + orderDto.getOrderDesc()); + + return orderDto; + } + + /** + * Call external API with retry logic. + * + * Retries up to 5 times with exponential backoff for external API calls. + * More retry attempts because external APIs are more prone to transient issues. + * + * @param apiUrl API endpoint URL + * @param orderId Order ID for the API call + * @return API response + */ + @Retry(name = "externalApiCall", fallbackMethod = "externalApiCallFallback") + public String callExternalApiWithRetry(String apiUrl, Integer orderId) { + log.info("Calling external API with retry: {} for order: {}", apiUrl, orderId); + + try { + String response = restTemplate.getForObject(apiUrl + "/" + orderId, String.class); + log.info("External API call successful for order: {}", orderId); + return response; + + } catch (Exception e) { + log.warn("External API call failed (will retry): {} - {}", apiUrl, e.getMessage()); + throw e; + } + } + + /** + * Fallback for external API calls. + * Returns cached data or default response. + * + * @param apiUrl The API URL that failed + * @param orderId The order ID + * @param exception The exception thrown + * @return Fallback response + */ + public String externalApiCallFallback(String apiUrl, Integer orderId, Exception exception) { + log.error("External API call failed after all retries: {} for order: {}. Error: {}", + apiUrl, orderId, exception.getMessage()); + + // Return cached data or default response + return "{\"status\":\"unavailable\",\"orderId\":" + orderId + + ",\"message\":\"Service temporarily unavailable\"}"; + } + + /** + * Process payment with retry. + * Critical operation that needs multiple retry attempts. + * + * @param orderId Order ID for payment + * @param amount Amount to charge + * @return Payment confirmation + */ + @Retry(name = "orderProcessing", fallbackMethod = "processPaymentFallback") + public String processPaymentWithRetry(Integer orderId, Double amount) { + log.info("Processing payment with retry for order: {}, amount: {}", orderId, amount); + + // Simulate payment processing that might fail + if (Math.random() < 0.3) { // 30% chance of transient failure + throw new RuntimeException("Payment gateway temporarily unavailable"); + } + + log.info("Payment processed successfully for order: {}", orderId); + return "PAYMENT_SUCCESS"; + } + + /** + * Fallback for payment processing. + * Queues payment for later retry or manual processing. + */ + public String processPaymentFallback(Integer orderId, Double amount, Exception exception) { + log.error("Payment processing failed after retries for order: {}. Queuing for manual review.", orderId); + + // In production: + // 1. Queue to payment retry service + // 2. Alert finance team + // 3. Update order status to PAYMENT_PENDING + + return "PAYMENT_QUEUED_FOR_RETRY"; + } + + /** + * Update inventory with retry. + * Ensures inventory updates don't fail due to transient issues. + * + * @param productId Product ID + * @param quantity Quantity to update + * @return Update status + */ + @Retry(name = "orderProcessing", fallbackMethod = "updateInventoryFallback") + public boolean updateInventoryWithRetry(Integer productId, Integer quantity) { + log.info("Updating inventory with retry - Product: {}, Quantity: {}", productId, quantity); + + // Simulate inventory update that might have transient failures + if (Math.random() < 0.2) { // 20% chance of failure + throw new RuntimeException("Inventory service connection timeout"); + } + + log.info("Inventory updated successfully for product: {}", productId); + return true; + } + + /** + * Fallback for inventory update. + * Queues update for eventual consistency. + */ + public boolean updateInventoryFallback(Integer productId, Integer quantity, Exception exception) { + log.error("Inventory update failed after retries for product: {}. Queuing for async update.", productId); + + // Queue for async processing to maintain eventual consistency + // In production: send to message queue for later processing + + return false; + } +} diff --git a/order-service/src/main/java/com/selimhorri/app/resource/FeatureToggleController.java b/order-service/src/main/java/com/selimhorri/app/resource/FeatureToggleController.java new file mode 100644 index 000000000..741b85344 --- /dev/null +++ b/order-service/src/main/java/com/selimhorri/app/resource/FeatureToggleController.java @@ -0,0 +1,196 @@ +package com.selimhorri.app.resource; + +import com.selimhorri.app.config.FeatureToggle; +import com.selimhorri.app.config.FeatureToggleService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; + +/** + * REST Controller for managing feature toggles. + * + * Provides endpoints to: + * - View all feature toggles + * - Check if specific feature is enabled + * - Enable/disable features + * - Update rollout percentage + * + * This allows operations teams to control feature rollout without redeployment. + * + * @author ecommerce-team + * @version 1.0 + */ +@RestController +@RequestMapping("/actuator/features") +@RequiredArgsConstructor +@Slf4j +public class FeatureToggleController { + + private final FeatureToggleService featureToggleService; + + /** + * Get all configured feature toggles + * + * GET /actuator/features + * + * @return Map of all feature toggles with their configuration + */ + @GetMapping + public ResponseEntity> getAllToggles() { + log.info("Fetching all feature toggles"); + return ResponseEntity.ok(featureToggleService.getAllToggles()); + } + + /** + * Get a specific feature toggle + * + * GET /actuator/features/{featureName} + * + * @param featureName Name of the feature + * @return Feature toggle configuration + */ + @GetMapping("/{featureName}") + public ResponseEntity getToggle(@PathVariable String featureName) { + log.info("Fetching feature toggle: {}", featureName); + + Map toggles = featureToggleService.getAllToggles(); + FeatureToggle toggle = toggles.get(featureName); + + if (toggle == null) { + return ResponseEntity.notFound().build(); + } + + return ResponseEntity.ok(toggle); + } + + /** + * Check if a feature is enabled + * + * GET /actuator/features/{featureName}/enabled + * + * @param featureName Name of the feature + * @param userId Optional user ID for user-specific check + * @return Status indicating if feature is enabled + */ + @GetMapping("/{featureName}/enabled") + public ResponseEntity> isFeatureEnabled( + @PathVariable String featureName, + @RequestParam(required = false) String userId) { + + Map response = new HashMap<>(); + response.put("feature", featureName); + + if (userId != null) { + boolean enabled = featureToggleService.isEnabledForUser(featureName, userId); + response.put("enabled", enabled); + response.put("userId", userId); + log.info("Feature '{}' enabled check for user {}: {}", featureName, userId, enabled); + } else { + boolean enabled = featureToggleService.isEnabled(featureName); + response.put("enabled", enabled); + log.info("Feature '{}' enabled check (global): {}", featureName, enabled); + } + + return ResponseEntity.ok(response); + } + + /** + * Update a feature toggle + * + * PUT /actuator/features/{featureName} + * + * @param featureName Name of the feature + * @param toggle New toggle configuration + * @return Updated toggle + */ + @PutMapping("/{featureName}") + public ResponseEntity updateToggle( + @PathVariable String featureName, + @RequestBody FeatureToggle toggle) { + + log.info("Updating feature toggle '{}': enabled={}, rollout={}%", + featureName, toggle.isEnabled(), toggle.getRolloutPercentage()); + + featureToggleService.updateToggle(featureName, toggle); + + return ResponseEntity.ok(toggle); + } + + /** + * Enable a feature completely (100% rollout) + * + * POST /actuator/features/{featureName}/enable + * + * @param featureName Name of the feature + * @return Success message + */ + @PostMapping("/{featureName}/enable") + public ResponseEntity> enableFeature(@PathVariable String featureName) { + log.info("Enabling feature: {}", featureName); + + featureToggleService.enableFeature(featureName); + + Map response = new HashMap<>(); + response.put("message", "Feature '" + featureName + "' enabled successfully"); + response.put("status", "enabled"); + + return ResponseEntity.ok(response); + } + + /** + * Disable a feature completely + * + * POST /actuator/features/{featureName}/disable + * + * @param featureName Name of the feature + * @return Success message + */ + @PostMapping("/{featureName}/disable") + public ResponseEntity> disableFeature(@PathVariable String featureName) { + log.info("Disabling feature: {}", featureName); + + featureToggleService.disableFeature(featureName); + + Map response = new HashMap<>(); + response.put("message", "Feature '" + featureName + "' disabled successfully"); + response.put("status", "disabled"); + + return ResponseEntity.ok(response); + } + + /** + * Set rollout percentage for gradual release + * + * PUT /actuator/features/{featureName}/rollout + * + * @param featureName Name of the feature + * @param percentage Rollout percentage (0-100) + * @return Success message with new percentage + */ + @PutMapping("/{featureName}/rollout") + public ResponseEntity> setRolloutPercentage( + @PathVariable String featureName, + @RequestParam int percentage) { + + if (percentage < 0 || percentage > 100) { + Map error = new HashMap<>(); + error.put("error", "Percentage must be between 0 and 100"); + return ResponseEntity.badRequest().body(error); + } + + log.info("Setting rollout percentage for '{}' to {}%", featureName, percentage); + + featureToggleService.setRolloutPercentage(featureName, percentage); + + Map response = new HashMap<>(); + response.put("message", "Rollout percentage updated successfully"); + response.put("feature", featureName); + response.put("rolloutPercentage", percentage); + + return ResponseEntity.ok(response); + } +} diff --git a/order-service/src/main/java/com/selimhorri/app/service/OrderMetricsService.java b/order-service/src/main/java/com/selimhorri/app/service/OrderMetricsService.java new file mode 100644 index 000000000..65b448e33 --- /dev/null +++ b/order-service/src/main/java/com/selimhorri/app/service/OrderMetricsService.java @@ -0,0 +1,133 @@ +package com.selimhorri.app.service; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Service for tracking business metrics related to orders + */ +@Service +@Slf4j +public class OrderMetricsService { + + private final Counter ordersCreatedCounter; + private final Counter ordersCompletedCounter; + private final Counter ordersCancelledCounter; + private final Counter ordersFailedCounter; + private final AtomicLong totalOrderAmount; + private final AtomicInteger activeOrders; + private final Timer orderProcessingTimer; + + public OrderMetricsService(MeterRegistry meterRegistry) { + // Counter for orders created + this.ordersCreatedCounter = Counter.builder("orders.created.total") + .description("Total number of orders created") + .tag("service", "order-service") + .register(meterRegistry); + + // Counter for orders completed + this.ordersCompletedCounter = Counter.builder("orders.completed.total") + .description("Total number of orders completed successfully") + .tag("service", "order-service") + .register(meterRegistry); + + // Counter for orders cancelled + this.ordersCancelledCounter = Counter.builder("orders.cancelled.total") + .description("Total number of orders cancelled") + .tag("service", "order-service") + .register(meterRegistry); + + // Counter for orders failed + this.ordersFailedCounter = Counter.builder("orders.failed.total") + .description("Total number of orders that failed") + .tag("service", "order-service") + .register(meterRegistry); + + // Gauge for total order amount + this.totalOrderAmount = new AtomicLong(0); + Gauge.builder("orders.amount.total", totalOrderAmount, AtomicLong::get) + .description("Total amount of all orders") + .tag("service", "order-service") + .register(meterRegistry); + + // Gauge for active orders + this.activeOrders = new AtomicInteger(0); + Gauge.builder("orders.active.count", activeOrders, AtomicInteger::get) + .description("Number of active orders being processed") + .tag("service", "order-service") + .register(meterRegistry); + + // Timer for order processing time + this.orderProcessingTimer = Timer.builder("orders.processing.time") + .description("Time taken to process an order") + .tag("service", "order-service") + .register(meterRegistry); + } + + /** + * Record that an order was created + */ + public void recordOrderCreated() { + ordersCreatedCounter.increment(); + activeOrders.incrementAndGet(); + log.debug("Order created metric recorded"); + } + + /** + * Record that an order was created with amount + */ + public void recordOrderCreated(double amount) { + ordersCreatedCounter.increment(); + totalOrderAmount.addAndGet((long) amount); + activeOrders.incrementAndGet(); + log.debug("Order created with amount {} metric recorded", amount); + } + + /** + * Record that an order was completed + */ + public void recordOrderCompleted() { + ordersCompletedCounter.increment(); + activeOrders.decrementAndGet(); + log.debug("Order completed metric recorded"); + } + + /** + * Record that an order was cancelled + */ + public void recordOrderCancelled() { + ordersCancelledCounter.increment(); + activeOrders.decrementAndGet(); + log.debug("Order cancelled metric recorded"); + } + + /** + * Record that an order failed + */ + public void recordOrderFailed() { + ordersFailedCounter.increment(); + activeOrders.decrementAndGet(); + log.debug("Order failed metric recorded"); + } + + /** + * Get timer for recording order processing time + */ + public Timer.Sample startOrderProcessing() { + return Timer.start(); + } + + /** + * Stop timer and record order processing time + */ + public void stopOrderProcessing(Timer.Sample sample) { + sample.stop(orderProcessingTimer); + } +} diff --git a/order-service/src/main/resources/application.yml b/order-service/src/main/resources/application.yml index 1713e8409..1c4d568a2 100644 --- a/order-service/src/main/resources/application.yml +++ b/order-service/src/main/resources/application.yml @@ -5,15 +5,21 @@ server: spring: zipkin: - base-url: ${SPRING_ZIPKIN_BASE_URL:http://localhost:9411/} + base-url: ${SPRING_ZIPKIN_BASE_URL:http://zipkin:9411/} config: - import: ${SPRING_CONFIG_IMPORT:optional:configserver:http://localhost:9296} + import: ${SPRING_CONFIG_IMPORT:optional:configserver:http://cloud-config:9296} application: name: ORDER-SERVICE profiles: active: - dev +eureka: + client: + service-url: + defaultZone: ${EUREKA_CLIENT_SERVICEURL_DEFAULTZONE:http://service-discovery-container:8761/eureka/} + + resilience4j: circuitbreaker: instances: @@ -27,20 +33,104 @@ resilience4j: sliding-window-size: 10 wait-duration-in-open-state: 5s sliding-window-type: COUNT_BASED + + # Bulkhead Pattern Configuration + bulkhead: + instances: + orderProcessing: + max-concurrent-calls: 10 + max-wait-duration: 0ms + highPriorityOrders: + max-concurrent-calls: 5 + max-wait-duration: 0ms + + thread-pool-bulkhead: + instances: + orderAsyncProcessing: + max-thread-pool-size: 5 + core-thread-pool-size: 3 + queue-capacity: 20 + keep-alive-duration: 20ms + + # Retry Pattern Configuration + retry: + instances: + orderProcessing: + max-attempts: 3 + wait-duration: 1000ms + exponential-backoff-multiplier: 2 + retry-exceptions: + - java.net.ConnectException + - java.net.SocketTimeoutException + - org.springframework.web.client.HttpServerErrorException + + externalApiCall: + max-attempts: 5 + wait-duration: 500ms + exponential-backoff-multiplier: 1.5 + enable-exponential-backoff: true -management: - health: - circuitbreakers: +# Feature Toggle Configuration +features: + environment: ${SPRING_PROFILES_ACTIVE:dev} + toggles: + new-checkout-flow: enabled: true + rollout-percentage: 50 + description: "New optimized checkout flow with fewer steps" + enabled-environments: + - dev + - stage + + advanced-order-analytics: + enabled: true + rollout-percentage: 10 + description: "Advanced analytics and insights for orders" + enabled-users: + - admin@example.com + + order-retry-mechanism: + enabled: true + rollout-percentage: 100 + description: "Automatic retry for failed order processing" + + bulk-order-processing: + enabled: true + rollout-percentage: 25 + description: "Process multiple orders in a single transaction" + enabled-environments: + - dev + + real-time-inventory: + enabled: false + rollout-percentage: 0 + description: "Real-time inventory updates (experimental)" + +management: + endpoints: + web: + exposure: + include: health,info,metrics,prometheus,env,loggers,threaddump,heapdump,refresh,features endpoint: health: show-details: always - - - - - - - - - + probes: + enabled: true + metrics: + enabled: true + prometheus: + enabled: true + metrics: + export: + prometheus: + enabled: true + tags: + application: ${spring.application.name} + environment: ${spring.profiles.active} + health: + circuitbreakers: + enabled: true + livenessstate: + enabled: true + readinessstate: + enabled: true diff --git a/order-service/src/main/resources/logback-spring.xml b/order-service/src/main/resources/logback-spring.xml new file mode 100644 index 000000000..9a551a487 --- /dev/null +++ b/order-service/src/main/resources/logback-spring.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + {"app_name":"${appName}","app_port":"${appPort}"} + traceId + spanId + + + + + + logstash:5000 + + {"app_name":"${appName}","app_port":"${appPort}"} + traceId + spanId + + 5 minutes + + + + + + 512 + + + + + + + + + + + + + + + diff --git a/order-service/src/test/java/com/selimhorri/app/integration/OrderIntegrationTests.java b/order-service/src/test/java/com/selimhorri/app/integration/OrderIntegrationTests.java new file mode 100644 index 000000000..e79408936 --- /dev/null +++ b/order-service/src/test/java/com/selimhorri/app/integration/OrderIntegrationTests.java @@ -0,0 +1,73 @@ +package com.selimhorri.app.integration; + +import com.selimhorri.app.domain.Order; +import com.selimhorri.app.domain.Cart; +import com.selimhorri.app.dto.OrderDto; +import com.selimhorri.app.dto.CartDto; +import com.selimhorri.app.repository.OrderRepository; +import com.selimhorri.app.service.impl.OrderServiceImpl; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.Collections; +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +class OrderIntegrationTests { + + @Autowired + private OrderServiceImpl service; + + @MockBean + private OrderRepository repo; + + @Test + void contextLoads() { + assertNotNull(service); + } + + @Test + void testSaveIntegration() { + OrderDto dto = OrderDto.builder().orderId(10).orderDate(LocalDateTime.now()).orderDesc("desc").orderFee(5.0).cartDto(CartDto.builder().cartId(1).build()).build(); + Order entity = Order.builder().orderId(10).orderDate(dto.getOrderDate()).orderDesc(dto.getOrderDesc()).orderFee(dto.getOrderFee()).cart(Cart.builder().cartId(1).build()).build(); + Mockito.when(repo.save(Mockito.any())).thenReturn(entity); + OrderDto result = service.save(dto); + assertEquals(dto.getOrderId(), result.getOrderId()); + } + + @Test + void testUpdateIntegration() { + OrderDto dto = OrderDto.builder().orderId(10).orderDate(LocalDateTime.now()).orderDesc("desc").orderFee(5.0).cartDto(CartDto.builder().cartId(1).build()).build(); + Order entity = Order.builder().orderId(10).orderDate(dto.getOrderDate()).orderDesc(dto.getOrderDesc()).orderFee(dto.getOrderFee()).cart(Cart.builder().cartId(1).build()).build(); + Mockito.when(repo.save(Mockito.any())).thenReturn(entity); + OrderDto result = service.update(dto); + assertEquals(dto.getOrderId(), result.getOrderId()); + } + + @Test + void testDeleteByIdIntegration() { + Order order = Order.builder().orderId(10).orderDate(LocalDateTime.now()).orderDesc("desc").orderFee(5.0).cart(Cart.builder().cartId(1).build()).build(); + Mockito.when(repo.findById(10)).thenReturn(Optional.of(order)); + service.deleteById(10); + Mockito.verify(repo).delete(Mockito.any()); + } + + @Test + void testFindByIdIntegration() { + Order order = Order.builder().orderId(10).orderDate(LocalDateTime.now()).orderDesc("desc").orderFee(5.0).cart(Cart.builder().cartId(1).build()).build(); + Mockito.when(repo.findById(10)).thenReturn(Optional.of(order)); + OrderDto result = service.findById(10); + assertEquals(order.getOrderId(), result.getOrderId()); + } + + @Test + void testFindAllIntegration() { + Order order = Order.builder().orderId(10).orderDate(LocalDateTime.now()).orderDesc("desc").orderFee(5.0).cart(Cart.builder().cartId(1).build()).build(); + Mockito.when(repo.findAll()).thenReturn(Collections.singletonList(order)); + assertFalse(service.findAll().isEmpty()); + } +} \ No newline at end of file diff --git a/order-service/src/test/java/com/selimhorri/app/unit/OrderUnitTests.java b/order-service/src/test/java/com/selimhorri/app/unit/OrderUnitTests.java new file mode 100644 index 000000000..6459f226b --- /dev/null +++ b/order-service/src/test/java/com/selimhorri/app/unit/OrderUnitTests.java @@ -0,0 +1,68 @@ +package com.selimhorri.app.unit; + +import com.selimhorri.app.domain.Order; +import com.selimhorri.app.domain.Cart; +import com.selimhorri.app.dto.OrderDto; +import com.selimhorri.app.dto.CartDto; +import com.selimhorri.app.helper.OrderMappingHelper; +import com.selimhorri.app.repository.OrderRepository; +import com.selimhorri.app.service.impl.OrderServiceImpl; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import java.time.LocalDateTime; +import java.util.Collections; +import static org.junit.jupiter.api.Assertions.*; + +class OrderUnitTests { + + @Test + void testMapOrderToDto() { + Cart cart = Cart.builder().cartId(1).build(); + Order order = Order.builder().orderId(10).orderDate(LocalDateTime.now()).orderDesc("desc").orderFee(5.0).cart(cart).build(); + OrderDto dto = OrderMappingHelper.map(order); + assertEquals(order.getOrderId(), dto.getOrderId()); + assertEquals(order.getOrderDesc(), dto.getOrderDesc()); + assertNotNull(dto.getCartDto()); + } + + @Test + void testMapDtoToOrder() { + CartDto cartDto = CartDto.builder().cartId(1).build(); + OrderDto dto = OrderDto.builder().orderId(10).orderDate(LocalDateTime.now()).orderDesc("desc").orderFee(5.0).cartDto(cartDto).build(); + Order order = OrderMappingHelper.map(dto); + assertEquals(dto.getOrderId(), order.getOrderId()); + assertEquals(dto.getOrderDesc(), order.getOrderDesc()); + assertNotNull(order.getCart()); + } + + @Test + void testSaveCallsRepository() { + OrderRepository repo = Mockito.mock(OrderRepository.class); + OrderServiceImpl service = new OrderServiceImpl(repo); + OrderDto dto = OrderDto.builder().orderId(10).orderDate(LocalDateTime.now()).orderDesc("desc").orderFee(5.0).cartDto(CartDto.builder().cartId(1).build()).build(); + Order entity = OrderMappingHelper.map(dto); + Mockito.when(repo.save(Mockito.any())).thenReturn(entity); + OrderDto result = service.save(dto); + assertEquals(dto.getOrderId(), result.getOrderId()); + } + + @Test + void testUpdateCallsRepository() { + OrderRepository repo = Mockito.mock(OrderRepository.class); + OrderServiceImpl service = new OrderServiceImpl(repo); + OrderDto dto = OrderDto.builder().orderId(10).orderDate(LocalDateTime.now()).orderDesc("desc").orderFee(5.0).cartDto(CartDto.builder().cartId(1).build()).build(); + Order entity = OrderMappingHelper.map(dto); + Mockito.when(repo.save(Mockito.any())).thenReturn(entity); + OrderDto result = service.update(dto); + assertEquals(dto.getOrderId(), result.getOrderId()); + } + + @Test + void testFindAllReturnsList() { + OrderRepository repo = Mockito.mock(OrderRepository.class); + OrderServiceImpl service = new OrderServiceImpl(repo); + Order order = Order.builder().orderId(10).orderDate(LocalDateTime.now()).orderDesc("desc").orderFee(5.0).cart(Cart.builder().cartId(1).build()).build(); + Mockito.when(repo.findAll()).thenReturn(Collections.singletonList(order)); + assertFalse(service.findAll().isEmpty()); + } +} \ No newline at end of file diff --git a/payment-service/Dockerfile b/payment-service/Dockerfile index 57d5b931c..b59e40639 100644 --- a/payment-service/Dockerfile +++ b/payment-service/Dockerfile @@ -3,10 +3,8 @@ FROM openjdk:11 ARG PROJECT_VERSION=0.1.0 RUN mkdir -p /home/app WORKDIR /home/app -ENV SPRING_PROFILES_ACTIVE dev +ENV SPRING_PROFILES_ACTIVE=dev COPY payment-service/ . ADD payment-service/target/payment-service-v${PROJECT_VERSION}.jar payment-service.jar EXPOSE 8400 -ENTRYPOINT ["java", "-Dspring.profiles.active=${SPRING_PROFILES_ACTIVE}", "-jar", "payment-service.jar"] - - +ENTRYPOINT ["java", "-Dspring.profiles.active=${SPRING_PROFILES_ACTIVE}", "-jar", "payment-service.jar"] \ No newline at end of file diff --git a/payment-service/compose.yml b/payment-service/compose.yml index 4c3539d19..c64932a5b 100644 --- a/payment-service/compose.yml +++ b/payment-service/compose.yml @@ -1,12 +1,22 @@ - version: '3' services: payment-service-container: - image: selimhorri/payment-service-ecommerce-boot:0.1.0 + image: juanse201/payment-service-ecommerce-boot:0.1.0 ports: - 8400:8400 environment: - SPRING_PROFILES_ACTIVE=dev - + - SPRING_ZIPKIN_BASE_URL=http://zipkin:9411 + - SPRING_CONFIG_IMPORT=optional:configserver:http://cloud-config-container:9296/ + - EUREKA_CLIENT_REGION=default + - EUREKA_CLIENT_AVAILABILITY_ZONES_DEFAULT=myzone + - EUREKA_CLIENT_SERVICEURL_MYZONE=http://service-discovery-container:8761/eureka + - EUREKA_CLIENT_SERVICEURL_DEFAULTZONE=http://service-discovery-container:8761/eureka/ + networks: + - microservices_network +networks: + microservices_network: + external: true + name: microservices_network diff --git a/payment-service/pom.xml b/payment-service/pom.xml index 0f3da9562..8121dafc4 100644 --- a/payment-service/pom.xml +++ b/payment-service/pom.xml @@ -73,6 +73,23 @@ mysql test + + org.springframework.boot + spring-boot-starter-actuator + + + io.micrometer + micrometer-registry-prometheus + + + net.logstash.logback + logstash-logback-encoder + 7.3 + + + org.springframework.cloud + spring-cloud-starter-sleuth + diff --git a/payment-service/src/main/java/com/selimhorri/app/health/DatabaseHealthIndicator.java b/payment-service/src/main/java/com/selimhorri/app/health/DatabaseHealthIndicator.java new file mode 100644 index 000000000..261ec76e2 --- /dev/null +++ b/payment-service/src/main/java/com/selimhorri/app/health/DatabaseHealthIndicator.java @@ -0,0 +1,53 @@ +package com.selimhorri.app.health; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.HealthIndicator; +import org.springframework.stereotype.Component; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.Statement; + +/** + * Custom health indicator for database connectivity + */ +@Component("database") +@RequiredArgsConstructor +@Slf4j +public class DatabaseHealthIndicator implements HealthIndicator { + + private final DataSource dataSource; + + @Override + public Health health() { + try (Connection connection = dataSource.getConnection(); + Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery("SELECT 1")) { + + if (resultSet.next()) { + // Get database metadata + String databaseProductName = connection.getMetaData().getDatabaseProductName(); + String databaseProductVersion = connection.getMetaData().getDatabaseProductVersion(); + + return Health.up() + .withDetail("database", databaseProductName) + .withDetail("version", databaseProductVersion) + .withDetail("status", "Connected") + .build(); + } else { + return Health.down() + .withDetail("status", "Unable to validate connection") + .build(); + } + } catch (Exception e) { + log.error("Database health check failed", e); + return Health.down() + .withDetail("error", e.getMessage()) + .withException(e) + .build(); + } + } +} diff --git a/payment-service/src/main/java/com/selimhorri/app/service/PaymentMetricsService.java b/payment-service/src/main/java/com/selimhorri/app/service/PaymentMetricsService.java new file mode 100644 index 000000000..4306122ea --- /dev/null +++ b/payment-service/src/main/java/com/selimhorri/app/service/PaymentMetricsService.java @@ -0,0 +1,97 @@ +package com.selimhorri.app.service; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.concurrent.atomic.AtomicLong; + +/** + * Service for tracking business metrics related to payments + */ +@Service +@Slf4j +public class PaymentMetricsService { + + private final Counter paymentsProcessedCounter; + private final Counter paymentsFailedCounter; + private final Counter paymentsRefundedCounter; + private final AtomicLong totalPaymentAmount; + private final Timer paymentProcessingTimer; + + public PaymentMetricsService(MeterRegistry meterRegistry) { + // Counter for payments processed + this.paymentsProcessedCounter = Counter.builder("payments.processed.total") + .description("Total number of payments processed successfully") + .tag("service", "payment-service") + .register(meterRegistry); + + // Counter for payments failed + this.paymentsFailedCounter = Counter.builder("payments.failed.total") + .description("Total number of payments that failed") + .tag("service", "payment-service") + .register(meterRegistry); + + // Counter for payments refunded + this.paymentsRefundedCounter = Counter.builder("payments.refunded.total") + .description("Total number of payments refunded") + .tag("service", "payment-service") + .register(meterRegistry); + + // Gauge for total payment amount + this.totalPaymentAmount = new AtomicLong(0); + Gauge.builder("payments.amount.total", totalPaymentAmount, AtomicLong::get) + .description("Total amount of all payments processed") + .tag("service", "payment-service") + .register(meterRegistry); + + // Timer for payment processing time + this.paymentProcessingTimer = Timer.builder("payments.processing.time") + .description("Time taken to process a payment") + .tag("service", "payment-service") + .register(meterRegistry); + } + + /** + * Record that a payment was processed successfully + */ + public void recordPaymentProcessed(double amount) { + paymentsProcessedCounter.increment(); + totalPaymentAmount.addAndGet((long) amount); + log.debug("Payment processed metric recorded with amount {}", amount); + } + + /** + * Record that a payment failed + */ + public void recordPaymentFailed() { + paymentsFailedCounter.increment(); + log.debug("Payment failed metric recorded"); + } + + /** + * Record that a payment was refunded + */ + public void recordPaymentRefunded(double amount) { + paymentsRefundedCounter.increment(); + totalPaymentAmount.addAndGet(-((long) amount)); + log.debug("Payment refunded metric recorded with amount {}", amount); + } + + /** + * Get timer for recording payment processing time + */ + public Timer.Sample startPaymentProcessing() { + return Timer.start(); + } + + /** + * Stop timer and record payment processing time + */ + public void stopPaymentProcessing(Timer.Sample sample) { + sample.stop(paymentProcessingTimer); + } +} diff --git a/payment-service/src/main/resources/application.yml b/payment-service/src/main/resources/application.yml index f595a0bf3..f7fbbfcea 100644 --- a/payment-service/src/main/resources/application.yml +++ b/payment-service/src/main/resources/application.yml @@ -5,15 +5,21 @@ server: spring: zipkin: - base-url: ${SPRING_ZIPKIN_BASE_URL:http://localhost:9411/} + base-url: ${SPRING_ZIPKIN_BASE_URL:http://zipkin:9411/} config: - import: ${SPRING_CONFIG_IMPORT:optional:configserver:http://localhost:9296} + import: ${SPRING_CONFIG_IMPORT:optional:configserver:http://cloud-config:9296} application: name: PAYMENT-SERVICE profiles: active: - dev +eureka: + client: + service-url: + defaultZone: ${EUREKA_CLIENT_SERVICEURL_DEFAULTZONE:http://service-discovery-container:8761/eureka/} + + resilience4j: circuitbreaker: instances: @@ -29,12 +35,33 @@ resilience4j: sliding-window-type: COUNT_BASED management: - health: - circuitbreakers: - enabled: true + endpoints: + web: + exposure: + include: health,info,metrics,prometheus,env,loggers,threaddump,heapdump endpoint: health: show-details: always + probes: + enabled: true + metrics: + enabled: true + prometheus: + enabled: true + metrics: + export: + prometheus: + enabled: true + tags: + application: ${spring.application.name} + environment: ${spring.profiles.active} + health: + circuitbreakers: + enabled: true + livenessstate: + enabled: true + readinessstate: + enabled: true diff --git a/payment-service/src/main/resources/logback-spring.xml b/payment-service/src/main/resources/logback-spring.xml new file mode 100644 index 000000000..9a551a487 --- /dev/null +++ b/payment-service/src/main/resources/logback-spring.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + {"app_name":"${appName}","app_port":"${appPort}"} + traceId + spanId + + + + + + logstash:5000 + + {"app_name":"${appName}","app_port":"${appPort}"} + traceId + spanId + + 5 minutes + + + + + + 512 + + + + + + + + + + + + + + + diff --git a/payment-service/src/test/java/com/selimhorri/app/integration/PaymentIntegrationTests.java b/payment-service/src/test/java/com/selimhorri/app/integration/PaymentIntegrationTests.java new file mode 100644 index 000000000..1d44f3bb9 --- /dev/null +++ b/payment-service/src/test/java/com/selimhorri/app/integration/PaymentIntegrationTests.java @@ -0,0 +1,70 @@ +package com.selimhorri.app.integration; + +import com.selimhorri.app.domain.Payment; +import com.selimhorri.app.dto.PaymentDto; +import com.selimhorri.app.dto.OrderDto; +import com.selimhorri.app.repository.PaymentRepository; +import com.selimhorri.app.service.impl.PaymentServiceImpl; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.web.client.RestTemplate; +import java.util.Optional; +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +class PaymentIntegrationTests { + + @Autowired + private PaymentServiceImpl service; + + @MockBean + private PaymentRepository repo; + + @MockBean + private RestTemplate restTemplate; + + @Test + void contextLoads() { + assertNotNull(service); + } + + + @Test + void testSaveIntegration() { + // Set orderDto to avoid NullPointerException in mapping/service logic + OrderDto orderDto = OrderDto.builder().orderId(2).build(); + PaymentDto dto = PaymentDto.builder().paymentId(1).isPayed(true).paymentStatus(null).orderDto(orderDto).build(); + Payment entity = Payment.builder().paymentId(1).isPayed(true).paymentStatus(null).orderId(2).build(); + Mockito.when(repo.save(Mockito.any())).thenReturn(entity); + PaymentDto result = service.save(dto); + assertEquals(dto.getPaymentId(), result.getPaymentId()); + } + + @Test + void testUpdateIntegration() { + OrderDto orderDto = OrderDto.builder().orderId(2).build(); + PaymentDto dto = PaymentDto.builder().paymentId(1).isPayed(true).paymentStatus(null).orderDto(orderDto).build(); + Payment entity = Payment.builder().paymentId(1).isPayed(true).paymentStatus(null).orderId(2).build(); + Mockito.when(repo.save(Mockito.any())).thenReturn(entity); + PaymentDto result = service.update(dto); + assertEquals(dto.getPaymentId(), result.getPaymentId()); + } + + @Test + void testDeleteByIdIntegration() { + service.deleteById(1); + Mockito.verify(repo).deleteById(1); + } + + @Test + void testFindByIdIntegration() { + Payment entity = Payment.builder().paymentId(1).isPayed(true).paymentStatus(null).orderId(2).build(); + Mockito.when(repo.findById(1)).thenReturn(Optional.of(entity)); + Mockito.when(restTemplate.getForObject(Mockito.anyString(), Mockito.any())).thenReturn(com.selimhorri.app.dto.OrderDto.builder().orderId(2).build()); + PaymentDto result = service.findById(1); + assertEquals(entity.getPaymentId(), result.getPaymentId()); + } +} \ No newline at end of file diff --git a/payment-service/src/test/java/com/selimhorri/app/unit/PaymentUnitTests.java b/payment-service/src/test/java/com/selimhorri/app/unit/PaymentUnitTests.java new file mode 100644 index 000000000..395a7b529 --- /dev/null +++ b/payment-service/src/test/java/com/selimhorri/app/unit/PaymentUnitTests.java @@ -0,0 +1,71 @@ +package com.selimhorri.app.unit; + +import com.selimhorri.app.domain.Payment; +import com.selimhorri.app.dto.PaymentDto; +import com.selimhorri.app.dto.OrderDto; +import com.selimhorri.app.helper.PaymentMappingHelper; +import com.selimhorri.app.repository.PaymentRepository; +import com.selimhorri.app.service.impl.PaymentServiceImpl; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.web.client.RestTemplate; +import static org.junit.jupiter.api.Assertions.*; + +class PaymentUnitTests { + + + @Test + void testMapPaymentToDto() { + Payment payment = Payment.builder().paymentId(1).isPayed(true).paymentStatus(null).orderId(2).build(); + PaymentDto dto = PaymentMappingHelper.map(payment); + assertEquals(payment.getPaymentId(), dto.getPaymentId()); + assertEquals(payment.getIsPayed(), dto.getIsPayed()); + assertEquals(payment.getPaymentStatus(), dto.getPaymentStatus()); + } + + @Test + void testMapDtoToPayment() { + OrderDto orderDto = OrderDto.builder().orderId(2).build(); + PaymentDto dto = PaymentDto.builder().paymentId(1).isPayed(true).paymentStatus(null).orderDto(orderDto).build(); + Payment payment = PaymentMappingHelper.map(dto); + assertEquals(dto.getPaymentId(), payment.getPaymentId()); + assertEquals(dto.getIsPayed(), payment.getIsPayed()); + assertEquals(dto.getPaymentStatus(), payment.getPaymentStatus()); + } + + + @Test + void testSaveCallsRepository() { + PaymentRepository repo = Mockito.mock(PaymentRepository.class); + RestTemplate restTemplate = Mockito.mock(RestTemplate.class); + PaymentServiceImpl service = new PaymentServiceImpl(repo, restTemplate); + OrderDto orderDto = OrderDto.builder().orderId(2).build(); + PaymentDto dto = PaymentDto.builder().paymentId(1).isPayed(true).paymentStatus(null).orderDto(orderDto).build(); + Payment entity = PaymentMappingHelper.map(dto); + Mockito.when(repo.save(Mockito.any())).thenReturn(entity); + PaymentDto result = service.save(dto); + assertEquals(dto.getPaymentId(), result.getPaymentId()); + } + + @Test + void testUpdateCallsRepository() { + PaymentRepository repo = Mockito.mock(PaymentRepository.class); + RestTemplate restTemplate = Mockito.mock(RestTemplate.class); + PaymentServiceImpl service = new PaymentServiceImpl(repo, restTemplate); + OrderDto orderDto = OrderDto.builder().orderId(2).build(); + PaymentDto dto = PaymentDto.builder().paymentId(1).isPayed(true).paymentStatus(null).orderDto(orderDto).build(); + Payment entity = PaymentMappingHelper.map(dto); + Mockito.when(repo.save(Mockito.any())).thenReturn(entity); + PaymentDto result = service.update(dto); + assertEquals(dto.getPaymentId(), result.getPaymentId()); + } + + @Test + void testDeleteByIdCallsRepository() { + PaymentRepository repo = Mockito.mock(PaymentRepository.class); + RestTemplate restTemplate = Mockito.mock(RestTemplate.class); + PaymentServiceImpl service = new PaymentServiceImpl(repo, restTemplate); + service.deleteById(1); + Mockito.verify(repo).deleteById(1); + } +} \ No newline at end of file diff --git a/pom.xml b/pom.xml index 832247436..8c972e38f 100644 --- a/pom.xml +++ b/pom.xml @@ -32,6 +32,10 @@ org.springframework.boot spring-boot-starter-actuator + + org.springframework.boot + spring-boot-starter-actuator + org.springframework.cloud spring-cloud-starter-circuitbreaker-resilience4j diff --git a/product-service/Dockerfile b/product-service/Dockerfile index c052995cf..f73406b40 100644 --- a/product-service/Dockerfile +++ b/product-service/Dockerfile @@ -3,10 +3,8 @@ FROM openjdk:11 ARG PROJECT_VERSION=0.1.0 RUN mkdir -p /home/app WORKDIR /home/app -ENV SPRING_PROFILES_ACTIVE dev +ENV SPRING_PROFILES_ACTIVE=dev COPY product-service/ . ADD product-service/target/product-service-v${PROJECT_VERSION}.jar product-service.jar EXPOSE 8500 -ENTRYPOINT ["java", "-Dspring.profiles.active=${SPRING_PROFILES_ACTIVE}", "-jar", "product-service.jar"] - - +ENTRYPOINT ["java", "-Dspring.profiles.active=${SPRING_PROFILES_ACTIVE}", "-jar", "product-service.jar"] \ No newline at end of file diff --git a/product-service/compose.yml b/product-service/compose.yml index 618eb819a..3f8286fa3 100644 --- a/product-service/compose.yml +++ b/product-service/compose.yml @@ -1,12 +1,23 @@ - version: '3' services: product-service-container: - image: selimhorri/product-service-ecommerce-boot:0.1.0 + image: juanse201/product-service-ecommerce-boot:0.1.0 ports: - 8500:8500 environment: - SPRING_PROFILES_ACTIVE=dev + - SPRING_ZIPKIN_BASE_URL=http://zipkin:9411 + - SPRING_CONFIG_IMPORT=optional:configserver:http://cloud-config-container:9296/ + - EUREKA_CLIENT_REGION=default + - EUREKA_CLIENT_AVAILABILITY_ZONES_DEFAULT=myzone + - EUREKA_CLIENT_SERVICEURL_MYZONE=http://service-discovery-container:8761/eureka + - EUREKA_CLIENT_SERVICEURL_DEFAULTZONE=http://service-discovery-container:8761/eureka/ + networks: + - microservices_network +networks: + microservices_network: + external: true + name: microservices_network diff --git a/product-service/pom.xml b/product-service/pom.xml index ac89c6c8a..63ebccfba 100644 --- a/product-service/pom.xml +++ b/product-service/pom.xml @@ -73,6 +73,23 @@ mysql test + + org.springframework.boot + spring-boot-starter-actuator + + + io.micrometer + micrometer-registry-prometheus + + + net.logstash.logback + logstash-logback-encoder + 7.3 + + + org.springframework.cloud + spring-cloud-starter-sleuth + diff --git a/product-service/src/main/java/com/selimhorri/app/health/DatabaseHealthIndicator.java b/product-service/src/main/java/com/selimhorri/app/health/DatabaseHealthIndicator.java new file mode 100644 index 000000000..261ec76e2 --- /dev/null +++ b/product-service/src/main/java/com/selimhorri/app/health/DatabaseHealthIndicator.java @@ -0,0 +1,53 @@ +package com.selimhorri.app.health; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.HealthIndicator; +import org.springframework.stereotype.Component; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.Statement; + +/** + * Custom health indicator for database connectivity + */ +@Component("database") +@RequiredArgsConstructor +@Slf4j +public class DatabaseHealthIndicator implements HealthIndicator { + + private final DataSource dataSource; + + @Override + public Health health() { + try (Connection connection = dataSource.getConnection(); + Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery("SELECT 1")) { + + if (resultSet.next()) { + // Get database metadata + String databaseProductName = connection.getMetaData().getDatabaseProductName(); + String databaseProductVersion = connection.getMetaData().getDatabaseProductVersion(); + + return Health.up() + .withDetail("database", databaseProductName) + .withDetail("version", databaseProductVersion) + .withDetail("status", "Connected") + .build(); + } else { + return Health.down() + .withDetail("status", "Unable to validate connection") + .build(); + } + } catch (Exception e) { + log.error("Database health check failed", e); + return Health.down() + .withDetail("error", e.getMessage()) + .withException(e) + .build(); + } + } +} diff --git a/product-service/src/main/java/com/selimhorri/app/service/ProductMetricsService.java b/product-service/src/main/java/com/selimhorri/app/service/ProductMetricsService.java new file mode 100644 index 000000000..b7ad28f79 --- /dev/null +++ b/product-service/src/main/java/com/selimhorri/app/service/ProductMetricsService.java @@ -0,0 +1,112 @@ +package com.selimhorri.app.service; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.MeterRegistry; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Service for tracking business metrics related to products + */ +@Service +@Slf4j +public class ProductMetricsService { + + private final Counter productsCreatedCounter; + private final Counter productsViewedCounter; + private final Counter productsUpdatedCounter; + private final Counter productsDeletedCounter; + private final AtomicInteger lowStockProducts; + + public ProductMetricsService(MeterRegistry meterRegistry) { + // Counter for products created + this.productsCreatedCounter = Counter.builder("products.created.total") + .description("Total number of products created") + .tag("service", "product-service") + .register(meterRegistry); + + // Counter for product views + this.productsViewedCounter = Counter.builder("products.views.total") + .description("Total number of product views") + .tag("service", "product-service") + .register(meterRegistry); + + // Counter for products updated + this.productsUpdatedCounter = Counter.builder("products.updated.total") + .description("Total number of products updated") + .tag("service", "product-service") + .register(meterRegistry); + + // Counter for products deleted + this.productsDeletedCounter = Counter.builder("products.deleted.total") + .description("Total number of products deleted") + .tag("service", "product-service") + .register(meterRegistry); + + // Gauge for low stock products + this.lowStockProducts = new AtomicInteger(0); + Gauge.builder("products.stock.low", lowStockProducts, AtomicInteger::get) + .description("Number of products with low stock levels") + .tag("service", "product-service") + .register(meterRegistry); + } + + /** + * Record that a product was created + */ + public void recordProductCreated() { + productsCreatedCounter.increment(); + log.debug("Product created metric recorded"); + } + + /** + * Record that a product was viewed + */ + public void recordProductViewed() { + productsViewedCounter.increment(); + log.debug("Product view metric recorded"); + } + + /** + * Record that a product was updated + */ + public void recordProductUpdated() { + productsUpdatedCounter.increment(); + log.debug("Product updated metric recorded"); + } + + /** + * Record that a product was deleted + */ + public void recordProductDeleted() { + productsDeletedCounter.increment(); + log.debug("Product deleted metric recorded"); + } + + /** + * Update low stock products count + */ + public void updateLowStockCount(int count) { + lowStockProducts.set(count); + log.debug("Low stock products count updated to {}", count); + } + + /** + * Increment low stock products count + */ + public void incrementLowStockCount() { + lowStockProducts.incrementAndGet(); + log.debug("Low stock products count incremented"); + } + + /** + * Decrement low stock products count + */ + public void decrementLowStockCount() { + lowStockProducts.decrementAndGet(); + log.debug("Low stock products count decremented"); + } +} diff --git a/product-service/src/main/resources/application.yml b/product-service/src/main/resources/application.yml index 75c2b0a46..eec1d3ad8 100644 --- a/product-service/src/main/resources/application.yml +++ b/product-service/src/main/resources/application.yml @@ -5,15 +5,20 @@ server: spring: zipkin: - base-url: ${SPRING_ZIPKIN_BASE_URL:http://localhost:9411/} + base-url: ${SPRING_ZIPKIN_BASE_URL:http://zipkin:9411/} config: - import: ${SPRING_CONFIG_IMPORT:optional:configserver:http://localhost:9296} + import: ${SPRING_CONFIG_IMPORT:optional:configserver:http://cloud-config:9296} application: name: PRODUCT-SERVICE profiles: active: - dev +eureka: + client: + service-url: + defaultZone: ${EUREKA_CLIENT_SERVICEURL_DEFAULTZONE:http://service-discovery-container:8761/eureka/} + resilience4j: circuitbreaker: instances: @@ -29,12 +34,33 @@ resilience4j: sliding-window-type: COUNT_BASED management: - health: - circuitbreakers: - enabled: true + endpoints: + web: + exposure: + include: health,info,metrics,prometheus,env,loggers,threaddump,heapdump endpoint: health: show-details: always + probes: + enabled: true + metrics: + enabled: true + prometheus: + enabled: true + metrics: + export: + prometheus: + enabled: true + tags: + application: ${spring.application.name} + environment: ${spring.profiles.active} + health: + circuitbreakers: + enabled: true + livenessstate: + enabled: true + readinessstate: + enabled: true diff --git a/product-service/src/main/resources/logback-spring.xml b/product-service/src/main/resources/logback-spring.xml new file mode 100644 index 000000000..9a551a487 --- /dev/null +++ b/product-service/src/main/resources/logback-spring.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + {"app_name":"${appName}","app_port":"${appPort}"} + traceId + spanId + + + + + + logstash:5000 + + {"app_name":"${appName}","app_port":"${appPort}"} + traceId + spanId + + 5 minutes + + + + + + 512 + + + + + + + + + + + + + + + diff --git a/product-service/src/test/java/com/selimhorri/app/integration/ProductIntegrationTests.java b/product-service/src/test/java/com/selimhorri/app/integration/ProductIntegrationTests.java new file mode 100644 index 000000000..9b5cf7eeb --- /dev/null +++ b/product-service/src/test/java/com/selimhorri/app/integration/ProductIntegrationTests.java @@ -0,0 +1,72 @@ +package com.selimhorri.app.integration; + +import com.selimhorri.app.domain.Product; +import com.selimhorri.app.domain.Category; +import com.selimhorri.app.dto.ProductDto; +import com.selimhorri.app.dto.CategoryDto; +import com.selimhorri.app.repository.ProductRepository; +import com.selimhorri.app.service.impl.ProductServiceImpl; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import java.util.Optional; +import java.util.Collections; +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +class ProductIntegrationTests { + + @Autowired + private ProductServiceImpl service; + + @MockBean + private ProductRepository repo; + + @Test + void contextLoads() { + assertNotNull(service); + } + + @Test + void testSaveIntegration() { + ProductDto dto = ProductDto.builder().productId(10).productTitle("Test").priceUnit(99.99).categoryDto(CategoryDto.builder().categoryId(1).build()).build(); + Product entity = Product.builder().productId(10).productTitle("Test").priceUnit(99.99).category(Category.builder().categoryId(1).build()).build(); + Mockito.when(repo.save(Mockito.any())).thenReturn(entity); + ProductDto result = service.save(dto); + assertEquals(dto.getProductId(), result.getProductId()); + } + + @Test + void testUpdateIntegration() { + ProductDto dto = ProductDto.builder().productId(10).productTitle("Test").priceUnit(99.99).categoryDto(CategoryDto.builder().categoryId(1).build()).build(); + Product entity = Product.builder().productId(10).productTitle("Test").priceUnit(99.99).category(Category.builder().categoryId(1).build()).build(); + Mockito.when(repo.save(Mockito.any())).thenReturn(entity); + ProductDto result = service.update(dto); + assertEquals(dto.getProductId(), result.getProductId()); + } + + @Test + void testDeleteByIdIntegration() { + Product product = Product.builder().productId(10).productTitle("Test").priceUnit(99.99).category(Category.builder().categoryId(1).build()).build(); + Mockito.when(repo.findById(10)).thenReturn(Optional.of(product)); + service.deleteById(10); + Mockito.verify(repo).delete(Mockito.any()); + } + + @Test + void testFindByIdIntegration() { + Product product = Product.builder().productId(10).productTitle("Test").priceUnit(99.99).category(Category.builder().categoryId(1).build()).build(); + Mockito.when(repo.findById(10)).thenReturn(Optional.of(product)); + ProductDto result = service.findById(10); + assertEquals(product.getProductId(), result.getProductId()); + } + + @Test + void testFindAllIntegration() { + Product product = Product.builder().productId(10).productTitle("Test").priceUnit(99.99).category(Category.builder().categoryId(1).build()).build(); + Mockito.when(repo.findAll()).thenReturn(Collections.singletonList(product)); + assertFalse(service.findAll().isEmpty()); + } +} \ No newline at end of file diff --git a/product-service/src/test/java/com/selimhorri/app/unit/ProductUnitTests.java b/product-service/src/test/java/com/selimhorri/app/unit/ProductUnitTests.java new file mode 100644 index 000000000..97e9cc9db --- /dev/null +++ b/product-service/src/test/java/com/selimhorri/app/unit/ProductUnitTests.java @@ -0,0 +1,72 @@ +package com.selimhorri.app.unit; + +import com.selimhorri.app.domain.Product; +import com.selimhorri.app.domain.Category; +import com.selimhorri.app.dto.ProductDto; +import com.selimhorri.app.dto.CategoryDto; +import com.selimhorri.app.helper.ProductMappingHelper; +import com.selimhorri.app.repository.ProductRepository; +import com.selimhorri.app.service.impl.ProductServiceImpl; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import java.util.Collections; +import static org.junit.jupiter.api.Assertions.*; + +class ProductUnitTests { + + + @Test + void testMapProductToDto() { + Category category = Category.builder().categoryId(1).build(); + Product product = Product.builder().productId(10).productTitle("Test").priceUnit(99.99).category(category).build(); + ProductDto dto = ProductMappingHelper.map(product); + assertEquals(product.getProductId(), dto.getProductId()); + assertEquals(product.getProductTitle(), dto.getProductTitle()); + assertNotNull(dto.getCategoryDto()); + } + + + @Test + void testMapDtoToProduct() { + CategoryDto categoryDto = CategoryDto.builder().categoryId(1).build(); + ProductDto dto = ProductDto.builder().productId(10).productTitle("Test").priceUnit(99.99).categoryDto(categoryDto).build(); + Product product = ProductMappingHelper.map(dto); + assertEquals(dto.getProductId(), product.getProductId()); + assertEquals(dto.getProductTitle(), product.getProductTitle()); + assertNotNull(product.getCategory()); + } + + + @Test + void testSaveCallsRepository() { + ProductRepository repo = Mockito.mock(ProductRepository.class); + ProductServiceImpl service = new ProductServiceImpl(repo); + ProductDto dto = ProductDto.builder().productId(10).productTitle("Test").priceUnit(99.99).categoryDto(CategoryDto.builder().categoryId(1).build()).build(); + Product entity = ProductMappingHelper.map(dto); + Mockito.when(repo.save(Mockito.any())).thenReturn(entity); + ProductDto result = service.save(dto); + assertEquals(dto.getProductId(), result.getProductId()); + } + + + @Test + void testUpdateCallsRepository() { + ProductRepository repo = Mockito.mock(ProductRepository.class); + ProductServiceImpl service = new ProductServiceImpl(repo); + ProductDto dto = ProductDto.builder().productId(10).productTitle("Test").priceUnit(99.99).categoryDto(CategoryDto.builder().categoryId(1).build()).build(); + Product entity = ProductMappingHelper.map(dto); + Mockito.when(repo.save(Mockito.any())).thenReturn(entity); + ProductDto result = service.update(dto); + assertEquals(dto.getProductId(), result.getProductId()); + } + + + @Test + void testFindAllReturnsList() { + ProductRepository repo = Mockito.mock(ProductRepository.class); + ProductServiceImpl service = new ProductServiceImpl(repo); + Product product = Product.builder().productId(10).productTitle("Test").priceUnit(99.99).category(Category.builder().categoryId(1).build()).build(); + Mockito.when(repo.findAll()).thenReturn(Collections.singletonList(product)); + assertFalse(service.findAll().isEmpty()); + } +} \ No newline at end of file diff --git a/prometheus/alert.rules.yml b/prometheus/alert.rules.yml new file mode 100644 index 000000000..3a204a3f9 --- /dev/null +++ b/prometheus/alert.rules.yml @@ -0,0 +1,11 @@ +groups: + - name: ecommerce-alerts + rules: + - alert: HighErrorRate + expr: rate(http_server_requests_seconds_count{status="500"}[5m]) > 5 + for: 1m + labels: + severity: critical + annotations: + summary: "High error rate detected" + description: "More than 5 errors 500 per minute detected in the last 5 minutes." diff --git a/prometheus/alertmanager.yml b/prometheus/alertmanager.yml new file mode 100644 index 000000000..31c331523 --- /dev/null +++ b/prometheus/alertmanager.yml @@ -0,0 +1,49 @@ +global: + resolve_timeout: 5m + +route: + group_by: ['alertname', 'cluster', 'service'] + group_wait: 10s + group_interval: 10s + repeat_interval: 12h + receiver: 'default' + routes: + - match: + severity: critical + receiver: 'critical' + continue: false + - match: + severity: warning + receiver: 'warning' + continue: false + +receivers: + - name: 'default' + webhook_configs: + - url: 'http://localhost:5001/alerts' + send_resolved: true + + - name: 'critical' + webhook_configs: + - url: 'http://localhost:5001/alerts/critical' + send_resolved: true + # Descomentar y configurar para usar email + # email_configs: + # - to: 'team@example.com' + # from: 'alertmanager@example.com' + # smarthost: 'smtp.example.com:587' + # auth_username: 'alertmanager@example.com' + # auth_password: 'password' + + - name: 'warning' + webhook_configs: + - url: 'http://localhost:5001/alerts/warning' + send_resolved: true + +inhibit_rules: + # Inhibit warning alerts when critical alerts are firing + - source_match: + severity: 'critical' + target_match: + severity: 'warning' + equal: ['alertname', 'cluster', 'service'] diff --git a/prometheus/prometheus-lite.yml b/prometheus/prometheus-lite.yml new file mode 100644 index 000000000..f4d898c5c --- /dev/null +++ b/prometheus/prometheus-lite.yml @@ -0,0 +1,27 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + external_labels: + cluster: 'ecommerce-dev' + environment: 'development' + +alerting: + alertmanagers: + - static_configs: + - targets: ['alertmanager:9093'] + +rule_files: + - '/etc/prometheus/alert.rules.yml' + +scrape_configs: + # Solo los 3 microservicios que están corriendo + - job_name: 'ecommerce-services' + metrics_path: '/actuator/prometheus' + scrape_interval: 10s + static_configs: + - targets: + - 'ecommerce-microservice-backend-app-product-service-container-1:8500' + - 'ecommerce-microservice-backend-app-order-service-container-1:8300' + - 'ecommerce-microservice-backend-app-payment-service-container-1:8400' + labels: + environment: 'dev' diff --git a/prometheus/prometheus.yml b/prometheus/prometheus.yml new file mode 100644 index 000000000..847198523 --- /dev/null +++ b/prometheus/prometheus.yml @@ -0,0 +1,48 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + external_labels: + cluster: 'ecommerce-dev' + environment: 'development' + +alerting: + alertmanagers: + - static_configs: + - targets: ['alertmanager:9093'] + +rule_files: + - '/etc/prometheus/alert.rules.yml' + +scrape_configs: + # Product Service + - job_name: 'product-service' + metrics_path: '/product-service/actuator/prometheus' + scrape_interval: 10s + static_configs: + - targets: + - 'ecommerce-microservice-backend-app-product-service-container-1:8500' + labels: + application: 'product-service' + environment: 'dev' + + # Order Service + - job_name: 'order-service' + metrics_path: '/order-service/actuator/prometheus' + scrape_interval: 10s + static_configs: + - targets: + - 'ecommerce-microservice-backend-app-order-service-container-1:8300' + labels: + application: 'order-service' + environment: 'dev' + + # Payment Service + - job_name: 'payment-service' + metrics_path: '/payment-service/actuator/prometheus' + scrape_interval: 10s + static_configs: + - targets: + - 'ecommerce-microservice-backend-app-payment-service-container-1:8400' + labels: + application: 'payment-service' + environment: 'dev' diff --git a/proxy-client/Dockerfile b/proxy-client/Dockerfile index deac97bb7..eba6f7477 100644 --- a/proxy-client/Dockerfile +++ b/proxy-client/Dockerfile @@ -1,12 +1,9 @@ - FROM openjdk:11 ARG PROJECT_VERSION=0.1.0 RUN mkdir -p /home/app WORKDIR /home/app -ENV SPRING_PROFILES_ACTIVE dev +ENV SPRING_PROFILES_ACTIVE=dev COPY proxy-client/ . ADD proxy-client/target/proxy-client-v${PROJECT_VERSION}.jar proxy-client.jar EXPOSE 8900 -ENTRYPOINT ["java", "-Dspring.profiles.active=${SPRING_PROFILES_ACTIVE}", "-jar", "proxy-client.jar"] - - +ENTRYPOINT ["java", "-Dspring.profiles.active=${SPRING_PROFILES_ACTIVE}", "-jar", "proxy-client.jar"] \ No newline at end of file diff --git a/proxy-client/compose.yml b/proxy-client/compose.yml index 6255f4c12..692c62ccf 100644 --- a/proxy-client/compose.yml +++ b/proxy-client/compose.yml @@ -1,12 +1,23 @@ - version: '3' services: auth-service-container: - image: selimhorri/proxy-client-ecommerce-boot:0.1.0 + image: juanse201/proxy-client-ecommerce-boot:0.1.0 ports: - 8900:8900 environment: - SPRING_PROFILES_ACTIVE=dev + - SPRING_ZIPKIN_BASE_URL=http://zipkin:9411 + - SPRING_CONFIG_IMPORT=optional:configserver:http://cloud-config-container:9296/ + - EUREKA_CLIENT_REGION=default + - EUREKA_CLIENT_AVAILABILITY_ZONES_DEFAULT=myzone + - EUREKA_CLIENT_SERVICEURL_MYZONE=http://service-discovery-container:8761/eureka + - EUREKA_CLIENT_SERVICEURL_DEFAULTZONE=http://service-discovery-container:8761/eureka/ + networks: + - microservices_network +networks: + microservices_network: + external: true + name: microservices_network diff --git a/proxy-client/pom.xml b/proxy-client/pom.xml index 16a9917f3..e83075153 100644 --- a/proxy-client/pom.xml +++ b/proxy-client/pom.xml @@ -72,6 +72,28 @@ spring-security-test test + + org.testcontainers + mysql + test + + + org.springframework.boot + spring-boot-starter-actuator + + + io.micrometer + micrometer-registry-prometheus + + + net.logstash.logback + logstash-logback-encoder + 7.3 + + + org.springframework.cloud + spring-cloud-starter-sleuth + diff --git a/proxy-client/src/main/resources/application.yml b/proxy-client/src/main/resources/application.yml index 3869e3030..c2aa962f6 100644 --- a/proxy-client/src/main/resources/application.yml +++ b/proxy-client/src/main/resources/application.yml @@ -5,15 +5,20 @@ server: spring: zipkin: - base-url: ${SPRING_ZIPKIN_BASE_URL:http://localhost:9411/} + base-url: ${SPRING_ZIPKIN_BASE_URL:http://zipkin:9411/} config: - import: ${SPRING_CONFIG_IMPORT:optional:configserver:http://localhost:9296} + import: ${SPRING_CONFIG_IMPORT:optional:configserver:http://cloud-config-container:9296} application: name: PROXY-CLIENT profiles: active: - dev +eureka: + client: + service-url: + defaultZone: ${EUREKA_CLIENT_SERVICEURL_DEFAULTZONE:http://service-discovery-container:8761/eureka/} + resilience4j: circuitbreaker: instances: @@ -29,12 +34,33 @@ resilience4j: sliding-window-type: COUNT_BASED management: - health: - circuitbreakers: - enabled: true + endpoints: + web: + exposure: + include: health,info,metrics,prometheus,env,loggers,threaddump,heapdump endpoint: health: show-details: always + probes: + enabled: true + metrics: + enabled: true + prometheus: + enabled: true + metrics: + export: + prometheus: + enabled: true + tags: + application: ${spring.application.name} + environment: ${spring.profiles.active} + health: + circuitbreakers: + enabled: true + livenessstate: + enabled: true + readinessstate: + enabled: true diff --git a/proxy-client/src/main/resources/logback-spring.xml b/proxy-client/src/main/resources/logback-spring.xml new file mode 100644 index 000000000..9a551a487 --- /dev/null +++ b/proxy-client/src/main/resources/logback-spring.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + {"app_name":"${appName}","app_port":"${appPort}"} + traceId + spanId + + + + + + logstash:5000 + + {"app_name":"${appName}","app_port":"${appPort}"} + traceId + spanId + + 5 minutes + + + + + + 512 + + + + + + + + + + + + + + + diff --git a/proxy-client/src/test/java/com/selimhorri/app/integration/EncoderConfigIntegrationTest.java b/proxy-client/src/test/java/com/selimhorri/app/integration/EncoderConfigIntegrationTest.java new file mode 100644 index 000000000..f5526bb6c --- /dev/null +++ b/proxy-client/src/test/java/com/selimhorri/app/integration/EncoderConfigIntegrationTest.java @@ -0,0 +1,18 @@ +package com.selimhorri.app.integration; + +import com.selimhorri.app.config.encoder.EncoderConfig; +import org.junit.jupiter.api.Test; +import org.springframework.security.crypto.password.PasswordEncoder; +import static org.junit.jupiter.api.Assertions.*; + +public class EncoderConfigIntegrationTest { + + @Test + void testPasswordEncoderBeanMatches() { + EncoderConfig config = new EncoderConfig(); + PasswordEncoder encoder = config.getPasswordEncoder(); + String raw = "integrationPass"; + String encoded = encoder.encode(raw); + assertTrue(encoder.matches(raw, encoded)); + } +} diff --git a/proxy-client/src/test/java/com/selimhorri/app/integration/JwtUtilImplIntegrationTest.java b/proxy-client/src/test/java/com/selimhorri/app/integration/JwtUtilImplIntegrationTest.java new file mode 100644 index 000000000..30083c856 --- /dev/null +++ b/proxy-client/src/test/java/com/selimhorri/app/integration/JwtUtilImplIntegrationTest.java @@ -0,0 +1,80 @@ +package com.selimhorri.app.integration; + +import com.selimhorri.app.jwt.util.impl.JwtUtilImpl; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.userdetails.UserDetails; +import static org.junit.jupiter.api.Assertions.*; + +public class JwtUtilImplIntegrationTest { + + // Dummy UserDetails for testing + static class DummyUserDetails implements org.springframework.security.core.userdetails.UserDetails { + private final String username; + DummyUserDetails(String username) { this.username = username; } + @Override public String getUsername() { return username; } + @Override public String getPassword() { return "pass"; } + @Override public boolean isAccountNonExpired() { return true; } + @Override public boolean isAccountNonLocked() { return true; } + @Override public boolean isCredentialsNonExpired() { return true; } + @Override public boolean isEnabled() { return true; } + @Override public java.util.Collection getAuthorities() { return java.util.Collections.emptyList(); } + } + + @Test + void testTokenLifecycle() { + JwtUtilImpl jwtUtil = new JwtUtilImpl(); + UserDetails user = new DummyUserDetails("integrationUser"); + String token = jwtUtil.generateToken(user); + assertNotNull(token); + assertEquals("integrationUser", jwtUtil.extractUsername(token)); + assertTrue(jwtUtil.validateToken(token, user)); + } + + @Test + void testTokenInvalidForOtherUser() { + JwtUtilImpl jwtUtil = new JwtUtilImpl(); + UserDetails user = new DummyUserDetails("integrationUser"); + String token = jwtUtil.generateToken(user); + UserDetails other = new DummyUserDetails("otherUser"); + assertFalse(jwtUtil.validateToken(token, other)); + } + + @Test + void testTokenExpiration() throws InterruptedException { + JwtUtilImpl jwtUtil = new JwtUtilImpl() { + @Override + public String generateToken(UserDetails userDetails) { + // Override to set short expiration for test + java.util.Map claims = new java.util.HashMap<>(); + return io.jsonwebtoken.Jwts.builder() + .setClaims(claims) + .setSubject(userDetails.getUsername()) + .setIssuedAt(new java.util.Date(System.currentTimeMillis())) + .setExpiration(new java.util.Date(System.currentTimeMillis() + 100)) + .signWith(io.jsonwebtoken.SignatureAlgorithm.HS256, "secret") + .compact(); + } + }; + UserDetails user = new DummyUserDetails("expUser"); + String token = jwtUtil.generateToken(user); + Thread.sleep(150); // Wait for expiration + assertThrows(io.jsonwebtoken.ExpiredJwtException.class, () -> jwtUtil.validateToken(token, user)); + } + + @Test + void testExtractClaims() { + JwtUtilImpl jwtUtil = new JwtUtilImpl(); + UserDetails user = new DummyUserDetails("claimsUser"); + String token = jwtUtil.generateToken(user); + String subject = jwtUtil.extractClaims(token, claims -> claims.getSubject()); + assertEquals("claimsUser", subject); + } + + @Test + void testExtractExpirationNotNull() { + JwtUtilImpl jwtUtil = new JwtUtilImpl(); + UserDetails user = new DummyUserDetails("expUser"); + String token = jwtUtil.generateToken(user); + assertNotNull(jwtUtil.extractExpiration(token)); + } +} diff --git a/proxy-client/src/test/java/com/selimhorri/app/integration/UserControllerIntegrationTest.java b/proxy-client/src/test/java/com/selimhorri/app/integration/UserControllerIntegrationTest.java new file mode 100644 index 000000000..9060d5fe5 --- /dev/null +++ b/proxy-client/src/test/java/com/selimhorri/app/integration/UserControllerIntegrationTest.java @@ -0,0 +1,69 @@ +package com.selimhorri.app.integration; + +import com.selimhorri.app.business.user.controller.UserController; +import com.selimhorri.app.business.user.model.UserDto; +import com.selimhorri.app.business.user.model.CredentialDto; +import com.selimhorri.app.business.user.service.UserClientService; +import com.selimhorri.app.business.user.model.response.UserUserServiceCollectionDtoResponse; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.http.ResponseEntity; +import static org.junit.jupiter.api.Assertions.*; + +public class UserControllerIntegrationTest { + + @Test + void testFindAllReturnsCollection() { + UserClientService mockService = Mockito.mock(UserClientService.class); + UserUserServiceCollectionDtoResponse response = new UserUserServiceCollectionDtoResponse(); + Mockito.when(mockService.findAll()).thenReturn(ResponseEntity.ok(response)); + UserController controller = new UserController(mockService); + ResponseEntity result = controller.findAll(); + assertEquals(response, result.getBody()); + } + + @Test + void testFindByIdReturnsUser() { + UserClientService mockService = Mockito.mock(UserClientService.class); + UserDto user = new UserDto(); user.setUserId(1); + Mockito.when(mockService.findById("1")).thenReturn(ResponseEntity.ok(user)); + UserController controller = new UserController(mockService); + ResponseEntity result = controller.findById("1"); + assertEquals(user, result.getBody()); + } + + @Test + void testFindByUsernameReturnsUser() { + UserClientService mockService = Mockito.mock(UserClientService.class); + UserDto user = new UserDto(); + CredentialDto cred = new CredentialDto(); + cred.setUsername("testuser"); + user.setCredentialDto(cred); + Mockito.when(mockService.findByUsername("testuser")).thenReturn(ResponseEntity.ok(user)); + UserController controller = new UserController(mockService); + ResponseEntity result = controller.findByUsername("testuser"); + assertEquals(user, result.getBody()); + } + + @Test + void testSaveReturnsSavedUser() { + UserClientService mockService = Mockito.mock(UserClientService.class); + UserDto user = new UserDto(); + CredentialDto cred = new CredentialDto(); + cred.setUsername("saveuser"); + user.setCredentialDto(cred); + Mockito.when(mockService.save(user)).thenReturn(ResponseEntity.ok(user)); + UserController controller = new UserController(mockService); + ResponseEntity result = controller.save(user); + assertEquals(user, result.getBody()); + } + + @Test + void testDeleteByIdReturnsTrue() { + UserClientService mockService = Mockito.mock(UserClientService.class); + Mockito.when(mockService.deleteById("1")).thenReturn(ResponseEntity.ok(true)); + UserController controller = new UserController(mockService); + ResponseEntity result = controller.deleteById("1"); + assertTrue(result.getBody()); + } +} diff --git a/proxy-client/src/test/java/com/selimhorri/app/integration/UserDetailsImplIntegrationTest.java b/proxy-client/src/test/java/com/selimhorri/app/integration/UserDetailsImplIntegrationTest.java new file mode 100644 index 000000000..3757eaa2b --- /dev/null +++ b/proxy-client/src/test/java/com/selimhorri/app/integration/UserDetailsImplIntegrationTest.java @@ -0,0 +1,91 @@ +package com.selimhorri.app.integration; + +import com.selimhorri.app.business.user.model.CredentialDto; +import com.selimhorri.app.business.user.model.RoleBasedAuthority; +import com.selimhorri.app.business.user.model.UserDetailsImpl; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +public class UserDetailsImplIntegrationTest { + + @Test + void testUserDetailsImplWithCredential() { + CredentialDto credential = CredentialDto.builder() + .username("integrationUser") + .password("integrationPass") + .roleBasedAuthority(RoleBasedAuthority.ROLE_USER) + .isEnabled(true) + .isAccountNonExpired(true) + .isAccountNonLocked(true) + .isCredentialsNonExpired(true) + .build(); + UserDetailsImpl userDetails = new UserDetailsImpl(credential); + assertEquals("integrationUser", userDetails.getUsername()); + assertEquals("integrationPass", userDetails.getPassword()); + assertTrue(userDetails.isEnabled()); + assertTrue(userDetails.isAccountNonExpired()); + assertTrue(userDetails.isAccountNonLocked()); + assertTrue(userDetails.isCredentialsNonExpired()); + assertEquals("ROLE_USER", userDetails.getAuthorities().iterator().next().getAuthority()); + } + + @Test + void testUserDetailsImplDisabled() { + CredentialDto credential = CredentialDto.builder() + .username("disabledUser") + .password("pass") + .roleBasedAuthority(RoleBasedAuthority.ROLE_USER) + .isEnabled(false) + .isAccountNonExpired(true) + .isAccountNonLocked(true) + .isCredentialsNonExpired(true) + .build(); + UserDetailsImpl userDetails = new UserDetailsImpl(credential); + assertFalse(userDetails.isEnabled()); + } + + @Test + void testUserDetailsImplAccountExpired() { + CredentialDto credential = CredentialDto.builder() + .username("expiredUser") + .password("pass") + .roleBasedAuthority(RoleBasedAuthority.ROLE_USER) + .isEnabled(true) + .isAccountNonExpired(false) + .isAccountNonLocked(true) + .isCredentialsNonExpired(true) + .build(); + UserDetailsImpl userDetails = new UserDetailsImpl(credential); + assertFalse(userDetails.isAccountNonExpired()); + } + + @Test + void testUserDetailsImplAccountLocked() { + CredentialDto credential = CredentialDto.builder() + .username("lockedUser") + .password("pass") + .roleBasedAuthority(RoleBasedAuthority.ROLE_USER) + .isEnabled(true) + .isAccountNonExpired(true) + .isAccountNonLocked(false) + .isCredentialsNonExpired(true) + .build(); + UserDetailsImpl userDetails = new UserDetailsImpl(credential); + assertFalse(userDetails.isAccountNonLocked()); + } + + @Test + void testUserDetailsImplCredentialsExpired() { + CredentialDto credential = CredentialDto.builder() + .username("credExpiredUser") + .password("pass") + .roleBasedAuthority(RoleBasedAuthority.ROLE_USER) + .isEnabled(true) + .isAccountNonExpired(true) + .isAccountNonLocked(true) + .isCredentialsNonExpired(false) + .build(); + UserDetailsImpl userDetails = new UserDetailsImpl(credential); + assertFalse(userDetails.isCredentialsNonExpired()); + } +} diff --git a/proxy-client/src/test/java/com/selimhorri/app/unit/EncoderConfigUnitTest.java b/proxy-client/src/test/java/com/selimhorri/app/unit/EncoderConfigUnitTest.java new file mode 100644 index 000000000..7cfe7f819 --- /dev/null +++ b/proxy-client/src/test/java/com/selimhorri/app/unit/EncoderConfigUnitTest.java @@ -0,0 +1,19 @@ +package com.selimhorri.app.unit; + +import com.selimhorri.app.config.encoder.EncoderConfig; +import org.junit.jupiter.api.Test; +import org.springframework.security.crypto.password.PasswordEncoder; +import static org.junit.jupiter.api.Assertions.*; + +public class EncoderConfigUnitTest { + + @Test + void testPasswordEncoderBeanIsBCrypt() { + EncoderConfig config = new EncoderConfig(); + PasswordEncoder encoder = config.getPasswordEncoder(); + assertNotNull(encoder); + String raw = "password123"; + String encoded = encoder.encode(raw); + assertTrue(encoder.matches(raw, encoded)); + } +} diff --git a/proxy-client/src/test/java/com/selimhorri/app/unit/JwtUtilImplUnitTest.java b/proxy-client/src/test/java/com/selimhorri/app/unit/JwtUtilImplUnitTest.java new file mode 100644 index 000000000..a88631fc3 --- /dev/null +++ b/proxy-client/src/test/java/com/selimhorri/app/unit/JwtUtilImplUnitTest.java @@ -0,0 +1,62 @@ +package com.selimhorri.app.unit; + +import com.selimhorri.app.jwt.util.impl.JwtUtilImpl; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.userdetails.UserDetails; +import java.util.Date; +import static org.junit.jupiter.api.Assertions.*; + +public class JwtUtilImplUnitTest { + + @Test + void testExtractUsername() { + JwtUtilImpl jwtUtil = new JwtUtilImpl(); + String token = jwtUtil.generateToken(new DummyUserDetails("user1")); + assertEquals("user1", jwtUtil.extractUsername(token)); + } + + @Test + void testExtractExpiration() { + JwtUtilImpl jwtUtil = new JwtUtilImpl(); + String token = jwtUtil.generateToken(new DummyUserDetails("user1")); + Date expiration = jwtUtil.extractExpiration(token); + assertTrue(expiration.after(new Date())); + } + + @Test + void testGenerateTokenNotNull() { + JwtUtilImpl jwtUtil = new JwtUtilImpl(); + String token = jwtUtil.generateToken(new DummyUserDetails("user1")); + assertNotNull(token); + } + + @Test + void testValidateTokenTrue() { + JwtUtilImpl jwtUtil = new JwtUtilImpl(); + UserDetails user = new DummyUserDetails("user1"); + String token = jwtUtil.generateToken(user); + assertTrue(jwtUtil.validateToken(token, user)); + } + + @Test + void testValidateTokenFalseForWrongUser() { + JwtUtilImpl jwtUtil = new JwtUtilImpl(); + UserDetails user = new DummyUserDetails("user1"); + String token = jwtUtil.generateToken(user); + UserDetails otherUser = new DummyUserDetails("user2"); + assertFalse(jwtUtil.validateToken(token, otherUser)); + } + + // Dummy UserDetails for testing + static class DummyUserDetails implements UserDetails { + private final String username; + DummyUserDetails(String username) { this.username = username; } + @Override public String getUsername() { return username; } + @Override public String getPassword() { return "pass"; } + @Override public boolean isAccountNonExpired() { return true; } + @Override public boolean isAccountNonLocked() { return true; } + @Override public boolean isCredentialsNonExpired() { return true; } + @Override public boolean isEnabled() { return true; } + @Override public java.util.Collection getAuthorities() { return java.util.Collections.emptyList(); } + } +} diff --git a/proxy-client/src/test/java/com/selimhorri/app/unit/UserDetailsImplUnitTest.java b/proxy-client/src/test/java/com/selimhorri/app/unit/UserDetailsImplUnitTest.java new file mode 100644 index 000000000..da3a86e8a --- /dev/null +++ b/proxy-client/src/test/java/com/selimhorri/app/unit/UserDetailsImplUnitTest.java @@ -0,0 +1,67 @@ +package com.selimhorri.app.unit; + +import com.selimhorri.app.business.user.model.UserDetailsImpl; +import com.selimhorri.app.business.user.model.CredentialDto; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.GrantedAuthority; +import java.util.Collection; +import static org.junit.jupiter.api.Assertions.*; + +public class UserDetailsImplUnitTest { + + @Test + void testGetUsername() { + CredentialDto credential = getCredential("user1", "pass", "ROLE_USER"); + UserDetailsImpl userDetails = new UserDetailsImpl(credential); + assertEquals("user1", userDetails.getUsername()); + } + + @Test + void testGetPassword() { + CredentialDto credential = getCredential("user1", "pass123", "ROLE_USER"); + UserDetailsImpl userDetails = new UserDetailsImpl(credential); + assertEquals("pass123", userDetails.getPassword()); + } + + @Test + void testIsEnabled() { + CredentialDto credential = getCredential("user1", "pass", "ROLE_USER"); + credential.setIsEnabled(true); + UserDetailsImpl userDetails = new UserDetailsImpl(credential); + assertTrue(userDetails.isEnabled()); + } + + @Test + void testGetAuthorities() { + CredentialDto credential = getCredential("user1", "pass", "ROLE_ADMIN"); + UserDetailsImpl userDetails = new UserDetailsImpl(credential); + Collection authorities = userDetails.getAuthorities(); + assertEquals(1, authorities.size()); + assertEquals("ROLE_ADMIN", authorities.iterator().next().getAuthority()); + } + + @Test + void testAccountNonExpiredAndLocked() { + CredentialDto credential = getCredential("user1", "pass", "ROLE_USER"); + credential.setIsAccountNonExpired(true); + credential.setIsAccountNonLocked(true); + credential.setIsCredentialsNonExpired(true); + UserDetailsImpl userDetails = new UserDetailsImpl(credential); + assertTrue(userDetails.isAccountNonExpired()); + assertTrue(userDetails.isAccountNonLocked()); + assertTrue(userDetails.isCredentialsNonExpired()); + } + + // Helper to create a CredentialDto with default values + private CredentialDto getCredential(String username, String password, String role) { + CredentialDto credential = new CredentialDto(); + credential.setUsername(username); + credential.setPassword(password); + credential.setRoleBasedAuthority(com.selimhorri.app.business.user.model.RoleBasedAuthority.valueOf(role)); + credential.setIsEnabled(true); + credential.setIsAccountNonExpired(true); + credential.setIsAccountNonLocked(true); + credential.setIsCredentialsNonExpired(true); + return credential; + } +} diff --git a/release-notes/README.md b/release-notes/README.md new file mode 100644 index 000000000..71efcb2fd --- /dev/null +++ b/release-notes/README.md @@ -0,0 +1,228 @@ +# Release Notes Generator + +Script automatizado para generar release notes en deployments de producción y staging con **versionado automático**. + +## Descripción + +Este script genera automáticamente documentación de release notes que incluye: +- Información de versión y fecha +- Lista de servicios desplegados +- Commits recientes +- Detalles de infraestructura +- Enlaces a builds de Jenkins y commits de GitHub + +## Uso + +### Sintaxis Básica +```bash +./generate-release-notes.sh [version] [environment] [service1] [service2] ... +``` + +### Auto-detección de Versión + +El script ahora detecta automáticamente la versión si no se especifica: + +**Prioridad de detección:** +1. Variable `BUILD_NUMBER` (Jenkins) +2. Último tag de Git (ej: `v1.2.3`) +3. Número de commits (ej: `0.0.152`) +4. Timestamp (fallback) + +### Ejemplos + +**Con auto-detección de versión:** +```bash +# Detecta versión automáticamente, ambiente por defecto (production) +./release-notes/generate-release-notes.sh + +# Solo especifica ambiente, versión auto-detectada +./release-notes/generate-release-notes.sh production + +# Ambiente + servicios, versión auto-detectada +./release-notes/generate-release-notes.sh staging order-service user-service +``` + +**Con versión manual:** +```bash +# Versión específica +./release-notes/generate-release-notes.sh "1.0.0" "production" + +# Versión + servicios +./release-notes/generate-release-notes.sh "2.5.0" "production" \ + order-service user-service payment-service +``` + +**Desde Jenkins (Production):** +```bash +# Simple - usa BUILD_NUMBER automáticamente +./release-notes/generate-release-notes.sh production \ + order-service user-service payment-service + +# O incluso más simple - todo se auto-detecta +./release-notes/generate-release-notes.sh +``` + +**Desde Jenkins (Staging):** +```bash +./release-notes/generate-release-notes.sh staging \ + order-service user-service payment-service +``` + +**Manual:** +```bash +./release-notes/generate-release-notes.sh "1.0.0" "production" \ + "service1" "service2" +``` + +## Gestión de Versiones con Git Tags + +### Version Manager Script + +Incluye un script adicional para gestionar versiones semánticas automáticamente: + +```bash +./release-notes/version-manager.sh [major|minor|patch|auto] +``` + +**Tipos de versión:** +- `major`: 1.0.0 → 2.0.0 (cambios incompatibles) +- `minor`: 1.0.0 → 1.1.0 (nuevas features) +- `patch`: 1.0.0 → 1.0.1 (bug fixes) +- `auto`: Analiza commits y decide automáticamente + +**Análisis automático de commits (Conventional Commits):** +- `feat:` → incrementa **minor** +- `fix:` → incrementa **patch** +- `BREAKING CHANGE` o `!:` → incrementa **major** + +**Ejemplo de uso:** +```bash +# Auto-detectar tipo de versión según commits +./release-notes/version-manager.sh auto + +# Incrementar versión minor manualmente +./release-notes/version-manager.sh minor + +# Esto creará el tag y opcionalmente las release notes +``` + +### Workflow Recomendado + +```bash +# 1. Hacer commits con Conventional Commits +git commit -m "feat: add new payment method" +git commit -m "fix: resolve memory leak" + +# 2. Cuando estés listo para release +./release-notes/version-manager.sh auto + +# 3. El script: +# - Analiza commits +# - Sugiere nueva versión +# - Crea el tag +# - Opcionalmente genera release notes + +# 4. Push del tag +git push origin v1.2.0 +``` + +## Estructura de Archivos + +Los release notes se guardan en: +``` +release-notes/ +├── generate-release-notes.sh # Script principal +├── README.md # Esta documentación +├── RELEASE-production-v1.md # Release notes de producción +├── RELEASE-staging-v2.md # Release notes de staging +└── ... +``` + +## Integración con Jenkins + +### En Jenkinsfile +```groovy +stage('Generate Release Notes') { + steps { + sh """ + chmod +x release-notes/generate-release-notes.sh + ./release-notes/generate-release-notes.sh \ + "${BUILD_NUMBER}" \ + "production" \ + "${S1}" "${S2}" "${S3}" "${S4}" \ + "${S5}" "${S6}" "${S7}" "${S8}" + """ + archiveArtifacts artifacts: "release-notes/RELEASE-*.md", fingerprint: true + } +} +``` + +## Formato de Release Notes + +El script genera un archivo Markdown con las siguientes secciones: + +1. **Información General**: Versión, fecha, commit, build +2. **Servicios Desplegados**: Lista de microservicios +3. **Imágenes Docker**: Información del registry +4. **Últimos Cambios**: Top 10 commits +5. **Infraestructura**: Detalles de K8s y CI/CD +6. **Estado del Deployment**: Checklist de validaciones +7. **Validaciones**: Tests y health checks +8. **Notas Adicionales**: Enlaces útiles + +## Variables de Entorno + +El script utiliza automáticamente las siguientes variables si están disponibles: + +| Variable | Descripción | Default | +|----------|-------------|---------| +| `BUILD_NUMBER` | Número de build de Jenkins | "N/A" | +| `JOB_NAME` | Nombre del job de Jenkins | "Manual" | +| `BUILD_URL` | URL del build en Jenkins | "N/A" | + +## Características + +- Detecta automáticamente si las release notes ya existen +- Obtiene información de Git (hash, branch, commits) +- Integración automática con Jenkins +- Output colorizado para mejor legibilidad +- Manejo de errores robusto +- Compatible con diferentes ambientes + +## Comportamiento + +### Si las Release Notes YA existen: +- Muestra un warning +- Imprime el contenido existente +- Sale con código 0 (éxito) + +### Si NO existen: +- Crea el archivo nuevo +- Imprime el contenido generado +- Sale con código 0 (éxito) + +## Notas + +- El script es idempotente: ejecutarlo múltiples veces no sobrescribe archivos existentes +- Requiere Git para obtener información de commits +- Compatible con bash en Linux/Unix y Git Bash en Windows +- Los archivos generados usan formato Markdown estándar + +## Troubleshooting + +**Error: "Permission denied"** +```bash +chmod +x release-notes/generate-release-notes.sh +``` + +**Error: "git: command not found"** +- Asegúrate de que Git esté instalado y en el PATH +- El script continuará con valores por defecto si Git no está disponible + +**Los colores no se muestran correctamente** +- Normal en algunos entornos de Jenkins +- No afecta la funcionalidad del script + +## Licencia + +Este script es parte del proyecto ecommerce-microservice-backend-app. diff --git a/release-notes/RELEASE-EXAMPLE.md b/release-notes/RELEASE-EXAMPLE.md new file mode 100644 index 000000000..6aa845906 --- /dev/null +++ b/release-notes/RELEASE-EXAMPLE.md @@ -0,0 +1,65 @@ +# Release Notes - Production v42 + +## Información General +- **Fecha de Release**: 2025-11-03 14:30:00 +- **Versión**: v42 +- **Entorno**: production +- **Build Number**: 42 +- **Commit Hash**: `a1b2c3d` +- **Branch**: `master` +- **Jenkins Job**: ecommerce-production-pipeline +- **Build URL**: http://jenkins.example.com/job/ecommerce/42 + +## Servicios Desplegados +- order-service +- user-service +- payment-service +- product-service +- favourite-service +- cloud-config +- service-discovery +- zipkin + +## Imágenes Docker +Todas las imágenes fueron desplegadas desde Docker Hub con el tag correspondiente al ambiente production. + +## Últimos Cambios (Top 10 Commits) +- feat: Add new payment gateway integration (Juan Pérez) +- fix: Resolve memory leak in order service (María García) +- chore: Update dependencies to latest versions (Carlos López) +- docs: Update API documentation (Ana Martínez) +- refactor: Improve database connection pooling (Luis Rodríguez) +- test: Add integration tests for user service (Pedro Sánchez) +- feat: Implement caching layer for product catalog (Laura Torres) +- fix: Correct timezone handling in order timestamps (Miguel Díaz) +- perf: Optimize database queries in product search (Sofia Ruiz) +- ci: Improve Jenkins pipeline performance (Juan Pérez) + +## Infraestructura +- **Namespace Kubernetes**: production +- **Orchestrator**: Kubernetes +- **Container Registry**: Docker Hub +- **CI/CD**: Jenkins + +## Estado del Deployment +- Pre-cleanup ejecutado +- Imágenes Docker verificadas +- Manifiestos Kubernetes aplicados +- Servicios y Pods verificados +- Health checks completados + +## Validaciones +- Build exitoso en Jenkins +- Tests unitarios +- Tests de integración +- Deployment verificado + +## Notas Adicionales +Este release fue generado automáticamente por el pipeline de Jenkins. + +### Enlaces Útiles +- [Jenkins Build #42](http://jenkins.example.com/job/ecommerce/42) +- [GitHub Commit](https://github.com/JuanseDev2001/ecommerce-microservice-backend-app/commit/a1b2c3d) + +--- +*Generado automáticamente el 2025-11-03 14:30:00* diff --git a/release-notes/RELEASE-production-v-0.1.0.md b/release-notes/RELEASE-production-v-0.1.0.md new file mode 100644 index 000000000..dcdc9ab3c --- /dev/null +++ b/release-notes/RELEASE-production-v-0.1.0.md @@ -0,0 +1,58 @@ +# Release Notes - Production v-0.1.0 + +## Información General +- **Fecha de Release**: 2025-11-03 20:36:20 +- **Versión**: v-0.1.0 +- **Entorno**: production +- **Build Number**: N/A +- **Commit Hash**: `17e6248` +- **Branch**: `stage` +- **Jenkins Job**: Manual +- **Build URL**: N/A + +## Servicios Desplegados +- No services specified + +## Imágenes Docker +Todas las imágenes fueron desplegadas desde Docker Hub con el tag correspondiente al ambiente production. + +## Últimos Cambios (Top 10 Commits) +- Update validate_performance.py (juanseDev2001) +- Update validate_performance.py (juanseDev2001) +- Update validate_performance.py (juanseDev2001) +- feat: fixed performance test in pipeline (juanseDev2001) +- Update Jenkinsfile.stage (juanseDev2001) +- fix: removed -n stage namespace as it is interfering with tests, left it for prod as if it passes this then its ok (juanseDev2001) +- feat: Performance Tests added and edited Jenkins to install python with locust for tests (juanseDev2001) +- Update Jenkinsfile.prod (juanseDev2001) +- Update Jenkins.md (juanseDev2001) +- fix (juanseDev2001) + +## Infraestructura +- **Namespace Kubernetes**: production +- **Orchestrator**: Kubernetes +- **Container Registry**: Docker Hub +- **CI/CD**: Jenkins + +## Estado del Deployment +- Pre-cleanup ejecutado +- Imágenes Docker verificadas +- Manifiestos Kubernetes aplicados +- Servicios y Pods verificados +- Health checks completados + +## Validaciones +- Build exitoso en Jenkins +- Tests unitarios +- Tests de integración +- Deployment verificado + +## Notas Adicionales +Este release fue generado automáticamente por el pipeline de Jenkins. + +### Enlaces Útiles +- [Jenkins Build #N/A](N/A) +- [GitHub Commit](https://github.com/JuanseDev2001/ecommerce-microservice-backend-app/commit/17e6248) + +--- +*Generado automáticamente el 2025-11-03 20:36:20* diff --git a/release-notes/generate-release-notes.sh b/release-notes/generate-release-notes.sh new file mode 100755 index 000000000..7d011b47f --- /dev/null +++ b/release-notes/generate-release-notes.sh @@ -0,0 +1,162 @@ +#!/bin/bash + +# Script para generar Release Notes automáticamente +# Uso: ./generate-release-notes.sh [version] [environment] [services...] +# Si no se proporciona version, se auto-detecta desde Git + +set -e + +# Colores para output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Función para auto-detectar versión +get_version() { + # Prioridad 1: Variable de entorno BUILD_NUMBER (Jenkins) + if [ -n "$BUILD_NUMBER" ]; then + echo "$BUILD_NUMBER" + return + fi + + # Prioridad 2: Último tag de Git + local git_tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + if [ -n "$git_tag" ]; then + # Remover 'v-' del inicio (mantener formato con guión en archivos) + echo "${git_tag#v-}" + return + fi + + # Prioridad 3: Contar commits desde el inicio + local commit_count=$(git rev-list --count HEAD 2>/dev/null || echo "0") + if [ "$commit_count" != "0" ]; then + echo "0.0.${commit_count}" + return + fi + + # Fallback: usar timestamp + echo "$(date +%Y%m%d-%H%M%S)" +} + +# Parsear argumentos (ahora todos son opcionales) +if [ $# -eq 0 ]; then + # Sin argumentos: auto-detectar todo + VERSION=$(get_version) + ENVIRONMENT=${ENVIRONMENT:-"production"} + SERVICES=() +elif [ $# -eq 1 ]; then + # Solo ambiente o solo versión + if [[ "$1" =~ ^(production|staging|prod|stage|dev|development)$ ]]; then + VERSION=$(get_version) + ENVIRONMENT="$1" + SERVICES=() + else + VERSION="$1" + ENVIRONMENT=${ENVIRONMENT:-"production"} + SERVICES=() + fi +else + # Argumentos completos + VERSION=${1:-$(get_version)} + ENVIRONMENT=${2:-"production"} + shift 2 + SERVICES=("$@") +fi + +RELEASE_DIR="release-notes" +RELEASE_FILE="${RELEASE_DIR}/RELEASE-${ENVIRONMENT}-v${VERSION}.md" +DATE=$(date +%Y-%m-%d) +TIME=$(date +%H:%M:%S) +GIT_HASH=$(git rev-parse --short HEAD 2>/dev/null || echo "N/A") +GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "N/A") +GIT_LOG=$(git log --pretty=format:"- %s (%an)" -10 2>/dev/null || echo "No git history available") + +# Crear directorio si no existe +mkdir -p "${RELEASE_DIR}" + +# Verificar si el archivo ya existe +if [ -f "${RELEASE_FILE}" ]; then + echo -e "${YELLOW}Release Notes para ${ENVIRONMENT} v${VERSION} ya existen${NC}" + echo -e "${BLUE}Archivo: ${RELEASE_FILE}${NC}" + cat "${RELEASE_FILE}" + exit 0 +fi + +echo -e "${GREEN}Generando Release Notes para ${ENVIRONMENT} v${VERSION}...${NC}" + +# Generar lista de servicios +SERVICES_LIST="" +if [ ${#SERVICES[@]} -gt 0 ]; then + for service in "${SERVICES[@]}"; do + SERVICES_LIST="${SERVICES_LIST}- ${service}\n" + done +else + SERVICES_LIST="- No services specified\n" +fi + +# Obtener información adicional de Jenkins si está disponible +BUILD_NUMBER=${BUILD_NUMBER:-"N/A"} +JOB_NAME=${JOB_NAME:-"Manual"} +BUILD_URL=${BUILD_URL:-"N/A"} + +# Crear el archivo de release notes +cat > "${RELEASE_FILE}" << EOF +# Release Notes - ${ENVIRONMENT^} v${VERSION} + +## Información General +- **Fecha de Release**: ${DATE} ${TIME} +- **Versión**: v${VERSION} +- **Entorno**: ${ENVIRONMENT} +- **Build Number**: ${BUILD_NUMBER} +- **Commit Hash**: \`${GIT_HASH}\` +- **Branch**: \`${GIT_BRANCH}\` +- **Jenkins Job**: ${JOB_NAME} +- **Build URL**: ${BUILD_URL} + +## Servicios Desplegados +$(echo -e "${SERVICES_LIST}") + +## Imágenes Docker +Todas las imágenes fueron desplegadas desde Docker Hub con el tag correspondiente al ambiente ${ENVIRONMENT}. + +## Últimos Cambios (Top 10 Commits) +${GIT_LOG} + +## Infraestructura +- **Namespace Kubernetes**: ${ENVIRONMENT} +- **Orchestrator**: Kubernetes +- **Container Registry**: Docker Hub +- **CI/CD**: Jenkins + +## Estado del Deployment +- Pre-cleanup ejecutado +- Imágenes Docker verificadas +- Manifiestos Kubernetes aplicados +- Servicios y Pods verificados +- Health checks completados + +## Validaciones +- Build exitoso en Jenkins +- Tests unitarios +- Tests de integración +- Deployment verificado + +## Notas Adicionales +Este release fue generado automáticamente por el pipeline de Jenkins. + +### Enlaces Útiles +- [Jenkins Build #${BUILD_NUMBER}](${BUILD_URL}) +- [GitHub Commit](https://github.com/JuanseDev2001/ecommerce-microservice-backend-app/commit/${GIT_HASH}) + +--- +*Generado automáticamente el ${DATE} ${TIME}* +EOF + +echo -e "${GREEN}Release Notes creadas exitosamente${NC}" +echo -e "${BLUE}Archivo: ${RELEASE_FILE}${NC}" +echo "" +cat "${RELEASE_FILE}" + +exit 0 diff --git a/release-notes/version-manager.sh b/release-notes/version-manager.sh new file mode 100755 index 000000000..2e23e04c2 --- /dev/null +++ b/release-notes/version-manager.sh @@ -0,0 +1,110 @@ +#!/bin/bash + +# Script para gestionar versiones automáticamente +# Uso: ./version-manager.sh [major|minor|patch|auto] + +set -e + +# Colores +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +# Obtener la versión actual desde Git tags +get_current_version() { + local latest_tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "v-0.0.0") + # Remover 'v-' del inicio (mantener formato con guión) + echo "${latest_tag#v-}" +} + +# Incrementar versión según tipo +increment_version() { + local version=$1 + local type=$2 + + IFS='.' read -r -a parts <<< "$version" + local major="${parts[0]:-0}" + local minor="${parts[1]:-0}" + local patch="${parts[2]:-0}" + + case "$type" in + major) + major=$((major + 1)) + minor=0 + patch=0 + ;; + minor) + minor=$((minor + 1)) + patch=0 + ;; + patch|auto) + patch=$((patch + 1)) + ;; + *) + echo "Tipo inválido. Usa: major, minor, patch, o auto" + exit 1 + ;; + esac + + echo "${major}.${minor}.${patch}" +} + +# Analizar commits para determinar tipo de versión (Conventional Commits) +analyze_commits() { + local last_tag=$(git describe --tags --abbrev=0 2>/dev/null || git rev-list --max-parents=0 HEAD) + local commits=$(git log ${last_tag}..HEAD --pretty=format:"%s" 2>/dev/null || echo "") + + # Buscar breaking changes (major) + if echo "$commits" | grep -qi "BREAKING CHANGE\|!:"; then + echo "major" + return + fi + + # Buscar nuevas features (minor) + if echo "$commits" | grep -qi "^feat"; then + echo "minor" + return + fi + + # Por defecto, patch + echo "patch" +} + +# Main +TYPE=${1:-auto} + +if [ "$TYPE" = "auto" ]; then + TYPE=$(analyze_commits) + echo -e "${YELLOW}Analizando commits... Tipo detectado: ${TYPE}${NC}" +fi + +CURRENT_VERSION=$(get_current_version) +NEW_VERSION=$(increment_version "$CURRENT_VERSION" "$TYPE") + +echo -e "${BLUE}Versión actual: ${CURRENT_VERSION}${NC}" +echo -e "${GREEN}Nueva versión: ${NEW_VERSION}${NC}" +echo "" +echo -e "${YELLOW}¿Deseas crear el tag v${NEW_VERSION}? (y/n)${NC}" +read -r response + +if [[ "$response" =~ ^[Yy]$ ]]; then + # Crear tag con formato v-X.Y.Z (con guión) + git tag -a "v-${NEW_VERSION}" -m "Release v-${NEW_VERSION}" + echo -e "${GREEN}Tag v-${NEW_VERSION} creado${NC}" + echo -e "${BLUE}Para pushear el tag: git push origin v-${NEW_VERSION}${NC}" + echo "" + echo -e "${YELLOW}¿Deseas generar las release notes ahora? (y/n)${NC}" + read -r gen_notes + + if [[ "$gen_notes" =~ ^[Yy]$ ]]; then + # Corregir ruta cuando se ejecuta desde release-notes/ + if [ -f "./generate-release-notes.sh" ]; then + ./generate-release-notes.sh "${NEW_VERSION}" "production" + else + ../release-notes/generate-release-notes.sh "${NEW_VERSION}" "production" + fi + fi +else + echo -e "${YELLOW}Tag no creado${NC}" +fi diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 000000000..080bb2553 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,300 @@ +# Scripts de Utilidad para Azure VM + +Este directorio contiene scripts útiles para el despliegue y mantenimiento de la aplicación de microservicios en Azure VM. + +## 📋 Scripts Disponibles + +### 1. `azure-vm-setup.sh` +**Propósito**: Configuración automática inicial de la VM de Azure. + +**Qué hace**: +- Actualiza el sistema Ubuntu +- Instala Docker y Docker Compose +- Instala Java 11 y Maven +- Configura límites del sistema para Elasticsearch +- Crea swap de 8GB +- Configura el firewall (UFW) +- Crea directorios para datos persistentes + +**Uso**: +```bash +# En la VM de Azure (después de conectarte por SSH) +wget https://raw.githubusercontent.com/TU_USUARIO/TU_REPO/main/scripts/azure-vm-setup.sh +chmod +x azure-vm-setup.sh +./azure-vm-setup.sh +``` + +**Nota**: Después de ejecutar este script, debes cerrar sesión y volver a conectarte para que los cambios de grupo de Docker tengan efecto. + +--- + +### 2. `deploy-gradual.sh` +**Propósito**: Despliegue gradual y ordenado de todos los servicios. + +**Qué hace**: +- Inicia servicios en el orden correcto (infraestructura → gateway → microservicios → observabilidad) +- Espera a que cada servicio esté listo antes de continuar +- Verifica la salud de cada servicio +- Muestra un resumen completo al final + +**Uso**: +```bash +cd ~/projects/ecommerce-microservice-backend-app +chmod +x scripts/deploy-gradual.sh +./scripts/deploy-gradual.sh +``` + +**Fases del despliegue**: +1. **Infraestructura Base**: Zipkin, Eureka, Config Server +2. **Gateway y Proxy**: API Gateway, Proxy Client +3. **Microservicios**: User, Product, Order, Payment, Shipping, Favourite +4. **Observabilidad**: Elasticsearch, Logstash, Kibana, Prometheus, Grafana, Jaeger + +--- + +### 3. `check-services.sh` +**Propósito**: Verificación del estado de todos los servicios. + +**Qué hace**: +- Verifica que todos los contenedores Docker estén corriendo +- Comprueba que todos los endpoints HTTP respondan +- Lista servicios registrados en Eureka +- Muestra uso de recursos (CPU, memoria, disco) +- Genera un resumen del estado general + +**Uso**: +```bash +cd ~/projects/ecommerce-microservice-backend-app +chmod +x scripts/check-services.sh +./scripts/check-services.sh +``` + +**Código de salida**: +- `0`: Todos los servicios funcionan correctamente +- `1`: Algunos servicios tienen problemas + +--- + +### 4. `backup.sh` +**Propósito**: Backup de volúmenes Docker y configuraciones. + +**Qué hace**: +- Respalda volúmenes de Docker (Jenkins, Grafana, Elasticsearch) +- Respalda archivos de configuración (compose.yml, prometheus, elk, etc.) +- Respalda scripts personalizados +- Limpia backups antiguos (>7 días) +- Genera reporte de backups creados + +**Uso**: +```bash +cd ~/projects/ecommerce-microservice-backend-app +chmod +x scripts/backup.sh +./scripts/backup.sh +``` + +**Configurar backup automático** (diario a las 2 AM): +```bash +(crontab -l 2>/dev/null; echo "0 2 * * * /home/azureuser/projects/ecommerce-microservice-backend-app/scripts/backup.sh") | crontab - +``` + +**Restaurar un backup**: +```bash +# Para volúmenes Docker +docker run --rm \ + -v :/data \ + -v ~/backups:/backup \ + ubuntu tar xzf /backup/.tar.gz -C / + +# Para configuraciones +cd ~/projects/ecommerce-microservice-backend-app +tar xzf ~/backups/config-.tar.gz +``` + +--- + +## 🚀 Flujo de Trabajo Recomendado + +### Primera vez (VM nueva) + +```bash +# 1. Configurar la VM +./azure-vm-setup.sh + +# 2. Cerrar sesión y volver a conectar +exit +ssh -i ~/.ssh/vm-ecommerce-key.pem azureuser@ + +# 3. Clonar el repositorio +cd ~/projects +git clone +cd ecommerce-microservice-backend-app + +# 4. Dar permisos a los scripts +chmod +x scripts/*.sh + +# 5. Desplegar servicios +./scripts/deploy-gradual.sh + +# 6. Verificar estado +./scripts/check-services.sh +``` + +### Mantenimiento regular + +```bash +# Verificar estado de servicios +./scripts/check-services.sh + +# Realizar backup manual +./scripts/backup.sh + +# Reiniciar servicios si es necesario +docker-compose -f compose.yml restart + +# Ver logs +docker-compose -f compose.yml logs -f +``` + +### Actualización de servicios + +```bash +# 1. Hacer backup +./scripts/backup.sh + +# 2. Detener servicios +docker-compose -f compose.yml down + +# 3. Actualizar código +git pull + +# 4. Reconstruir imágenes (si es necesario) +docker-compose -f compose.yml build + +# 5. Desplegar nuevamente +./scripts/deploy-gradual.sh + +# 6. Verificar +./scripts/check-services.sh +``` + +--- + +## 🔧 Troubleshooting + +### Script falla con "Permission denied" +```bash +chmod +x scripts/.sh +``` + +### Servicios no inician correctamente +```bash +# Ver logs +docker-compose -f compose.yml logs + +# Verificar recursos +docker stats +free -h +df -h + +# Reiniciar servicio específico +docker-compose -f compose.yml restart +``` + +### Elasticsearch no inicia +```bash +# Verificar límites de memoria virtual +sysctl vm.max_map_count + +# Si es menor a 262144, ejecutar: +sudo sysctl -w vm.max_map_count=262144 +``` + +### Problemas de memoria +```bash +# Verificar swap +free -h + +# Limpiar recursos de Docker +docker system prune -a --volumes + +# Reiniciar servicios de observabilidad +docker-compose -f compose.yml restart elasticsearch logstash kibana +``` + +--- + +## 📊 Monitoreo + +### Ver uso de recursos en tiempo real +```bash +# CPU y memoria por contenedor +docker stats + +# Uso general del sistema +htop + +# Uso de disco +df -h + +# Uso de red +sudo iftop +``` + +### Acceder a servicios de monitoreo + +Desde tu navegador (reemplaza `` con la IP de tu VM): + +- **Prometheus**: `http://:9090` +- **Grafana**: `http://:3000` (admin/admin) +- **Kibana**: `http://:5601` +- **Jaeger**: `http://:16686` +- **Eureka**: `http://:8761` + +--- + +## 🔒 Seguridad + +### Recomendaciones + +1. **Cambiar contraseñas por defecto**: + - Grafana: admin/admin → cambiar en primer login + - Elasticsearch: configurar autenticación si se expone públicamente + +2. **Restringir acceso por IP** (en Azure NSG): + - Permitir solo IPs conocidas para servicios de administración + - Exponer solo API Gateway públicamente + +3. **Usar HTTPS**: + - Configurar certificados SSL/TLS + - Usar Let's Encrypt para certificados gratuitos + +4. **Actualizar regularmente**: + ```bash + sudo apt update && sudo apt upgrade -y + docker-compose pull + ``` + +--- + +## 📝 Notas Adicionales + +- Todos los scripts están diseñados para Ubuntu 22.04 LTS +- Los backups se almacenan en `~/backups` +- Los logs de Docker se rotan automáticamente (max 10MB, 3 archivos) +- El swap configurado es de 8GB +- Los volúmenes persistentes se crean automáticamente + +--- + +## 🆘 Soporte + +Si encuentras problemas: + +1. Revisa los logs: `docker-compose logs ` +2. Ejecuta el script de verificación: `./scripts/check-services.sh` +3. Consulta la guía completa: `docs/azure-vm-deployment-guide.md` +4. Abre un issue en el repositorio + +--- + +**Última actualización**: 2025-11-30 diff --git a/scripts/azure-vm-setup.sh b/scripts/azure-vm-setup.sh new file mode 100755 index 000000000..bd0501129 --- /dev/null +++ b/scripts/azure-vm-setup.sh @@ -0,0 +1,206 @@ +#!/bin/bash + +############################################################################### +# Script de Configuración Automática para Azure VM +# Este script instala y configura todo lo necesario para correr el proyecto +############################################################################### + +set -e # Salir si hay algún error + +# Colores para output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Función para imprimir mensajes +print_message() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +# Verificar que se ejecuta en Ubuntu +if [ ! -f /etc/lsb-release ]; then + print_error "Este script está diseñado para Ubuntu" + exit 1 +fi + +print_message "=== Iniciando configuración de Azure VM para E-Commerce Microservices ===" + +# 1. Actualizar el sistema +print_message "Actualizando el sistema..." +sudo apt update +sudo apt upgrade -y + +# 2. Instalar utilidades básicas +print_message "Instalando utilidades básicas..." +sudo apt install -y \ + curl \ + wget \ + git \ + vim \ + nano \ + net-tools \ + htop \ + iotop \ + jq \ + unzip \ + apt-transport-https \ + ca-certificates \ + software-properties-common + +# 3. Instalar Docker +print_message "Instalando Docker..." +if ! command -v docker &> /dev/null; then + # Agregar clave GPG de Docker + curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg + + # Agregar repositorio + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null + + # Instalar Docker + sudo apt update + sudo apt install -y docker-ce docker-ce-cli containerd.io + + # Agregar usuario al grupo docker + sudo usermod -aG docker $USER + + print_message "Docker instalado correctamente" +else + print_warning "Docker ya está instalado" +fi + +# 4. Instalar Docker Compose +print_message "Instalando Docker Compose..." +if ! command -v docker-compose &> /dev/null; then + sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose + sudo chmod +x /usr/local/bin/docker-compose + print_message "Docker Compose instalado correctamente" +else + print_warning "Docker Compose ya está instalado" +fi + +# 5. Instalar Java 11 +print_message "Instalando Java 11..." +if ! command -v java &> /dev/null; then + sudo apt install -y openjdk-11-jdk + + # Configurar JAVA_HOME + echo 'export JAVA_HOME=/usr/lib/jvm/java-11-openjdk-amd64' >> ~/.bashrc + echo 'export PATH=$PATH:$JAVA_HOME/bin' >> ~/.bashrc + + print_message "Java 11 instalado correctamente" +else + print_warning "Java ya está instalado" +fi + +# 6. Instalar Maven +print_message "Instalando Maven..." +if ! command -v mvn &> /dev/null; then + sudo apt install -y maven + print_message "Maven instalado correctamente" +else + print_warning "Maven ya está instalado" +fi + +# 7. Configurar límites del sistema para Elasticsearch +print_message "Configurando límites del sistema..." +sudo sysctl -w vm.max_map_count=262144 +echo 'vm.max_map_count=262144' | sudo tee -a /etc/sysctl.conf + +# 8. Configurar Swap (8GB) +print_message "Configurando Swap..." +if [ ! -f /swapfile ]; then + sudo fallocate -l 8G /swapfile + sudo chmod 600 /swapfile + sudo mkswap /swapfile + sudo swapon /swapfile + echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab + print_message "Swap configurado correctamente" +else + print_warning "Swap ya está configurado" +fi + +# 9. Configurar Docker daemon +print_message "Configurando Docker daemon..." +sudo mkdir -p /etc/docker +cat << EOF | sudo tee /etc/docker/daemon.json +{ + "log-driver": "json-file", + "log-opts": { + "max-size": "10m", + "max-file": "3" + }, + "default-ulimits": { + "nofile": { + "Name": "nofile", + "Hard": 64000, + "Soft": 64000 + } + } +} +EOF + +sudo systemctl restart docker + +# 10. Configurar firewall (UFW) +print_message "Configurando firewall..." +sudo ufw --force enable +sudo ufw allow 22/tcp # SSH +sudo ufw allow 80/tcp # HTTP +sudo ufw allow 443/tcp # HTTPS +sudo ufw allow 3000/tcp # Grafana +sudo ufw allow 5000/tcp # Logstash +sudo ufw allow 5601/tcp # Kibana +sudo ufw allow 8080/tcp # API Gateway +sudo ufw allow 8300/tcp # Order Service +sudo ufw allow 8400/tcp # Payment Service +sudo ufw allow 8500/tcp # Product Service +sudo ufw allow 8600/tcp # Shipping Service +sudo ufw allow 8700/tcp # User Service +sudo ufw allow 8800/tcp # Favourite Service +sudo ufw allow 8900/tcp # Proxy Client +sudo ufw allow 9090/tcp # Prometheus +sudo ufw allow 9093/tcp # Alertmanager +sudo ufw allow 9200/tcp # Elasticsearch +sudo ufw allow 9296/tcp # Config Server +sudo ufw allow 9411/tcp # Zipkin +sudo ufw allow 8761/tcp # Eureka +sudo ufw allow 16686/tcp # Jaeger UI +sudo ufw reload + +# 11. Crear directorios para datos persistentes +print_message "Creando directorios para datos persistentes..." +sudo mkdir -p /data/{grafana,prometheus,elasticsearch,jenkins} +sudo chown -R 472:472 /data/grafana +sudo chown -R 65534:65534 /data/prometheus +sudo chown -R 1000:1000 /data/elasticsearch +sudo chown -R 1000:1000 /data/jenkins + +# 12. Crear directorio de proyectos +print_message "Creando directorio de proyectos..." +mkdir -p ~/projects +mkdir -p ~/backups + +print_message "=== Configuración completada ===" +print_message "" +print_message "Versiones instaladas:" +echo " - Docker: $(docker --version)" +echo " - Docker Compose: $(docker-compose --version)" +echo " - Java: $(java -version 2>&1 | head -n 1)" +echo " - Maven: $(mvn -version | head -n 1)" +print_message "" +print_warning "IMPORTANTE: Cierra la sesión SSH y vuelve a conectarte para que los cambios de grupo de Docker tengan efecto" +print_message "" +print_message "Próximos pasos:" +print_message " 1. Cerrar sesión: exit" +print_message " 2. Volver a conectar por SSH" +print_message " 3. Clonar el repositorio: cd ~/projects && git clone " +print_message " 4. Ejecutar: cd ecommerce-microservice-backend-app && docker-compose -f compose.yml up -d" diff --git a/scripts/backup.sh b/scripts/backup.sh new file mode 100755 index 000000000..ecd7f1e4a --- /dev/null +++ b/scripts/backup.sh @@ -0,0 +1,111 @@ +#!/bin/bash + +############################################################################### +# Script de Backup para E-Commerce Microservices +# Realiza backup de volúmenes Docker y configuraciones +############################################################################### + +# Configuración +BACKUP_DIR="${HOME}/backups" +DATE=$(date +%Y%m%d_%H%M%S) +PROJECT_DIR="${HOME}/projects/ecommerce-microservice-backend-app" + +# Colores +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' + +# Crear directorio de backups +mkdir -p "$BACKUP_DIR" + +echo -e "${GREEN}=== Iniciando Backup ===${NC}" +echo "Fecha: $(date)" +echo "Directorio de backup: $BACKUP_DIR" +echo "" + +# 1. Backup de volúmenes de Docker +echo -e "${YELLOW}Realizando backup de volúmenes Docker...${NC}" + +volumes=("jenkins_home" "grafana-storage" "elasticsearch-data") + +for volume in "${volumes[@]}"; do + if docker volume inspect "$volume" > /dev/null 2>&1; then + echo " • Backup de $volume..." + docker run --rm \ + -v "${volume}:/data" \ + -v "${BACKUP_DIR}:/backup" \ + ubuntu tar czf "/backup/${volume}-${DATE}.tar.gz" /data + + if [ $? -eq 0 ]; then + echo -e " ${GREEN}✓ Completado${NC}" + else + echo -e " ${RED}✗ Error${NC}" + fi + else + echo " • Volumen $volume no existe, saltando..." + fi +done + +# 2. Backup de configuraciones +echo -e "\n${YELLOW}Realizando backup de configuraciones...${NC}" + +if [ -d "$PROJECT_DIR" ]; then + cd "$PROJECT_DIR" || exit + + tar czf "${BACKUP_DIR}/config-${DATE}.tar.gz" \ + compose.yml \ + docker-compose.observability.yml \ + prometheus/ \ + elk/ \ + .env 2>/dev/null + + if [ $? -eq 0 ]; then + echo -e " ${GREEN}✓ Configuraciones respaldadas${NC}" + else + echo -e " ${RED}✗ Error al respaldar configuraciones${NC}" + fi +else + echo -e " ${RED}✗ Directorio del proyecto no encontrado${NC}" +fi + +# 3. Backup de scripts personalizados +echo -e "\n${YELLOW}Realizando backup de scripts...${NC}" + +if [ -d "${PROJECT_DIR}/scripts" ]; then + tar czf "${BACKUP_DIR}/scripts-${DATE}.tar.gz" -C "$PROJECT_DIR" scripts/ + echo -e " ${GREEN}✓ Scripts respaldados${NC}" +fi + +# 4. Listar backups +echo -e "\n${YELLOW}Backups creados:${NC}" +ls -lh "$BACKUP_DIR" | grep "$DATE" + +# 5. Calcular tamaño total +total_size=$(du -sh "$BACKUP_DIR" | cut -f1) +echo -e "\nTamaño total de backups: ${GREEN}$total_size${NC}" + +# 6. Limpiar backups antiguos (mantener últimos 7 días) +echo -e "\n${YELLOW}Limpiando backups antiguos (>7 días)...${NC}" +old_backups=$(find "$BACKUP_DIR" -name "*.tar.gz" -mtime +7) + +if [ -n "$old_backups" ]; then + echo "$old_backups" | while read file; do + echo " • Eliminando: $(basename "$file")" + rm -f "$file" + done + echo -e "${GREEN}✓ Backups antiguos eliminados${NC}" +else + echo " No hay backups antiguos para eliminar" +fi + +# 7. Resumen +echo -e "\n${GREEN}=== Backup Completado ===${NC}" +echo "Archivos de backup:" +ls -1 "$BACKUP_DIR" | grep "$DATE" | while read file; do + size=$(du -h "${BACKUP_DIR}/${file}" | cut -f1) + echo " • $file ($size)" +done + +echo -e "\n${YELLOW}Para restaurar un backup:${NC}" +echo " docker run --rm -v :/data -v ${BACKUP_DIR}:/backup ubuntu tar xzf /backup/.tar.gz -C /" diff --git a/scripts/check-services.sh b/scripts/check-services.sh new file mode 100755 index 000000000..e158c8b88 --- /dev/null +++ b/scripts/check-services.sh @@ -0,0 +1,173 @@ +#!/bin/bash + +############################################################################### +# Script de Verificación de Servicios +# Verifica que todos los microservicios estén corriendo correctamente +############################################################################### + +# Colores +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +# Función para verificar un servicio HTTP +check_http_service() { + local name=$1 + local url=$2 + local max_attempts=${3:-30} + local attempt=1 + + echo -n "Verificando $name... " + + while [ $attempt -le $max_attempts ]; do + if curl -s -f "$url" > /dev/null 2>&1; then + echo -e "${GREEN}✓ OK${NC}" + return 0 + fi + sleep 2 + attempt=$((attempt + 1)) + done + + echo -e "${RED}✗ TIMEOUT${NC}" + return 1 +} + +# Función para verificar un contenedor Docker +check_container() { + local container_name=$1 + + if docker ps --format '{{.Names}}' | grep -q "^${container_name}$"; then + local status=$(docker inspect --format='{{.State.Status}}' "$container_name") + if [ "$status" = "running" ]; then + echo -e "${GREEN}✓${NC} $container_name: running" + return 0 + else + echo -e "${RED}✗${NC} $container_name: $status" + return 1 + fi + else + echo -e "${RED}✗${NC} $container_name: not found" + return 1 + fi +} + +# Banner +echo -e "${BLUE}" +echo "╔═══════════════════════════════════════════════════════════╗" +echo "║ E-Commerce Microservices - Verificación de Servicios ║" +echo "╚═══════════════════════════════════════════════════════════╝" +echo -e "${NC}" + +# 1. Verificar contenedores Docker +echo -e "\n${YELLOW}=== Verificando Contenedores Docker ===${NC}\n" + +containers=( + "zipkin" + "service-discovery-container" + "cloud-config-container" + "api-gateway-container" + "proxy-client-container" + "order-service-container" + "payment-service-container" + "product-service-container" + "shipping-service-container" + "user-service-container" + "favourite-service-container" + "prometheus" + "alertmanager" + "grafana" + "jaeger" + "elasticsearch" + "logstash" + "kibana" +) + +container_failures=0 +for container in "${containers[@]}"; do + if ! check_container "$container"; then + ((container_failures++)) + fi +done + +# 2. Verificar servicios HTTP +echo -e "\n${YELLOW}=== Verificando Servicios HTTP ===${NC}\n" + +service_failures=0 + +# Infraestructura +echo -e "${BLUE}Infraestructura:${NC}" +check_http_service "Eureka" "http://localhost:8761" || ((service_failures++)) +check_http_service "Config Server" "http://localhost:9296/actuator/health" || ((service_failures++)) +check_http_service "API Gateway" "http://localhost:8080/actuator/health" || ((service_failures++)) +check_http_service "Zipkin" "http://localhost:9411" || ((service_failures++)) + +# Microservicios +echo -e "\n${BLUE}Microservicios:${NC}" +check_http_service "Proxy Client" "http://localhost:8900/actuator/health" || ((service_failures++)) +check_http_service "Order Service" "http://localhost:8300/actuator/health" || ((service_failures++)) +check_http_service "Payment Service" "http://localhost:8400/actuator/health" || ((service_failures++)) +check_http_service "Product Service" "http://localhost:8500/actuator/health" || ((service_failures++)) +check_http_service "Shipping Service" "http://localhost:8600/actuator/health" || ((service_failures++)) +check_http_service "User Service" "http://localhost:8700/actuator/health" || ((service_failures++)) +check_http_service "Favourite Service" "http://localhost:8800/actuator/health" || ((service_failures++)) + +# Observabilidad +echo -e "\n${BLUE}Observabilidad:${NC}" +check_http_service "Prometheus" "http://localhost:9090/-/healthy" || ((service_failures++)) +check_http_service "Alertmanager" "http://localhost:9093/-/healthy" || ((service_failures++)) +check_http_service "Grafana" "http://localhost:3000/api/health" || ((service_failures++)) +check_http_service "Jaeger" "http://localhost:16686" || ((service_failures++)) +check_http_service "Elasticsearch" "http://localhost:9200/_cluster/health" || ((service_failures++)) +check_http_service "Kibana" "http://localhost:5601/api/status" || ((service_failures++)) + +# 3. Verificar servicios registrados en Eureka +echo -e "\n${YELLOW}=== Servicios Registrados en Eureka ===${NC}\n" + +if curl -s http://localhost:8761/eureka/apps > /dev/null 2>&1; then + registered_services=$(curl -s http://localhost:8761/eureka/apps | grep -o '[^<]*' | sed 's///g' | sed 's/<\/name>//g' | sort -u) + + if [ -n "$registered_services" ]; then + echo "$registered_services" | while read service; do + echo -e "${GREEN}✓${NC} $service" + done + else + echo -e "${RED}No se encontraron servicios registrados${NC}" + fi +else + echo -e "${RED}No se pudo conectar a Eureka${NC}" +fi + +# 4. Verificar uso de recursos +echo -e "\n${YELLOW}=== Uso de Recursos ===${NC}\n" + +echo -e "${BLUE}Memoria:${NC}" +free -h | grep -E "Mem:|Swap:" + +echo -e "\n${BLUE}Disco:${NC}" +df -h | grep -E "Filesystem|/dev/sda1" + +echo -e "\n${BLUE}Top 5 Contenedores por Uso de CPU:${NC}" +docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}" | head -n 6 + +# 5. Resumen +echo -e "\n${YELLOW}=== Resumen ===${NC}\n" + +total_containers=${#containers[@]} +total_services=17 # Número total de servicios HTTP verificados + +echo "Contenedores: $((total_containers - container_failures))/$total_containers corriendo" +echo "Servicios HTTP: $((total_services - service_failures))/$total_services respondiendo" + +if [ $container_failures -eq 0 ] && [ $service_failures -eq 0 ]; then + echo -e "\n${GREEN}✓ Todos los servicios están funcionando correctamente${NC}" + exit 0 +else + echo -e "\n${RED}✗ Algunos servicios tienen problemas${NC}" + echo -e "\nPara ver logs de un servicio específico:" + echo " docker-compose -f compose.yml logs " + echo -e "\nPara reiniciar un servicio:" + echo " docker-compose -f compose.yml restart " + exit 1 +fi diff --git a/scripts/deploy-gradual.sh b/scripts/deploy-gradual.sh new file mode 100755 index 000000000..d46dec1b2 --- /dev/null +++ b/scripts/deploy-gradual.sh @@ -0,0 +1,203 @@ +#!/bin/bash + +############################################################################### +# Script de Despliegue Gradual +# Inicia los servicios en el orden correcto con verificaciones +############################################################################### + +# Colores +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +# Función para esperar que un servicio esté listo +wait_for_service() { + local name=$1 + local url=$2 + local max_wait=${3:-300} # 5 minutos por defecto + local elapsed=0 + + echo -n "Esperando a que $name esté listo..." + + while [ $elapsed -lt $max_wait ]; do + if curl -s -f "$url" > /dev/null 2>&1; then + echo -e " ${GREEN}✓ Listo${NC} (${elapsed}s)" + return 0 + fi + echo -n "." + sleep 5 + elapsed=$((elapsed + 5)) + done + + echo -e " ${RED}✗ Timeout${NC}" + return 1 +} + +# Banner +echo -e "${BLUE}" +echo "╔═══════════════════════════════════════════════════════════╗" +echo "║ E-Commerce Microservices - Despliegue Gradual ║" +echo "╚═══════════════════════════════════════════════════════════╝" +echo -e "${NC}" + +# Verificar que estamos en el directorio correcto +if [ ! -f "compose.yml" ]; then + echo -e "${RED}Error: No se encontró compose.yml${NC}" + echo "Por favor, ejecuta este script desde el directorio del proyecto" + exit 1 +fi + +# Preguntar si detener servicios existentes +echo -e "${YELLOW}¿Deseas detener los servicios existentes primero? (s/n)${NC}" +read -r response +if [[ "$response" =~ ^[Ss]$ ]]; then + echo -e "\n${BLUE}Deteniendo servicios existentes...${NC}" + docker-compose -f compose.yml down + echo -e "${GREEN}Servicios detenidos${NC}" + sleep 5 +fi + +# Fase 1: Infraestructura Base +echo -e "\n${YELLOW}=== Fase 1: Infraestructura Base ===${NC}\n" + +echo "Iniciando Zipkin..." +docker-compose -f compose.yml up -d zipkin +wait_for_service "Zipkin" "http://localhost:9411" 60 + +echo -e "\nIniciando Service Discovery (Eureka)..." +docker-compose -f compose.yml up -d service-discovery-container +wait_for_service "Eureka" "http://localhost:8761" 120 + +echo -e "\nIniciando Cloud Config Server..." +docker-compose -f compose.yml up -d cloud-config-container +wait_for_service "Config Server" "http://localhost:9296/actuator/health" 120 + +echo -e "${GREEN}✓ Infraestructura base lista${NC}" +sleep 10 + +# Fase 2: API Gateway y Proxy +echo -e "\n${YELLOW}=== Fase 2: API Gateway y Proxy ===${NC}\n" + +echo "Iniciando API Gateway..." +docker-compose -f compose.yml up -d api-gateway-container +wait_for_service "API Gateway" "http://localhost:8080/actuator/health" 120 + +echo -e "\nIniciando Proxy Client..." +docker-compose -f compose.yml up -d proxy-client-container +wait_for_service "Proxy Client" "http://localhost:8900/actuator/health" 120 + +echo -e "${GREEN}✓ Gateway y Proxy listos${NC}" +sleep 10 + +# Fase 3: Microservicios de Negocio +echo -e "\n${YELLOW}=== Fase 3: Microservicios de Negocio ===${NC}\n" + +services=( + "user-service-container:8700" + "product-service-container:8500" + "order-service-container:8300" + "payment-service-container:8400" + "shipping-service-container:8600" + "favourite-service-container:8800" +) + +for service_info in "${services[@]}"; do + IFS=':' read -r service port <<< "$service_info" + service_name=$(echo "$service" | sed 's/-container//' | sed 's/-/ /g' | awk '{for(i=1;i<=NF;i++)sub(/./,toupper(substr($i,1,1)),$i)}1') + + echo -e "\nIniciando $service_name..." + docker-compose -f compose.yml up -d "$service" + wait_for_service "$service_name" "http://localhost:$port/actuator/health" 120 +done + +echo -e "${GREEN}✓ Microservicios de negocio listos${NC}" +sleep 10 + +# Fase 4: Stack de Observabilidad +echo -e "\n${YELLOW}=== Fase 4: Stack de Observabilidad ===${NC}\n" + +echo "Iniciando Elasticsearch..." +docker-compose -f compose.yml up -d elasticsearch +wait_for_service "Elasticsearch" "http://localhost:9200/_cluster/health" 180 + +echo -e "\nIniciando Logstash..." +docker-compose -f compose.yml up -d logstash +sleep 30 # Logstash tarda en iniciar + +echo -e "\nIniciando Kibana..." +docker-compose -f compose.yml up -d kibana +wait_for_service "Kibana" "http://localhost:5601/api/status" 180 + +echo -e "\nIniciando Prometheus..." +docker-compose -f compose.yml up -d prometheus +wait_for_service "Prometheus" "http://localhost:9090/-/healthy" 60 + +echo -e "\nIniciando Alertmanager..." +docker-compose -f compose.yml up -d alertmanager +wait_for_service "Alertmanager" "http://localhost:9093/-/healthy" 60 + +echo -e "\nIniciando Grafana..." +docker-compose -f compose.yml up -d grafana +wait_for_service "Grafana" "http://localhost:3000/api/health" 120 + +echo -e "\nIniciando Jaeger..." +docker-compose -f compose.yml up -d jaeger +wait_for_service "Jaeger" "http://localhost:16686" 60 + +echo -e "${GREEN}✓ Stack de observabilidad listo${NC}" + +# Resumen final +echo -e "\n${YELLOW}=== Resumen del Despliegue ===${NC}\n" + +echo "Estado de los contenedores:" +docker-compose -f compose.yml ps + +echo -e "\n${BLUE}Servicios disponibles:${NC}" +echo "" +echo "Infraestructura:" +echo " • Eureka: http://localhost:8761" +echo " • Config Server: http://localhost:9296" +echo " • API Gateway: http://localhost:8080" +echo " • Zipkin: http://localhost:9411" +echo "" +echo "Microservicios:" +echo " • User Service: http://localhost:8700/swagger-ui.html" +echo " • Product Service: http://localhost:8500/swagger-ui.html" +echo " • Order Service: http://localhost:8300/swagger-ui.html" +echo " • Payment Service: http://localhost:8400/swagger-ui.html" +echo " • Shipping Service: http://localhost:8600/swagger-ui.html" +echo " • Favourite Service: http://localhost:8800/swagger-ui.html" +echo " • Proxy Client: http://localhost:8900/swagger-ui.html" +echo "" +echo "Observabilidad:" +echo " • Prometheus: http://localhost:9090" +echo " • Grafana: http://localhost:3000 (admin/admin)" +echo " • Kibana: http://localhost:5601" +echo " • Jaeger: http://localhost:16686" +echo "" + +# Verificar servicios registrados en Eureka +echo -e "${BLUE}Servicios registrados en Eureka:${NC}" +sleep 5 # Dar tiempo para que se registren +if curl -s http://localhost:8761/eureka/apps > /dev/null 2>&1; then + registered=$(curl -s http://localhost:8761/eureka/apps | grep -o '[^<]*' | sed 's///g' | sed 's/<\/name>//g' | sort -u | wc -l) + echo " Total: $registered servicios" + curl -s http://localhost:8761/eureka/apps | grep -o '[^<]*' | sed 's///g' | sed 's/<\/name>//g' | sort -u | while read service; do + echo " • $service" + done +else + echo -e " ${RED}No se pudo conectar a Eureka${NC}" +fi + +echo -e "\n${GREEN}╔═══════════════════════════════════════════════════════════╗${NC}" +echo -e "${GREEN}║ ✓ Despliegue completado exitosamente ║${NC}" +echo -e "${GREEN}╚═══════════════════════════════════════════════════════════╝${NC}" + +echo -e "\n${YELLOW}Próximos pasos:${NC}" +echo " 1. Verifica el estado: ./scripts/check-services.sh" +echo " 2. Ejecuta los tests: ./test-em-all.sh" +echo " 3. Accede a Grafana y configura los dashboards" +echo " 4. Revisa los logs: docker-compose -f compose.yml logs -f" +echo "" diff --git a/service-discovery/Dockerfile b/service-discovery/Dockerfile index 7562e96fa..b59a4f9f5 100644 --- a/service-discovery/Dockerfile +++ b/service-discovery/Dockerfile @@ -3,10 +3,8 @@ FROM openjdk:11 ARG PROJECT_VERSION=0.1.0 RUN mkdir -p /home/app WORKDIR /home/app -ENV SPRING_PROFILES_ACTIVE dev +ENV SPRING_PROFILES_ACTIVE=dev COPY service-discovery/ . ADD service-discovery/target/service-discovery-v${PROJECT_VERSION}.jar service-discovery.jar EXPOSE 8761 -ENTRYPOINT ["java", "-Dspring.profiles.active=${SPRING_PROFILES_ACTIVE}", "-jar", "service-discovery.jar"] - - +ENTRYPOINT ["java", "-Dspring.profiles.active=${SPRING_PROFILES_ACTIVE}", "-jar", "service-discovery.jar"] \ No newline at end of file diff --git a/service-discovery/compose.yml b/service-discovery/compose.yml index 21226ed39..a08bb45f4 100644 --- a/service-discovery/compose.yml +++ b/service-discovery/compose.yml @@ -1,8 +1,7 @@ - version: '3' services: service-discovery-container: - image: selimhorri/service-discovery-ecommerce-boot:0.1.0 + image: juanse201/service-discovery-ecommerce-boot:0.1.0 ports: - 8761:8761 environment: diff --git a/service-discovery/src/main/resources/application.yml b/service-discovery/src/main/resources/application.yml index 8af09cce0..ac839eeaf 100644 --- a/service-discovery/src/main/resources/application.yml +++ b/service-discovery/src/main/resources/application.yml @@ -1,7 +1,7 @@ spring: zipkin: - base-url: ${SPRING_ZIPKIN_BASE_URL:http://localhost:9411/} + base-url: ${SPRING_ZIPKIN_BASE_URL:http://zipkin:9411/} application: name: SERVICE-DISCOVERY profiles: diff --git a/service-discovery/src/test/java/com/selimhorri/app/integration/ServiceDiscoveryApplicationIntegrationTest.java b/service-discovery/src/test/java/com/selimhorri/app/integration/ServiceDiscoveryApplicationIntegrationTest.java new file mode 100644 index 000000000..9875323aa --- /dev/null +++ b/service-discovery/src/test/java/com/selimhorri/app/integration/ServiceDiscoveryApplicationIntegrationTest.java @@ -0,0 +1,29 @@ +package com.selimhorri.app.integration; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +public class ServiceDiscoveryApplicationIntegrationTest { + + @Test + void contextLoads() { + // Spring context should load without exceptions + } + + @Test + void testApplicationClassIsNotNull() { + assertNotNull(com.selimhorri.app.ServiceDiscoveryApplication.class); + } + + @Test + void testSpringBootApplicationAnnotationPresent() { + assertTrue(com.selimhorri.app.ServiceDiscoveryApplication.class.isAnnotationPresent(org.springframework.boot.autoconfigure.SpringBootApplication.class)); + } + + @Test + void testEnableEurekaServerAnnotationPresent() { + assertTrue(com.selimhorri.app.ServiceDiscoveryApplication.class.isAnnotationPresent(org.springframework.cloud.netflix.eureka.server.EnableEurekaServer.class)); + } +} diff --git a/service-discovery/src/test/java/com/selimhorri/app/unit/ServiceDiscoveryApplicationUnitTest.java b/service-discovery/src/test/java/com/selimhorri/app/unit/ServiceDiscoveryApplicationUnitTest.java new file mode 100644 index 000000000..d8e4bafd5 --- /dev/null +++ b/service-discovery/src/test/java/com/selimhorri/app/unit/ServiceDiscoveryApplicationUnitTest.java @@ -0,0 +1,41 @@ +package com.selimhorri.app.unit; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +public class ServiceDiscoveryApplicationUnitTest { + + @Test + void testMainMethodExists() { + // Just check that the main method exists and is public static + try { + java.lang.reflect.Method main = com.selimhorri.app.ServiceDiscoveryApplication.class.getMethod("main", String[].class); + int modifiers = main.getModifiers(); + assertTrue(java.lang.reflect.Modifier.isPublic(modifiers)); + assertTrue(java.lang.reflect.Modifier.isStatic(modifiers)); + } catch (NoSuchMethodException e) { + fail("main method not found"); + } + } + + @Test + void testMainWithNullArgsThrows() { + // Should throw IllegalArgumentException if null is passed + assertThrows(IllegalArgumentException.class, () -> com.selimhorri.app.ServiceDiscoveryApplication.main(null)); + } + + @Test + void testClassHasSpringBootApplicationAnnotation() { + assertTrue(com.selimhorri.app.ServiceDiscoveryApplication.class.isAnnotationPresent(org.springframework.boot.autoconfigure.SpringBootApplication.class)); + } + + @Test + void testClassHasEnableEurekaServerAnnotation() { + assertTrue(com.selimhorri.app.ServiceDiscoveryApplication.class.isAnnotationPresent(org.springframework.cloud.netflix.eureka.server.EnableEurekaServer.class)); + } + + @Test + void testClassName() { + assertEquals("ServiceDiscoveryApplication", com.selimhorri.app.ServiceDiscoveryApplication.class.getSimpleName()); + } +} diff --git a/shipping-service/Dockerfile b/shipping-service/Dockerfile index c678aef31..48844f563 100644 --- a/shipping-service/Dockerfile +++ b/shipping-service/Dockerfile @@ -3,10 +3,8 @@ FROM openjdk:11 ARG PROJECT_VERSION=0.1.0 RUN mkdir -p /home/app WORKDIR /home/app -ENV SPRING_PROFILES_ACTIVE dev +ENV SPRING_PROFILES_ACTIVE=dev COPY shipping-service/ . ADD shipping-service/target/shipping-service-v${PROJECT_VERSION}.jar shipping-service.jar EXPOSE 8600 -ENTRYPOINT ["java", "-Dspring.profiles.active=${SPRING_PROFILES_ACTIVE}", "-jar", "shipping-service.jar"] - - +ENTRYPOINT ["java", "-Dspring.profiles.active=${SPRING_PROFILES_ACTIVE}", "-jar", "shipping-service.jar"] \ No newline at end of file diff --git a/shipping-service/compose.yml b/shipping-service/compose.yml index 81878a33a..7bc9d4572 100644 --- a/shipping-service/compose.yml +++ b/shipping-service/compose.yml @@ -1,12 +1,23 @@ - version: '3' services: shipping-service-container: - image: selimhorri/shipping-service-ecommerce-boot:0.1.0 + image: juanse201/shipping-service-ecommerce-boot:0.1.0 ports: - 8600:8600 environment: - SPRING_PROFILES_ACTIVE=dev + - SPRING_ZIPKIN_BASE_URL=http://zipkin:9411 + - SPRING_CONFIG_IMPORT=optional:configserver:http://cloud-config-container:9296/ + - EUREKA_CLIENT_REGION=default + - EUREKA_CLIENT_AVAILABILITY_ZONES_DEFAULT=myzone + - EUREKA_CLIENT_SERVICEURL_MYZONE=http://service-discovery-container:8761/eureka + - EUREKA_CLIENT_SERVICEURL_DEFAULTZONE=http://service-discovery-container:8761/eureka/ + networks: + - microservices_network +networks: + microservices_network: + external: true + name: microservices_network diff --git a/shipping-service/pom.xml b/shipping-service/pom.xml index 9e1c2d509..f2d5a2e80 100644 --- a/shipping-service/pom.xml +++ b/shipping-service/pom.xml @@ -69,10 +69,27 @@ true - org.testcontainers - mysql - test - + org.testcontainers + mysql + test + + + org.springframework.boot + spring-boot-starter-actuator + + + io.micrometer + micrometer-registry-prometheus + + + net.logstash.logback + logstash-logback-encoder + 7.3 + + + org.springframework.cloud + spring-cloud-starter-sleuth + diff --git a/shipping-service/src/main/java/com/selimhorri/app/service/impl/OrderItemServiceImpl.java b/shipping-service/src/main/java/com/selimhorri/app/service/impl/OrderItemServiceImpl.java index 8a6605ad9..cd8f1a88c 100644 --- a/shipping-service/src/main/java/com/selimhorri/app/service/impl/OrderItemServiceImpl.java +++ b/shipping-service/src/main/java/com/selimhorri/app/service/impl/OrderItemServiceImpl.java @@ -49,8 +49,8 @@ public List findAll() { @Override public OrderItemDto findById(final OrderItemId orderItemId) { - log.info("*** OrderItemDto, service; fetch orderItem by id *"); - return this.orderItemRepository.findById(null) + log.info("*** OrderItemDto, service; fetch orderItem by id *", orderItemId); + return this.orderItemRepository.findById(orderItemId) .map(OrderItemMappingHelper::map) .map(o -> { o.setProductDto(this.restTemplate.getForObject(AppConstant.DiscoveredDomainsApi @@ -59,7 +59,10 @@ public OrderItemDto findById(final OrderItemId orderItemId) { .ORDER_SERVICE_API_URL + "/" + o.getOrderDto().getOrderId(), OrderDto.class)); return o; }) - .orElseThrow(() -> new OrderItemNotFoundException(String.format("OrderItem with id: %s not found", orderItemId))); + .orElseThrow(() -> { + log.warn("OrderItem not found for id: {}", orderItemId); + throw new OrderItemNotFoundException(String.format("OrderItem with id: %s not found", orderItemId)); + }); } @Override diff --git a/shipping-service/src/main/resources/application.yml b/shipping-service/src/main/resources/application.yml index a7230f2ae..14eda8272 100644 --- a/shipping-service/src/main/resources/application.yml +++ b/shipping-service/src/main/resources/application.yml @@ -5,15 +5,21 @@ server: spring: zipkin: - base-url: ${SPRING_ZIPKIN_BASE_URL:http://localhost:9411/} + base-url: ${SPRING_ZIPKIN_BASE_URL:http://zipkin:9411/} config: - import: ${SPRING_CONFIG_IMPORT:optional:configserver:http://localhost:9296} + import: ${SPRING_CONFIG_IMPORT:optional:configserver:http://cloud-config:9296} application: name: SHIPPING-SERVICE profiles: active: - dev +eureka: + client: + service-url: + defaultZone: ${EUREKA_CLIENT_SERVICEURL_DEFAULTZONE:http://service-discovery-container:8761/eureka/} + + resilience4j: circuitbreaker: instances: @@ -29,12 +35,33 @@ resilience4j: sliding-window-type: COUNT_BASED management: - health: - circuitbreakers: - enabled: true + endpoints: + web: + exposure: + include: health,info,metrics,prometheus,env,loggers,threaddump,heapdump endpoint: health: show-details: always + probes: + enabled: true + metrics: + enabled: true + prometheus: + enabled: true + metrics: + export: + prometheus: + enabled: true + tags: + application: ${spring.application.name} + environment: ${spring.profiles.active} + health: + circuitbreakers: + enabled: true + livenessstate: + enabled: true + readinessstate: + enabled: true diff --git a/shipping-service/src/main/resources/logback-spring.xml b/shipping-service/src/main/resources/logback-spring.xml new file mode 100644 index 000000000..9a551a487 --- /dev/null +++ b/shipping-service/src/main/resources/logback-spring.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + {"app_name":"${appName}","app_port":"${appPort}"} + traceId + spanId + + + + + + logstash:5000 + + {"app_name":"${appName}","app_port":"${appPort}"} + traceId + spanId + + 5 minutes + + + + + + 512 + + + + + + + + + + + + + + + diff --git a/shipping-service/src/test/java/com/selimhorri/app/integration/ShippingServiceApplicationIntegrationTest.java b/shipping-service/src/test/java/com/selimhorri/app/integration/ShippingServiceApplicationIntegrationTest.java new file mode 100644 index 000000000..a5fab8aff --- /dev/null +++ b/shipping-service/src/test/java/com/selimhorri/app/integration/ShippingServiceApplicationIntegrationTest.java @@ -0,0 +1,44 @@ +package com.selimhorri.app.integration; + +import com.selimhorri.app.ShippingServiceApplication; +import com.selimhorri.app.config.mapper.MapperConfig; +import com.selimhorri.app.config.client.ClientConfig; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.web.client.RestTemplate; +import org.springframework.boot.test.context.SpringBootTest; +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +public class ShippingServiceApplicationIntegrationTest { + + @Test + void contextLoads() { + // Spring context should load without exceptions + } + + @Test + void testObjectMapperBean() { + MapperConfig config = new MapperConfig(); + ObjectMapper mapper = config.objectMapperBean(); + assertNotNull(mapper); + assertTrue(mapper.isEnabled(com.fasterxml.jackson.databind.SerializationFeature.INDENT_OUTPUT)); + } + + @Test + void testRestTemplateBean() { + ClientConfig config = new ClientConfig(); + RestTemplate restTemplate = config.restTemplateBean(); + assertNotNull(restTemplate); + } + + @Test + void testMainMethodDoesNotThrow() { + assertDoesNotThrow(() -> ShippingServiceApplication.main(new String[]{})); + } + + @Test + void testApplicationClassIsNotNull() { + assertNotNull(ShippingServiceApplication.class); + } +} diff --git a/shipping-service/src/test/java/com/selimhorri/app/unit/ShippingServiceApplicationUnitTest.java b/shipping-service/src/test/java/com/selimhorri/app/unit/ShippingServiceApplicationUnitTest.java new file mode 100644 index 000000000..fcf3f8fcc --- /dev/null +++ b/shipping-service/src/test/java/com/selimhorri/app/unit/ShippingServiceApplicationUnitTest.java @@ -0,0 +1,59 @@ +package com.selimhorri.app.unit; + +import com.selimhorri.app.ShippingServiceApplication; +import com.selimhorri.app.helper.OrderItemMappingHelper; +import com.selimhorri.app.domain.OrderItem; +import com.selimhorri.app.dto.OrderItemDto; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +public class ShippingServiceApplicationUnitTest { + + @Test + void testMainMethodExists() { + try { + java.lang.reflect.Method main = ShippingServiceApplication.class.getMethod("main", String[].class); + int modifiers = main.getModifiers(); + assertTrue(java.lang.reflect.Modifier.isPublic(modifiers)); + assertTrue(java.lang.reflect.Modifier.isStatic(modifiers)); + } catch (NoSuchMethodException e) { + fail("main method not found"); + } + } + + @Test + void testOrderItemMappingHelperMapToDto() { + OrderItem orderItem = OrderItem.builder().productId(1).orderId(2).orderedQuantity(3).build(); + OrderItemDto dto = OrderItemMappingHelper.map(orderItem); + assertEquals(1, dto.getProductId()); + assertEquals(2, dto.getOrderId()); + assertEquals(3, dto.getOrderedQuantity()); + assertNotNull(dto.getProductDto()); + assertNotNull(dto.getOrderDto()); + } + + @Test + void testOrderItemMappingHelperMapToEntity() { + OrderItemDto dto = OrderItemDto.builder().productId(1).orderId(2).orderedQuantity(3).build(); + OrderItem entity = OrderItemMappingHelper.map(dto); + assertEquals(1, entity.getProductId()); + assertEquals(2, entity.getOrderId()); + assertEquals(3, entity.getOrderedQuantity()); + } + + @Test + void testOrderItemDtoBuilder() { + OrderItemDto dto = OrderItemDto.builder().productId(10).orderId(20).orderedQuantity(30).build(); + assertEquals(10, dto.getProductId()); + assertEquals(20, dto.getOrderId()); + assertEquals(30, dto.getOrderedQuantity()); + } + + @Test + void testOrderItemBuilder() { + OrderItem entity = OrderItem.builder().productId(10).orderId(20).orderedQuantity(30).build(); + assertEquals(10, entity.getProductId()); + assertEquals(20, entity.getOrderId()); + assertEquals(30, entity.getOrderedQuantity()); + } +} diff --git a/user-service/Dockerfile b/user-service/Dockerfile index fdc109e11..4e7aa5a30 100644 --- a/user-service/Dockerfile +++ b/user-service/Dockerfile @@ -3,10 +3,8 @@ FROM openjdk:11 ARG PROJECT_VERSION=0.1.0 RUN mkdir -p /home/app WORKDIR /home/app -ENV SPRING_PROFILES_ACTIVE dev +ENV SPRING_PROFILES_ACTIVE=dev COPY user-service/ . ADD user-service/target/user-service-v${PROJECT_VERSION}.jar user-service.jar EXPOSE 8700 -ENTRYPOINT ["java", "-Dspring.profiles.active=${SPRING_PROFILES_ACTIVE}", "-jar", "user-service.jar"] - - +ENTRYPOINT ["java", "-Dspring.profiles.active=${SPRING_PROFILES_ACTIVE}", "-jar", "user-service.jar"] \ No newline at end of file diff --git a/user-service/compose.yml b/user-service/compose.yml index 18c0f49a0..87cd3aeba 100644 --- a/user-service/compose.yml +++ b/user-service/compose.yml @@ -1,12 +1,21 @@ - +#docker-compose version: '3' services: user-service-container: - image: selimhorri/user-service-ecommerce-boot:0.1.0 + image: juanse201/user-service-ecommerce-boot:0.1.0 ports: - 8700:8700 environment: - SPRING_PROFILES_ACTIVE=dev - - - + - SPRING_ZIPKIN_BASE_URL=http://zipkin:9411 + - SPRING_CONFIG_IMPORT=optional:configserver:http://cloud-config-container:9296/ + - EUREKA_CLIENT_REGION=default + - EUREKA_CLIENT_AVAILABILITY_ZONES_DEFAULT=myzone + - EUREKA_CLIENT_SERVICEURL_MYZONE=http://service-discovery-container:8761/eureka + - EUREKA_CLIENT_SERVICEURL_DEFAULTZONE=http://service-discovery-container:8761/eureka/ + networks: + - microservices_network +networks: + microservices_network: + external: true + name: microservices_network diff --git a/user-service/pom.xml b/user-service/pom.xml index 28bed9bd3..7206edd9a 100644 --- a/user-service/pom.xml +++ b/user-service/pom.xml @@ -73,6 +73,23 @@ mysql test + + org.springframework.boot + spring-boot-starter-actuator + + + io.micrometer + micrometer-registry-prometheus + + + net.logstash.logback + logstash-logback-encoder + 7.3 + + + org.springframework.cloud + spring-cloud-starter-sleuth + diff --git a/user-service/src/main/java/com/selimhorri/app/helper/UserMappingHelper.java b/user-service/src/main/java/com/selimhorri/app/helper/UserMappingHelper.java index 1bb02c2d6..d26fda2fc 100644 --- a/user-service/src/main/java/com/selimhorri/app/helper/UserMappingHelper.java +++ b/user-service/src/main/java/com/selimhorri/app/helper/UserMappingHelper.java @@ -8,47 +8,59 @@ public interface UserMappingHelper { public static UserDto map(final User user) { - return UserDto.builder() - .userId(user.getUserId()) - .firstName(user.getFirstName()) - .lastName(user.getLastName()) - .imageUrl(user.getImageUrl()) - .email(user.getEmail()) - .phone(user.getPhone()) - .credentialDto( - CredentialDto.builder() - .credentialId(user.getCredential().getCredentialId()) - .username(user.getCredential().getUsername()) - .password(user.getCredential().getPassword()) - .roleBasedAuthority(user.getCredential().getRoleBasedAuthority()) - .isEnabled(user.getCredential().getIsEnabled()) - .isAccountNonExpired(user.getCredential().getIsAccountNonExpired()) - .isAccountNonLocked(user.getCredential().getIsAccountNonLocked()) - .isCredentialsNonExpired(user.getCredential().getIsCredentialsNonExpired()) - .build()) - .build(); + Credential credential = user.getCredential(); + CredentialDto credentialDto = null; + if (credential != null) { + credentialDto = CredentialDto.builder() + .credentialId(credential.getCredentialId()) + .username(credential.getUsername()) + .password(credential.getPassword()) + .roleBasedAuthority(credential.getRoleBasedAuthority()) + .isEnabled(credential.getIsEnabled()) + .isAccountNonExpired(credential.getIsAccountNonExpired()) + .isAccountNonLocked(credential.getIsAccountNonLocked()) + .isCredentialsNonExpired(credential.getIsCredentialsNonExpired()) + .build(); + } + return UserDto.builder() + .userId(user.getUserId()) + .firstName(user.getFirstName()) + .lastName(user.getLastName()) + .imageUrl(user.getImageUrl()) + .email(user.getEmail()) + .phone(user.getPhone()) + .credentialDto(credentialDto) + .build(); } public static User map(final UserDto userDto) { - return User.builder() - .userId(userDto.getUserId()) - .firstName(userDto.getFirstName()) - .lastName(userDto.getLastName()) - .imageUrl(userDto.getImageUrl()) - .email(userDto.getEmail()) - .phone(userDto.getPhone()) - .credential( - Credential.builder() - .credentialId(userDto.getCredentialDto().getCredentialId()) - .username(userDto.getCredentialDto().getUsername()) - .password(userDto.getCredentialDto().getPassword()) - .roleBasedAuthority(userDto.getCredentialDto().getRoleBasedAuthority()) - .isEnabled(userDto.getCredentialDto().getIsEnabled()) - .isAccountNonExpired(userDto.getCredentialDto().getIsAccountNonExpired()) - .isAccountNonLocked(userDto.getCredentialDto().getIsAccountNonLocked()) - .isCredentialsNonExpired(userDto.getCredentialDto().getIsCredentialsNonExpired()) - .build()) - .build(); + User.UserBuilder userBuilder = User.builder() + .userId(userDto.getUserId()) + .firstName(userDto.getFirstName()) + .lastName(userDto.getLastName()) + .imageUrl(userDto.getImageUrl()) + .email(userDto.getEmail()) + .phone(userDto.getPhone()); + + if (userDto.getCredentialDto() != null) { + Credential.CredentialBuilder credentialBuilder = Credential.builder() + .credentialId(userDto.getCredentialDto().getCredentialId()) + .username(userDto.getCredentialDto().getUsername()) + .password(userDto.getCredentialDto().getPassword()) + .roleBasedAuthority(userDto.getCredentialDto().getRoleBasedAuthority()) + .isEnabled(userDto.getCredentialDto().getIsEnabled()) + .isAccountNonExpired(userDto.getCredentialDto().getIsAccountNonExpired()) + .isAccountNonLocked(userDto.getCredentialDto().getIsAccountNonLocked()) + .isCredentialsNonExpired(userDto.getCredentialDto().getIsCredentialsNonExpired()); + + Credential credential = credentialBuilder.build(); + User user = userBuilder.build(); + credential.setUser(user); + user.setCredential(credential); + return user; + } else { + return userBuilder.build(); + } } diff --git a/user-service/src/main/resources/application.yml b/user-service/src/main/resources/application.yml index 27596cdd2..da7663a31 100644 --- a/user-service/src/main/resources/application.yml +++ b/user-service/src/main/resources/application.yml @@ -5,15 +5,20 @@ server: spring: zipkin: - base-url: ${SPRING_ZIPKIN_BASE_URL:http://localhost:9411/} + base-url: ${SPRING_ZIPKIN_BASE_URL:http://zipkin:9411/} config: - import: ${SPRING_CONFIG_IMPORT:optional:configserver:http://localhost:9296} + import: ${SPRING_CONFIG_IMPORT:optional:configserver:http://cloud-config:9296} application: name: USER-SERVICE profiles: active: - dev +eureka: + client: + service-url: + defaultZone: ${EUREKA_CLIENT_SERVICEURL_DEFAULTZONE:http://service-discovery-container:8761/eureka/} + resilience4j: circuitbreaker: instances: @@ -29,12 +34,33 @@ resilience4j: sliding-window-type: COUNT_BASED management: - health: - circuitbreakers: - enabled: true + endpoints: + web: + exposure: + include: health,info,metrics,prometheus,env,loggers,threaddump,heapdump endpoint: health: show-details: always + probes: + enabled: true + metrics: + enabled: true + prometheus: + enabled: true + metrics: + export: + prometheus: + enabled: true + tags: + application: ${spring.application.name} + environment: ${spring.profiles.active} + health: + circuitbreakers: + enabled: true + livenessstate: + enabled: true + readinessstate: + enabled: true diff --git a/user-service/src/main/resources/logback-spring.xml b/user-service/src/main/resources/logback-spring.xml new file mode 100644 index 000000000..9a551a487 --- /dev/null +++ b/user-service/src/main/resources/logback-spring.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + {"app_name":"${appName}","app_port":"${appPort}"} + traceId + spanId + + + + + + logstash:5000 + + {"app_name":"${appName}","app_port":"${appPort}"} + traceId + spanId + + 5 minutes + + + + + + 512 + + + + + + + + + + + + + + + diff --git a/user-service/src/test/java/com/selimhorri/app/integration/UserServiceApplicationIntegrationTest.java b/user-service/src/test/java/com/selimhorri/app/integration/UserServiceApplicationIntegrationTest.java new file mode 100644 index 000000000..487d583d7 --- /dev/null +++ b/user-service/src/test/java/com/selimhorri/app/integration/UserServiceApplicationIntegrationTest.java @@ -0,0 +1,44 @@ +package com.selimhorri.app.integration; + +import com.selimhorri.app.UserServiceApplication; +import com.selimhorri.app.helper.UserMappingHelper; +import com.selimhorri.app.dto.UserDto; +import com.selimhorri.app.dto.CredentialDto; +import com.selimhorri.app.domain.User; +import com.selimhorri.app.domain.Credential; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +public class UserServiceApplicationIntegrationTest { + + @Test + void contextLoads() { + // Spring context should load without exceptions + } + + @Test + void testUserMappingHelperMapToDtoAndBack() { + Credential cred = Credential.builder().credentialId(1).username("user").password("pass").build(); + User user = User.builder().userId(2).firstName("A").lastName("B").email("a@b.com").credential(cred).build(); + UserDto dto = UserMappingHelper.map(user); + User user2 = UserMappingHelper.map(dto); + assertEquals(user.getUserId(), user2.getUserId()); + assertEquals(user.getFirstName(), user2.getFirstName()); + assertEquals(user.getCredential().getUsername(), user2.getCredential().getUsername()); + } + + @Test + void testUserDtoBuilderWithCredential() { + CredentialDto cred = CredentialDto.builder().credentialId(1).username("user").password("pass").build(); + UserDto dto = UserDto.builder().userId(2).credentialDto(cred).build(); + assertEquals(2, dto.getUserId()); + assertEquals("user", dto.getCredentialDto().getUsername()); + } + + @Test + void testApplicationClassIsNotNull() { + assertNotNull(UserServiceApplication.class); + } +} diff --git a/user-service/src/test/java/com/selimhorri/app/unit/UserServiceApplicationUnitTest.java b/user-service/src/test/java/com/selimhorri/app/unit/UserServiceApplicationUnitTest.java new file mode 100644 index 000000000..c01878d74 --- /dev/null +++ b/user-service/src/test/java/com/selimhorri/app/unit/UserServiceApplicationUnitTest.java @@ -0,0 +1,64 @@ +package com.selimhorri.app.unit; + +import com.selimhorri.app.UserServiceApplication; +import com.selimhorri.app.helper.UserMappingHelper; +import com.selimhorri.app.dto.UserDto; +import com.selimhorri.app.dto.CredentialDto; +import com.selimhorri.app.domain.User; +import com.selimhorri.app.domain.Credential; +import com.selimhorri.app.dto.AddressDto; +import org.junit.jupiter.api.Test; +import java.util.Set; +import static org.junit.jupiter.api.Assertions.*; + +public class UserServiceApplicationUnitTest { + + @Test + void testMainMethodExists() { + try { + java.lang.reflect.Method main = UserServiceApplication.class.getMethod("main", String[].class); + int modifiers = main.getModifiers(); + assertTrue(java.lang.reflect.Modifier.isPublic(modifiers)); + assertTrue(java.lang.reflect.Modifier.isStatic(modifiers)); + } catch (NoSuchMethodException e) { + fail("main method not found"); + } + } + + @Test + void testUserMappingHelperMapToDto() { + Credential cred = Credential.builder().credentialId(1).username("user").password("pass").build(); + User user = User.builder().userId(2).firstName("A").lastName("B").email("a@b.com").credential(cred).build(); + UserDto dto = UserMappingHelper.map(user); + assertEquals(2, dto.getUserId()); + assertEquals("A", dto.getFirstName()); + assertEquals("user", dto.getCredentialDto().getUsername()); + } + + @Test + void testUserMappingHelperMapToEntity() { + CredentialDto cred = CredentialDto.builder().credentialId(1).username("user").password("pass").build(); + UserDto dto = UserDto.builder().userId(2).firstName("A").lastName("B").email("a@b.com").credentialDto(cred).build(); + User user = UserMappingHelper.map(dto); + assertEquals(2, user.getUserId()); + assertEquals("A", user.getFirstName()); + assertEquals("user", user.getCredential().getUsername()); + } + + @Test + void testUserDtoBuilder() { + UserDto dto = UserDto.builder().userId(10).firstName("F").lastName("L").email("f@l.com").build(); + assertEquals(10, dto.getUserId()); + assertEquals("F", dto.getFirstName()); + assertEquals("L", dto.getLastName()); + assertEquals("f@l.com", dto.getEmail()); + } + + @Test + void testUserDtoWithAddresses() { + AddressDto address = new AddressDto(); + UserDto dto = UserDto.builder().userId(1).addressDtos(Set.of(address)).build(); + assertNotNull(dto.getAddressDtos()); + assertEquals(1, dto.getAddressDtos().size()); + } +}