diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..1f27a1d6 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,197 @@ +name: Build & Deploy to Azure VM + +on: + push: + branches: [main, migration/complete-java-migration] + pull_request: + branches: [main] + +env: + JAVA_VERSION: '21' + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }}/eshop-catalog + WORKING_DIR: eShopLegacyMVC-SpringBoot + +jobs: + # ────────────────────────────────────────────── + # Job 1: Build, Test, and Verify + # ────────────────────────────────────────────── + build-and-test: + name: Build & Test + runs-on: ubuntu-latest + defaults: + run: + working-directory: ${{ env.WORKING_DIR }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: ${{ env.JAVA_VERSION }} + cache: maven + cache-dependency-path: ${{ env.WORKING_DIR }}/pom.xml + + - name: Build and run tests + run: ./mvnw clean verify -B + + - name: Check code formatting (Spotless) + run: ./mvnw spotless:check -B + + - name: Publish test results + uses: dorny/test-reporter@v1 + if: always() + with: + name: JUnit Test Results + path: ${{ env.WORKING_DIR }}/target/surefire-reports/*.xml + reporter: java-junit + + - name: Upload JaCoCo coverage report + uses: actions/upload-artifact@v4 + if: always() + with: + name: jacoco-report + path: ${{ env.WORKING_DIR }}/target/site/jacoco/ + retention-days: 14 + + # ────────────────────────────────────────────── + # Job 2: Build Docker Image and Push to GHCR + # ────────────────────────────────────────────── + docker-build-push: + name: Docker Build & Push + runs-on: ubuntu-latest + needs: build-and-test + if: github.event_name == 'push' + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=${{ github.sha }} + type=ref,event=branch + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: ${{ env.WORKING_DIR }} + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + # ────────────────────────────────────────────── + # Job 3: Deploy to Staging (auto-deploy) + # ────────────────────────────────────────────── + deploy-staging: + name: Deploy to Staging + runs-on: ubuntu-latest + needs: docker-build-push + environment: staging + steps: + - name: Deploy to staging VM via SSH + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.STAGING_VM_HOST }} + username: ${{ secrets.VM_USERNAME }} + key: ${{ secrets.VM_SSH_KEY }} + envs: GHCR_TOKEN + script: | + echo "$GHCR_TOKEN" | docker login ghcr.io -u ${{ github.actor }} --password-stdin + docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} + docker stop eshop-catalog || true + docker rm eshop-catalog || true + docker run -d \ + --name eshop-catalog \ + --restart unless-stopped \ + -p 8080:8080 \ + --env-file /opt/app/staging.env \ + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} + env: + GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # ────────────────────────────────────────────── + # Job 4: Health Check on Staging + # ────────────────────────────────────────────── + health-check-staging: + name: Staging Health Check + runs-on: ubuntu-latest + needs: deploy-staging + environment: staging + steps: + - name: Wait for application startup + run: sleep 30 + + - name: Verify health endpoint + run: | + for i in $(seq 1 10); do + STATUS=$(curl -sf "${{ secrets.STAGING_URL }}/actuator/health" | jq -r '.status') || true + if [ "$STATUS" = "UP" ]; then + echo "Health check passed: status is UP" + exit 0 + fi + echo "Attempt $i: status=$STATUS — retrying in 10s..." + sleep 10 + done + echo "Health check failed after 10 attempts" + exit 1 + + # ────────────────────────────────────────────── + # Job 5: Deploy to Production (manual approval) + # ────────────────────────────────────────────── + deploy-production: + name: Deploy to Production + runs-on: ubuntu-latest + needs: health-check-staging + environment: + name: production + url: ${{ secrets.PROD_URL }} + steps: + - name: Deploy to production VM via SSH + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.PROD_VM_HOST }} + username: ${{ secrets.VM_USERNAME }} + key: ${{ secrets.VM_SSH_KEY }} + envs: GHCR_TOKEN + script: | + echo "$GHCR_TOKEN" | docker login ghcr.io -u ${{ github.actor }} --password-stdin + docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} + docker stop eshop-catalog || true + docker rm eshop-catalog || true + docker run -d \ + --name eshop-catalog \ + --restart unless-stopped \ + -p 8080:8080 \ + --env-file /opt/app/production.env \ + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} + env: + GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Verify production health + run: | + sleep 30 + for i in $(seq 1 10); do + STATUS=$(curl -sf "${{ secrets.PROD_URL }}/actuator/health" | jq -r '.status') || true + if [ "$STATUS" = "UP" ]; then + echo "Production health check passed: status is UP" + exit 0 + fi + echo "Attempt $i: status=$STATUS — retrying in 10s..." + sleep 10 + done + echo "Production health check failed after 10 attempts" + exit 1 diff --git a/eShopLegacyMVC-SpringBoot/.dockerignore b/eShopLegacyMVC-SpringBoot/.dockerignore new file mode 100644 index 00000000..e10a2c91 --- /dev/null +++ b/eShopLegacyMVC-SpringBoot/.dockerignore @@ -0,0 +1,36 @@ +# Build output +target/ + +# IDE files +.idea/ +*.iml +.vscode/ +*.swp +*.swo + +# Git +.git/ +.gitignore + +# Docker +Dockerfile +docker-compose.yml +.dockerignore + +# CI/CD +azure-pipelines.yml +.github/ + +# Kubernetes +k8s/ + +# Scripts +scripts/ + +# Documentation +*.md +LICENSE + +# OS files +.DS_Store +Thumbs.db diff --git a/eShopLegacyMVC-SpringBoot/CUTOVER.md b/eShopLegacyMVC-SpringBoot/CUTOVER.md new file mode 100644 index 00000000..457d89b7 --- /dev/null +++ b/eShopLegacyMVC-SpringBoot/CUTOVER.md @@ -0,0 +1,292 @@ +# eShop Catalog — Cutover & Rollback Plan + +This document describes the procedure for cutting over from the legacy .NET MVC application to the new Spring Boot Java application on Azure VMs, and how to roll back if issues arise. + +--- + +## Table of Contents + +1. [Pre-Cutover Checklist](#pre-cutover-checklist) +2. [Database Migration](#database-migration) +3. [Cutover Procedure](#cutover-procedure) +4. [Rollback Plan](#rollback-plan) +5. [Monitoring Checklist](#monitoring-checklist) +6. [Communication Plan](#communication-plan) +7. [Success Criteria](#success-criteria) + +--- + +## Pre-Cutover Checklist + +### CI/CD Pipeline + +- [ ] GitHub Actions pipeline passes: build, tests, Spotless formatting, JaCoCo coverage +- [ ] Docker image built and pushed to GHCR (`ghcr.io`) +- [ ] Staging deployment completed via GitHub Actions +- [ ] Health endpoint returns `{"status":"UP"}` on staging + +### Infrastructure + +- [ ] Production Azure VM provisioned (Ubuntu 22.04, Docker installed via `scripts/setup-vm.sh`) +- [ ] NSG rules configured: SSH (port 22), HTTP (port 8080) +- [ ] Environment file populated: `/opt/app/production.env` +- [ ] SSH key for GitHub Actions stored in repository secrets (`VM_SSH_KEY`) +- [ ] DNS records prepared with low TTL (60s) for fast rollback + +### Application + +- [ ] Flyway migrations validated against production database schema +- [ ] Database backup taken before any schema changes +- [ ] Smoke tests passed on staging (catalog browse, search, CRUD) +- [ ] Load testing completed — response times within SLA +- [ ] Legacy .NET application snapshot/backup taken + +### Organizational + +- [ ] Rollback procedure reviewed and rehearsed by the team +- [ ] Communication sent to stakeholders with cutover window +- [ ] On-call team aware of the cutover schedule + +--- + +## Database Migration + +### Compatibility + +Both the .NET and Java applications connect to the same SQL Server database (`CatalogDb`). The Java application uses Flyway for schema migrations and `ddl-auto: validate` — Hibernate will not alter the schema. + +### Steps + +1. **Create a database backup** before any schema changes: + ```sql + BACKUP DATABASE CatalogDb TO DISK = '/backups/CatalogDb_pre_cutover.bak' + ``` + +2. **Preview Flyway migrations** in dry-run mode: + ```bash + ./mvnw flyway:info -Dspring.profiles.active=prod + ``` + +3. **Apply migrations** against the production database: + ```bash + ./mvnw flyway:migrate -Dspring.profiles.active=prod + ``` + +4. **Verify data integrity** — spot-check row counts: + ```sql + SELECT COUNT(*) FROM Catalog; + SELECT COUNT(*) FROM CatalogBrand; + SELECT COUNT(*) FROM CatalogType; + ``` + +### Database Rollback Strategy + +If Flyway migrations introduced breaking schema changes: + +- **Option A — Flyway undo migrations**: If undo migrations (`U1__*.sql`) are available, run: + ```bash + ./mvnw flyway:undo -Dspring.profiles.active=prod + ``` +- **Option B — Backup restore**: Restore from the pre-cutover backup: + ```sql + RESTORE DATABASE CatalogDb FROM DISK = '/backups/CatalogDb_pre_cutover.bak' WITH REPLACE + ``` + +Both options require restarting the legacy application afterward. + +--- + +## Cutover Procedure + +### Phase 1: Deploy to Production VM + +1. Trigger the GitHub Actions pipeline by merging to `main` or confirm the production deployment job has the correct image tag. + +2. The pipeline (with manual approval gate on the `production` environment) will: + - SSH into the production VM + - Pull the Docker image from GHCR + - Stop the old container and start the new one + + Manual equivalent: + ```bash + ssh azureuser@ + docker pull ghcr.io//eshop-catalog: + docker stop eshop-catalog || true + docker rm eshop-catalog || true + docker run -d \ + --name eshop-catalog \ + --restart unless-stopped \ + -p 8080:8080 \ + --env-file /opt/app/production.env \ + ghcr.io//eshop-catalog: + ``` + +3. Verify the application is healthy: + ```bash + curl -sf http://:8080/actuator/health + # Expected: {"status":"UP"} + ``` + +### Phase 2: Traffic Switching + +**Single-VM approach (stop old, start new):** + +1. Stop the legacy .NET application on the production VM (or separate legacy VM). +2. Update DNS A record to point to the Java application's VM IP. +3. Verify DNS propagation: + ```bash + dig eshop-catalog.example.com + curl -sf https://eshop-catalog.example.com/actuator/health + ``` + +**Blue-green approach (two VMs):** + +1. Keep the legacy application running on VM-A. +2. Deploy the Java application to VM-B. +3. Switch DNS from VM-A to VM-B. +4. Monitor for 30–60 minutes. +5. If stable, decommission VM-A after the soak period. + +### Phase 3: Decommission Legacy + +After a soak period (24–48 hours with no issues): + +1. Stop the legacy .NET application container or service. +2. Archive the legacy application artifacts. +3. Keep the legacy Docker image and VM snapshot available for at least 30 days. + +--- + +## Rollback Plan + +### Immediate Rollback (< 5 minutes) + +If critical issues are detected during or immediately after cutover: + +1. **Stop the Java application** on the production VM: + ```bash + ssh azureuser@ 'docker stop eshop-catalog' + ``` + +2. **Restart the legacy .NET application**: + ```bash + # If legacy runs on the same VM: + ssh azureuser@ 'docker start eshop-legacy' + + # If legacy runs on a separate VM (blue-green): + # Switch DNS back to the legacy VM IP + ``` + +3. **Revert DNS** to point to the legacy application's IP: + ```bash + # Update DNS A record back to legacy VM IP + dig eshop-catalog.example.com # Verify propagation + ``` + +4. **Verify** the legacy application is serving traffic: + ```bash + curl -sf https://eshop-catalog.example.com/ + ``` + +### Database Rollback + +See [Database Rollback Strategy](#database-rollback-strategy) above. + +### Post-Rollback Actions + +- Document the issue that triggered the rollback. +- Create a fix branch, apply the fix, re-run the full CI/CD pipeline. +- Re-test on staging before attempting another cutover. +- Notify stakeholders of the rollback and revised timeline. + +--- + +## Monitoring Checklist + +### Key Metrics to Watch During Cutover + +| Metric | Source | Threshold | +|--------|--------|-----------| +| HTTP 5xx error rate | Application logs / Prometheus | < 0.1% | +| P95 response latency | Micrometer / Prometheus | < 500ms | +| JVM heap usage | `/actuator/prometheus` | < 80% of limit | +| Database connection pool | HikariCP metrics | No pool exhaustion | +| Container restart count | `docker inspect` | 0 restarts | +| Flyway migration status | `/actuator/flyway` | All migrations applied | + +### Actuator Endpoints + +| Endpoint | Purpose | +|----------|---------| +| `/actuator/health` | Application health status | +| `/actuator/metrics` | Micrometer metrics | +| `/actuator/prometheus` | Prometheus-format metrics export | +| `/actuator/flyway` | Flyway migration status | + +### Prometheus Integration + +If Prometheus is configured, add the VM as a scrape target: + +```yaml +scrape_configs: + - job_name: 'eshop-catalog' + metrics_path: '/actuator/prometheus' + static_configs: + - targets: [':8080'] +``` + +### First 24 Hours + +- Check dashboards every 30 minutes for the first 4 hours. +- Validate end-to-end functionality (catalog browse, search, CRUD operations). +- Compare response times and error rates against the legacy application baseline. + +--- + +## Communication Plan + +### Before Cutover + +| When | Who | What | +|------|-----|------| +| T-5 days | All stakeholders | Announce cutover window, link to this document | +| T-1 day | Engineering + Ops | Final go/no-go decision based on staging results | +| T-1 hour | On-call team | Confirm readiness, verify monitoring dashboards | + +### During Cutover + +| When | Who | What | +|------|-----|------| +| T+0 | Engineering | Begin cutover, post status to team channel | +| T+15 min | Engineering | Report initial health check results | +| T+1 hour | Engineering | Report stability metrics, confirm or escalate | + +### After Cutover + +| When | Who | What | +|------|-----|------| +| T+4 hours | Engineering | Summary of first 4 hours to stakeholders | +| T+24 hours | Engineering | Confirm soak period passed, declare success or plan remediation | +| T+48 hours | Product Owner | Final sign-off and legacy decommission approval | + +### Rollback Communication + +If rollback is triggered: + +1. Immediately notify the on-call channel: "Rolling back eShop Catalog to legacy .NET — [reason]" +2. Post a follow-up within 1 hour with root cause analysis. +3. Schedule a retrospective within 48 hours. + +--- + +## Success Criteria + +The cutover is considered successful when all of the following are met: + +1. **Functional parity** — all catalog operations (list, search, create, edit, delete) work identically to the legacy application. +2. **Performance** — P95 latency is within 10% of the legacy application's baseline. +3. **Availability** — zero unplanned downtime in the first 48 hours post-cutover. +4. **Error rate** — HTTP 5xx rate stays below 0.1% for 48 hours. +5. **Data integrity** — no data loss or corruption; row counts match pre-cutover values. +6. **Health probes** — `/actuator/health` returns `UP` continuously. +7. **Stakeholder sign-off** — product owner confirms acceptance after the soak period. diff --git a/eShopLegacyMVC-SpringBoot/DEPLOYMENT.md b/eShopLegacyMVC-SpringBoot/DEPLOYMENT.md new file mode 100644 index 00000000..6ac21f9c --- /dev/null +++ b/eShopLegacyMVC-SpringBoot/DEPLOYMENT.md @@ -0,0 +1,225 @@ +# eShop Catalog — Deployment Guide + +This document describes the deployment architecture and procedures for the Spring Boot eShop Catalog application on Azure VMs using Docker containers. + +--- + +## Table of Contents + +1. [Architecture Overview](#architecture-overview) +2. [Pipeline Flow](#pipeline-flow) +3. [GitHub Environments and Secrets](#github-environments-and-secrets) +4. [Azure VM Prerequisites](#azure-vm-prerequisites) +5. [Environment Variable Configuration](#environment-variable-configuration) +6. [Local Development with Docker Compose](#local-development-with-docker-compose) +7. [DNS Configuration](#dns-configuration) +8. [Monitoring and Health Checks](#monitoring-and-health-checks) + +--- + +## Architecture Overview + +``` +┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ +│ Developer │────▶│ GitHub │────▶│ GitHub Container │ +│ Push/PR │ │ Actions │ │ Registry (GHCR) │ +└──────────────┘ └──────┬───────┘ └──────────┬───────────┘ + │ │ + ┌───────▼────────┐ ┌───────▼────────┐ + │ Staging VM │ │ Production VM │ + │ (Docker) │ │ (Docker) │ + │ Auto-deploy │ │ Manual gate │ + └───────┬────────┘ └───────┬────────┘ + │ │ + ┌───────▼────────┐ ┌───────▼────────┐ + │ SQL Server │ │ SQL Server │ + │ (Staging DB) │ │ (Production DB)│ + └────────────────┘ └────────────────┘ +``` + +The application is packaged as a Docker image, pushed to GitHub Container Registry (GHCR), and deployed to Azure VMs via SSH using GitHub Actions. + +--- + +## Pipeline Flow + +The CI/CD pipeline is defined in `.github/workflows/deploy.yml` and runs on push to `main` or `migration/complete-java-migration`: + +| Stage | Description | Trigger | +|-------|-------------|---------| +| **Build & Test** | Compiles with JDK 21, runs `./mvnw clean verify`, checks formatting with Spotless, publishes test results via `dorny/test-reporter` | Every push and PR | +| **Docker Build & Push** | Builds multi-stage Docker image, pushes to GHCR with SHA and branch tags | Push to branch only | +| **Deploy to Staging** | SSHs into staging VM, pulls new image, stops old container, starts new one | After Docker push | +| **Health Check** | Polls `/actuator/health` for `{"status":"UP"}` with retry logic | After staging deploy | +| **Deploy to Production** | Same SSH-based deployment; requires manual approval via GitHub Environment protection rules | After health check passes | + +--- + +## GitHub Environments and Secrets + +### Environments + +Configure these in **Settings > Environments** in the GitHub repository: + +| Environment | Protection Rules | +|-------------|-----------------| +| `staging` | Auto-deploy (no approval required) | +| `production` | Required reviewers (manual approval gate) | + +### Required Secrets + +Configure these in **Settings > Secrets and variables > Actions**: + +| Secret | Description | Example | +|--------|-------------|---------| +| `STAGING_VM_HOST` | Staging VM public IP or hostname | `10.0.1.10` | +| `PROD_VM_HOST` | Production VM public IP or hostname | `10.0.2.10` | +| `VM_USERNAME` | SSH username for both VMs | `azureuser` | +| `VM_SSH_KEY` | SSH private key for VM access | PEM-encoded private key | +| `STAGING_URL` | Staging application base URL | `http://staging.example.com:8080` | +| `PROD_URL` | Production application base URL | `https://eshop.example.com` | + +--- + +## Azure VM Prerequisites + +Each VM (staging and production) must meet the following requirements: + +- **OS**: Ubuntu 22.04 LTS +- **Docker**: Installed and running (see `scripts/setup-vm.sh`) +- **Network Security Group (NSG)** rules: + +| Priority | Direction | Port | Protocol | Source | Purpose | +|----------|-----------|------|----------|--------|---------| +| 100 | Inbound | 22 | TCP | GitHub Actions IPs | SSH for deployments | +| 110 | Inbound | 8080 | TCP | Any (or restricted) | Application HTTP traffic | +| 120 | Inbound | 443 | TCP | Any | HTTPS (if reverse proxy configured) | + +### VM Setup + +Run the idempotent setup script on each fresh VM: + +```bash +# Copy and run on the staging VM +scp scripts/setup-vm.sh azureuser@:~/ +ssh azureuser@ 'chmod +x ~/setup-vm.sh && sudo ~/setup-vm.sh staging' + +# Copy and run on the production VM +scp scripts/setup-vm.sh azureuser@:~/ +ssh azureuser@ 'chmod +x ~/setup-vm.sh && sudo ~/setup-vm.sh production' +``` + +--- + +## Environment Variable Configuration + +The application reads configuration from environment files on each VM: + +### Staging: `/opt/app/staging.env` + +```env +SPRING_PROFILES_ACTIVE=prod +DB_URL=jdbc:sqlserver://:1433;databaseName=CatalogDb;encrypt=true;trustServerCertificate=true +DB_USERNAME= +DB_PASSWORD= +DB_DRIVER=com.microsoft.sqlserver.jdbc.SQLServerDriver +USE_MOCK_DATA=false +``` + +### Production: `/opt/app/production.env` + +```env +SPRING_PROFILES_ACTIVE=prod +DB_URL=jdbc:sqlserver://:1433;databaseName=CatalogDb;encrypt=true;trustServerCertificate=false +DB_USERNAME= +DB_PASSWORD= +DB_DRIVER=com.microsoft.sqlserver.jdbc.SQLServerDriver +USE_MOCK_DATA=false +``` + +The setup script (`scripts/setup-vm.sh`) generates a template env file — fill in actual values before the first deployment. + +--- + +## Local Development with Docker Compose + +Use `docker-compose.yml` for local development with the application and a SQL Server instance: + +```bash +# Start with mock data (default) +docker compose up --build + +# Start with SQL Server database +SPRING_PROFILES_ACTIVE=prod USE_MOCK_DATA=false docker compose up --build + +# Stop and clean up +docker compose down -v +``` + +The Compose file starts: +- **app**: The Spring Boot application on port 8080 +- **sqlserver**: SQL Server 2022 on port 1433 + +--- + +## DNS Configuration + +For production deployments, configure DNS to point to the VM: + +1. **A Record**: Point your domain (e.g., `eshop-catalog.example.com`) to the production VM's public IP. +2. **TTL**: Set a low TTL (60–300 seconds) before cutover to allow fast rollback via DNS. +3. **Reverse Proxy** (optional): Use Nginx or Caddy on the VM to terminate TLS and proxy to port 8080. + +Example Nginx configuration: + +```nginx +server { + listen 443 ssl; + server_name eshop-catalog.example.com; + + ssl_certificate /etc/ssl/certs/eshop.crt; + ssl_certificate_key /etc/ssl/private/eshop.key; + + location / { + proxy_pass http://localhost:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +--- + +## Monitoring and Health Checks + +The application exposes Spring Boot Actuator endpoints: + +| Endpoint | Purpose | +|----------|---------| +| `/actuator/health` | Application health status | +| `/actuator/info` | Application metadata | +| `/actuator/metrics` | Micrometer metrics | +| `/actuator/prometheus` | Prometheus-format metrics export | + +### Docker Health Check + +The Dockerfile includes a built-in health check: + +```dockerfile +HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \ + CMD curl -f http://localhost:8080/actuator/health || exit 1 +``` + +### Prometheus Integration + +To scrape metrics, add the VM as a Prometheus target: + +```yaml +scrape_configs: + - job_name: 'eshop-catalog' + metrics_path: '/actuator/prometheus' + static_configs: + - targets: [':8080'] +``` diff --git a/eShopLegacyMVC-SpringBoot/Dockerfile b/eShopLegacyMVC-SpringBoot/Dockerfile new file mode 100644 index 00000000..417c8863 --- /dev/null +++ b/eShopLegacyMVC-SpringBoot/Dockerfile @@ -0,0 +1,39 @@ +# ---- Build Stage ---- +FROM eclipse-temurin:21-jdk AS build + +WORKDIR /app + +# Copy Maven wrapper and POM first for dependency caching +COPY mvnw mvnw.cmd pom.xml ./ +COPY .mvn .mvn + +RUN chmod +x mvnw && \ + ./mvnw dependency:go-offline -B + +# Copy source code +COPY src src + +# Build the application, skipping tests (tests run in CI) +RUN ./mvnw package -DskipTests -B && \ + mv target/*.jar target/app.jar + +# ---- Runtime Stage ---- +FROM eclipse-temurin:21-jre-alpine + +RUN apk add --no-cache curl && \ + addgroup -S appgroup && adduser -S appuser -G appgroup + +WORKDIR /app + +COPY --from=build /app/target/app.jar app.jar + +RUN chown -R appuser:appgroup /app + +USER appuser + +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \ + CMD curl -f http://localhost:8080/actuator/health || exit 1 + +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/eShopLegacyMVC-SpringBoot/azure-pipelines.yml b/eShopLegacyMVC-SpringBoot/azure-pipelines.yml new file mode 100644 index 00000000..8c4a755b --- /dev/null +++ b/eShopLegacyMVC-SpringBoot/azure-pipelines.yml @@ -0,0 +1,159 @@ +trigger: + branches: + include: + - main + - release/* + +pr: + branches: + include: + - main + +variables: + MAVEN_CACHE_FOLDER: $(Pipeline.Workspace)/.m2/repository + JAVA_VERSION: '21' + SPRING_PROFILES_ACTIVE: 'test' + ACR_NAME: '$(acrName)' + IMAGE_REPOSITORY: '$(imageRepository)' + IMAGE_TAG: '$(Build.BuildId)' + +stages: + - stage: Build + displayName: 'Build & Test' + jobs: + - job: BuildAndTest + pool: + vmImage: 'ubuntu-latest' + steps: + - task: JavaToolInstaller@0 + inputs: + versionSpec: '21' + jdkArchitectureOption: 'x64' + jdkSourceOption: 'PreInstalled' + displayName: 'Set up JDK 21' + + - task: Cache@2 + inputs: + key: 'maven | "$(Agent.OS)" | **/pom.xml' + restoreKeys: | + maven | "$(Agent.OS)" + path: $(MAVEN_CACHE_FOLDER) + displayName: 'Cache Maven dependencies' + + - script: | + cd eShopLegacyMVC-SpringBoot && \ + ./mvnw clean verify \ + -Dmaven.repo.local=$(MAVEN_CACHE_FOLDER) \ + -Dspring.profiles.active=test + displayName: 'Build, test, and verify' + + - script: | + cd eShopLegacyMVC-SpringBoot && \ + ./mvnw spotless:check \ + -Dmaven.repo.local=$(MAVEN_CACHE_FOLDER) + displayName: 'Code format check (Spotless)' + + - task: PublishTestResults@2 + inputs: + testResultsFormat: 'JUnit' + testResultsFiles: '**/target/surefire-reports/*.xml' + mergeTestResults: true + displayName: 'Publish test results' + condition: always() + + - task: PublishCodeCoverageResults@2 + inputs: + summaryFileLocation: '**/target/site/jacoco/jacoco.xml' + displayName: 'Publish JaCoCo coverage report' + condition: always() + + - stage: DockerBuild + displayName: 'Docker Build & Push' + dependsOn: Build + condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main')) + jobs: + - job: DockerBuildPush + pool: + vmImage: 'ubuntu-latest' + steps: + - task: Docker@2 + inputs: + containerRegistry: '$(dockerRegistryServiceConnection)' + repository: '$(IMAGE_REPOSITORY)' + command: 'buildAndPush' + Dockerfile: 'eShopLegacyMVC-SpringBoot/Dockerfile' + buildContext: 'eShopLegacyMVC-SpringBoot' + tags: | + $(IMAGE_TAG) + latest + displayName: 'Build and push Docker image' + + - stage: DeployStaging + displayName: 'Deploy to Staging' + dependsOn: DockerBuild + condition: succeeded() + jobs: + - deployment: DeployStaging + environment: 'staging' + pool: + vmImage: 'ubuntu-latest' + strategy: + runOnce: + deploy: + steps: + - script: | + echo "Deploying $(IMAGE_REPOSITORY):$(IMAGE_TAG) to staging" + displayName: 'Deploy to staging' + # Kubernetes deployment: + # - task: KubernetesManifest@1 + # inputs: + # action: 'deploy' + # connectionType: 'azureResourceManager' + # azureSubscriptionConnection: '$(azureServiceConnection)' + # azureResourceGroup: '$(aksResourceGroup)' + # kubernetesCluster: '$(aksClusterName)' + # namespace: '$(k8sNamespace)' + # manifests: 'eShopLegacyMVC-SpringBoot/k8s/*.yml' + # containers: '$(ACR_NAME).azurecr.io/$(IMAGE_REPOSITORY):$(IMAGE_TAG)' + + - stage: HealthCheck + displayName: 'Staging Health Check' + dependsOn: DeployStaging + condition: succeeded() + jobs: + - job: VerifyHealth + pool: + vmImage: 'ubuntu-latest' + steps: + - script: | + echo "Verifying /actuator/health endpoint on staging" + # curl -sf https://$(stagingUrl)/actuator/health || exit 1 + displayName: 'Verify staging health' + + - stage: DeployProduction + displayName: 'Deploy to Production' + dependsOn: HealthCheck + condition: succeeded() + jobs: + - deployment: DeployProduction + environment: 'production' + pool: + vmImage: 'ubuntu-latest' + strategy: + runOnce: + deploy: + steps: + - script: | + echo "Deploying $(IMAGE_REPOSITORY):$(IMAGE_TAG) to production" + displayName: 'Deploy to production' + # Kubernetes deployment: + # - task: KubernetesManifest@1 + # inputs: + # action: 'deploy' + # connectionType: 'azureResourceManager' + # azureSubscriptionConnection: '$(azureServiceConnection)' + # azureResourceGroup: '$(aksResourceGroup)' + # kubernetesCluster: '$(aksClusterName)' + # namespace: '$(k8sNamespace)' + # manifests: 'eShopLegacyMVC-SpringBoot/k8s/*.yml' + # containers: '$(ACR_NAME).azurecr.io/$(IMAGE_REPOSITORY):$(IMAGE_TAG)' diff --git a/eShopLegacyMVC-SpringBoot/docker-compose.yml b/eShopLegacyMVC-SpringBoot/docker-compose.yml new file mode 100644 index 00000000..e9110a48 --- /dev/null +++ b/eShopLegacyMVC-SpringBoot/docker-compose.yml @@ -0,0 +1,41 @@ +services: + app: + build: + context: . + dockerfile: Dockerfile + ports: + - "8080:8080" + environment: + SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-mock} + DB_URL: jdbc:sqlserver://sqlserver:1433;databaseName=CatalogDb;encrypt=true;trustServerCertificate=true + DB_USERNAME: ${DB_USERNAME:-sa} + DB_PASSWORD: ${DB_PASSWORD:-YourStrong@Passw0rd} + USE_MOCK_DATA: ${USE_MOCK_DATA:-true} + depends_on: + sqlserver: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"] + interval: 30s + timeout: 3s + start_period: 40s + retries: 3 + + sqlserver: + image: mcr.microsoft.com/mssql/server:2022-latest + environment: + ACCEPT_EULA: "Y" + MSSQL_SA_PASSWORD: ${DB_PASSWORD:-YourStrong@Passw0rd} + ports: + - "1433:1433" + volumes: + - sqlserver-data:/var/opt/mssql + healthcheck: + test: /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$$MSSQL_SA_PASSWORD" -C -Q "SELECT 1" -b + interval: 10s + timeout: 5s + start_period: 30s + retries: 5 + +volumes: + sqlserver-data: diff --git a/eShopLegacyMVC-SpringBoot/k8s/configmap.yml b/eShopLegacyMVC-SpringBoot/k8s/configmap.yml new file mode 100644 index 00000000..365fd64c --- /dev/null +++ b/eShopLegacyMVC-SpringBoot/k8s/configmap.yml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: eshop-catalog-config + labels: + app.kubernetes.io/name: eshop-catalog + app.kubernetes.io/part-of: eshop + app.kubernetes.io/managed-by: kubectl +data: + SPRING_PROFILES_ACTIVE: "prod" + DB_URL: "jdbc:sqlserver://sqlserver-service:1433;databaseName=CatalogDb;encrypt=true;trustServerCertificate=true" + USE_MOCK_DATA: "false" + SERVER_PORT: "8080" diff --git a/eShopLegacyMVC-SpringBoot/k8s/deployment.yml b/eShopLegacyMVC-SpringBoot/k8s/deployment.yml new file mode 100644 index 00000000..23f6e921 --- /dev/null +++ b/eShopLegacyMVC-SpringBoot/k8s/deployment.yml @@ -0,0 +1,92 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: eshop-catalog + labels: + app.kubernetes.io/name: eshop-catalog + app.kubernetes.io/part-of: eshop + app.kubernetes.io/managed-by: kubectl + platform/environment: production + platform/team: migration +spec: + replicas: 2 + selector: + matchLabels: + app.kubernetes.io/name: eshop-catalog + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + maxSurge: 1 + template: + metadata: + labels: + app.kubernetes.io/name: eshop-catalog + app.kubernetes.io/part-of: eshop + platform/environment: production + platform/team: migration + spec: + containers: + - name: eshop-catalog + image: eshop-catalog:latest + ports: + - containerPort: 8080 + protocol: TCP + env: + - name: SPRING_PROFILES_ACTIVE + valueFrom: + configMapKeyRef: + name: eshop-catalog-config + key: SPRING_PROFILES_ACTIVE + - name: DB_URL + valueFrom: + configMapKeyRef: + name: eshop-catalog-config + key: DB_URL + - name: DB_USERNAME + valueFrom: + secretKeyRef: + name: eshop-catalog-secret + key: DB_USERNAME + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: eshop-catalog-secret + key: DB_PASSWORD + - name: USE_MOCK_DATA + valueFrom: + configMapKeyRef: + name: eshop-catalog-config + key: USE_MOCK_DATA + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + livenessProbe: + httpGet: + path: /actuator/health/liveness + port: 8080 + initialDelaySeconds: 60 + periodSeconds: 15 + timeoutSeconds: 3 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /actuator/health/readiness + port: 8080 + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + startupProbe: + httpGet: + path: /actuator/health + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 5 + failureThreshold: 20 + restartPolicy: Always + terminationGracePeriodSeconds: 30 diff --git a/eShopLegacyMVC-SpringBoot/k8s/ingress.yml b/eShopLegacyMVC-SpringBoot/k8s/ingress.yml new file mode 100644 index 00000000..2ab86bfb --- /dev/null +++ b/eShopLegacyMVC-SpringBoot/k8s/ingress.yml @@ -0,0 +1,28 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: eshop-catalog + labels: + app.kubernetes.io/name: eshop-catalog + app.kubernetes.io/part-of: eshop + app.kubernetes.io/managed-by: kubectl + annotations: + nginx.ingress.kubernetes.io/rewrite-target: / + nginx.ingress.kubernetes.io/ssl-redirect: "true" +spec: + ingressClassName: nginx + tls: + - hosts: + - eshop-catalog.example.com + secretName: eshop-catalog-tls + rules: + - host: eshop-catalog.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: eshop-catalog + port: + number: 80 diff --git a/eShopLegacyMVC-SpringBoot/k8s/networkpolicy.yml b/eShopLegacyMVC-SpringBoot/k8s/networkpolicy.yml new file mode 100644 index 00000000..29f24b2b --- /dev/null +++ b/eShopLegacyMVC-SpringBoot/k8s/networkpolicy.yml @@ -0,0 +1,25 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: eshop-catalog + labels: + app.kubernetes.io/name: eshop-catalog + app.kubernetes.io/part-of: eshop + app.kubernetes.io/managed-by: kubectl +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: eshop-catalog + policyTypes: + - Ingress + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: ingress-nginx + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: monitoring + ports: + - protocol: TCP + port: 8080 diff --git a/eShopLegacyMVC-SpringBoot/k8s/secret.yml b/eShopLegacyMVC-SpringBoot/k8s/secret.yml new file mode 100644 index 00000000..5ab1284d --- /dev/null +++ b/eShopLegacyMVC-SpringBoot/k8s/secret.yml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Secret +metadata: + name: eshop-catalog-secret + labels: + app.kubernetes.io/name: eshop-catalog + app.kubernetes.io/part-of: eshop + app.kubernetes.io/managed-by: kubectl +type: Opaque +stringData: + DB_USERNAME: "REPLACE_WITH_ACTUAL_USERNAME" + DB_PASSWORD: "REPLACE_WITH_ACTUAL_PASSWORD" diff --git a/eShopLegacyMVC-SpringBoot/k8s/service.yml b/eShopLegacyMVC-SpringBoot/k8s/service.yml new file mode 100644 index 00000000..7c17366c --- /dev/null +++ b/eShopLegacyMVC-SpringBoot/k8s/service.yml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Service +metadata: + name: eshop-catalog + labels: + app.kubernetes.io/name: eshop-catalog + app.kubernetes.io/part-of: eshop + app.kubernetes.io/managed-by: kubectl +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 8080 + protocol: TCP + name: http + selector: + app.kubernetes.io/name: eshop-catalog diff --git a/eShopLegacyMVC-SpringBoot/scripts/setup-vm.sh b/eShopLegacyMVC-SpringBoot/scripts/setup-vm.sh new file mode 100755 index 00000000..aa7c2613 --- /dev/null +++ b/eShopLegacyMVC-SpringBoot/scripts/setup-vm.sh @@ -0,0 +1,90 @@ +#!/bin/bash +# setup-vm.sh — Idempotent script to prepare a fresh Azure VM for deployments. +# Usage: sudo ./setup-vm.sh [staging|production] +# Defaults to 'production' if no argument is provided. +# +# This script: +# 1. Installs Docker (if not already installed) +# 2. Creates application directories +# 3. Generates an environment file template +# +# Tested on Ubuntu 22.04 LTS. + +set -euo pipefail + +ENV_NAME="${1:-production}" +APP_DIR="/opt/app" +ENV_FILE="$APP_DIR/${ENV_NAME}.env" + +echo "=== eShop Catalog VM Setup ===" + +# ── Install Docker ────────────────────────────────────────────── +if command -v docker &>/dev/null; then + echo "[OK] Docker is already installed: $(docker --version)" +else + echo "[*] Installing Docker..." + apt-get update -qq + apt-get install -y -qq ca-certificates curl gnupg + + install -m 0755 -d /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/ubuntu/gpg \ + | gpg --dearmor -o /etc/apt/keyrings/docker.gpg + chmod a+r /etc/apt/keyrings/docker.gpg + + echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ + https://download.docker.com/linux/ubuntu \ + $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \ + | tee /etc/apt/sources.list.d/docker.list > /dev/null + + apt-get update -qq + apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-compose-plugin + + systemctl enable docker + systemctl start docker + echo "[OK] Docker installed: $(docker --version)" +fi + +# Add current sudo user to docker group (if invoked via sudo) +if [ -n "${SUDO_USER:-}" ]; then + if ! groups "$SUDO_USER" | grep -q docker; then + usermod -aG docker "$SUDO_USER" + echo "[OK] Added $SUDO_USER to docker group (re-login required)" + fi +fi + +# ── Create application directories ───────────────────────────── +mkdir -p "$APP_DIR" +chmod 755 "$APP_DIR" +echo "[OK] Application directory: $APP_DIR" + +# ── Generate environment file template ────────────────────────── +if [ ! -f "$ENV_FILE" ]; then + cat > "$ENV_FILE" <<'EOF' +# eShop Catalog — Environment Configuration +# Fill in actual values before the first deployment. + +SPRING_PROFILES_ACTIVE=prod + +# Database connection +DB_URL=jdbc:sqlserver://${DB_HOST}:1433;databaseName=${DB_NAME};encrypt=true;trustServerCertificate=true +DB_USERNAME=${DB_USER} +DB_PASSWORD=${DB_PASSWORD} +DB_DRIVER=com.microsoft.sqlserver.jdbc.SQLServerDriver + +# Application settings +USE_MOCK_DATA=false +EOF + chmod 600 "$ENV_FILE" + echo "[OK] Environment template created: $ENV_FILE" + echo " Edit this file with actual database credentials before deploying." +else + echo "[OK] Environment file already exists: $ENV_FILE (not overwritten)" +fi + +echo "" +echo "=== Setup Complete ===" +echo "Next steps:" +echo " 1. Edit $ENV_FILE with actual database credentials" +echo " 2. Log out and back in for docker group membership to take effect" +echo " 3. Test with: docker run --rm hello-world"