diff --git a/.env.example b/.env.example index 1224ed9..e4b07ea 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,13 @@ DB_URL=jdbc:postgresql://localhost:5432/roleimpact DB_USERNAME=roleimpact DB_PASSWORD=roleimpact CORS_ALLOWED_ORIGIN=http://localhost:5173 +SPRING_PROFILES_ACTIVE=dev +PORT=8080 -# Frontend setting. Leave empty locally to use the Vite /api proxy. +# Production tuning for a small always-on VM. +DB_MAX_POOL_SIZE=5 +DB_MIN_IDLE=0 + +# Leave empty locally to use the Vite /api proxy. On Vercel, set this to the +# public HTTPS backend origin, for example https://api.roleimpact.example. VITE_API_BASE_URL= diff --git a/.gitignore b/.gitignore index b2b8378..e37995b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .env +*.env .idea/ .vscode/ *.iml diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..9a43b52 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,9 @@ +.git +.idea +.vscode +target +*.log +.env +.env.* +HELP.md +Dockerfile* diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..9f14df6 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,19 @@ +FROM --platform=$BUILDPLATFORM maven:3.9-eclipse-temurin-21@sha256:613124833fa6718ded9d655a2ebfab6425818c178f899116b93560b6f1c9ffe9 AS build + +WORKDIR /workspace +COPY pom.xml ./ +COPY src ./src +RUN mvn --batch-mode -DskipTests package + +FROM eclipse-temurin:21-jre-jammy@sha256:eebd356ad7358b7094758e5787a6726f332917cfd56feab6457c56dab895cdbf + +WORKDIR /app +COPY --from=build --chown=10001:10001 /workspace/target/roleimpact-api-*.jar app.jar + +USER 10001:10001 +EXPOSE 8080 + +ENV SPRING_PROFILES_ACTIVE=prod \ + JAVA_TOOL_OPTIONS="-XX:InitialRAMPercentage=20.0 -XX:MaxRAMPercentage=70.0 -XX:+ExitOnOutOfMemoryError" + +ENTRYPOINT ["java", "-jar", "/app/app.jar"] diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml new file mode 100644 index 0000000..e544025 --- /dev/null +++ b/backend/src/main/resources/application-prod.yml @@ -0,0 +1,31 @@ +server: + port: ${PORT:8080} + forward-headers-strategy: framework + shutdown: graceful + +spring: + datasource: + hikari: + maximum-pool-size: ${DB_MAX_POOL_SIZE:5} + minimum-idle: ${DB_MIN_IDLE:0} + connection-timeout: 30000 + validation-timeout: 5000 + max-lifetime: 840000 + lifecycle: + timeout-per-shutdown-phase: 20s + +roleimpact: + cors: + allowed-origin: ${CORS_ALLOWED_ORIGIN} + +bootui: + enabled: false + +management: + endpoint: + health: + show-details: never + endpoints: + web: + exposure: + include: health,info diff --git a/backend/src/test/java/com/roleimpact/config/ProductionConfigurationTest.java b/backend/src/test/java/com/roleimpact/config/ProductionConfigurationTest.java new file mode 100644 index 0000000..357438d --- /dev/null +++ b/backend/src/test/java/com/roleimpact/config/ProductionConfigurationTest.java @@ -0,0 +1,37 @@ +package com.roleimpact.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.env.YamlPropertySourceLoader; +import org.springframework.core.io.ClassPathResource; + +class ProductionConfigurationTest { + + @Test + void productionProfileUsesPlatformPortAndKeepsDiagnosticsPrivate() throws IOException { + var resource = new ClassPathResource("application-prod.yml"); + assertThat(resource.exists()).isTrue(); + + var propertySource = new YamlPropertySourceLoader() + .load("production", resource) + .getFirst(); + + assertThat(propertySource.getProperty("server.port")).isEqualTo("${PORT:8080}"); + assertThat(propertySource.getProperty("server.forward-headers-strategy")).isEqualTo("framework"); + assertThat(propertySource.getProperty("server.shutdown")).isEqualTo("graceful"); + assertThat(propertySource.getProperty("bootui.enabled")).isEqualTo(false); + assertThat(propertySource.getProperty("management.endpoints.web.exposure.include")) + .isEqualTo("health,info"); + assertThat(propertySource.getProperty("management.endpoint.health.show-details")) + .isEqualTo("never"); + assertThat(propertySource.getProperty("roleimpact.cors.allowed-origin")) + .isEqualTo("${CORS_ALLOWED_ORIGIN}"); + assertThat(propertySource.getProperty("spring.datasource.hikari.maximum-pool-size")) + .isEqualTo("${DB_MAX_POOL_SIZE:5}"); + assertThat(propertySource.getProperty("spring.datasource.hikari.minimum-idle")) + .isEqualTo("${DB_MIN_IDLE:0}"); + } +} diff --git a/docs/production-deployment.md b/docs/production-deployment.md new file mode 100644 index 0000000..e73b019 --- /dev/null +++ b/docs/production-deployment.md @@ -0,0 +1,82 @@ +# Production deployment runbook + +RoleImpact uses three managed production services: + +- the React/Vite frontend runs on Vercel; +- the Spring Boot API runs as a Docker web service on Render; +- PostgreSQL runs in the dedicated `RoleImpact Production` Neon project. + +The repository-root `render.yaml` is the source of truth for the API service. Render builds `backend/Dockerfile`, provides HTTPS, monitors `/actuator/health`, and deploys reviewed changes from `main`. + +## 1. Merge the deployment configuration + +Before creating the service, confirm `render.yaml` exists on the repository's `main` branch and required CI checks pass. Do not deploy an unreviewed feature branch as production. + +## 2. Create the Render service + +In the Render dashboard, create a Blueprint from the RoleImpact GitHub repository and select **My Workspace**. Review the generated `roleimpact-api` web service before applying it: + +- runtime: Docker; +- plan: Free; +- region: Oregon, colocated with the Neon US West database; +- branch: `main`; +- health check: `/actuator/health`; +- auto-deploy: enabled. + +Do not create a Render PostgreSQL database. Production data remains in Neon. + +## 3. Configure secrets + +Enter the following values only in the Render dashboard when the Blueprint requests them: + +- `DB_URL`: the Neon JDBC URL with `sslmode=require`; +- `DB_USERNAME`: the Neon database role; +- `DB_PASSWORD`: the Neon database password; +- `CORS_ALLOWED_ORIGIN`: the exact Vercel production origin, including `https://` and no trailing slash. + +The Blueprint supplies the remaining non-secret settings, including the production Spring profile, conservative database-pool limits, and Java memory limits for the Free instance. Never put production database credentials in Git, documentation, build arguments, issues, or logs. + +## 4. Verify the first API deployment + +Watch the Render deploy until its status is **Live**. On the first successful start, Flyway applies the committed migrations to the Neon production database. + +Check the public health endpoint: + +```text +https://.onrender.com/actuator/health +``` + +It must return HTTP 200 with status `UP`. Review Render logs for Flyway errors, failed database authentication, out-of-memory exits, or repeated health-check failures before connecting the frontend. + +## 5. Connect Vercel + +In the Vercel production environment, set: + +```text +VITE_API_BASE_URL=https://.onrender.com +``` + +Redeploy the frontend after saving the variable. Confirm the API's `CORS_ALLOWED_ORIGIN` exactly matches the final Vercel production URL. + +## 6. Production smoke test + +From the deployed frontend: + +1. create a small organization; +2. copy its organization code and reopen it from the landing page; +3. refresh the organization map URL and confirm the same organization loads; +4. create or assign a role and confirm its member connections persist; +5. run an impact test and inspect its recommendation explanation; +6. refresh the impact route and confirm route recovery works. + +Also recheck `/actuator/health` and scan recent Render error logs after the smoke test. + +## 7. Updates and rollback + +Merging a reviewed change into `main` triggers an automatic Render deployment. Render keeps the previous deployment serving traffic until the new version becomes healthy. + +If a release fails, roll back to the most recent healthy deployment in the Render dashboard. Database migrations are forward-only: correct a faulty migration with a reviewed follow-up Flyway migration instead of manually changing production tables. + +## Free-plan behavior + +The Render Free service can spin down after inactivity. Its first request after an idle period can take longer while the service starts. Before a portfolio demonstration, open the production site once and wait for the API health check to return `UP`. diff --git a/frontend/vercel.json b/frontend/vercel.json new file mode 100644 index 0000000..33da267 --- /dev/null +++ b/frontend/vercel.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "rewrites": [ + { "source": "/:path*", "destination": "/index.html" } + ] +} diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..9ea39fb --- /dev/null +++ b/render.yaml @@ -0,0 +1,31 @@ +services: + - type: web + name: roleimpact-api + runtime: docker + plan: free + region: oregon + branch: main + autoDeploy: true + dockerfilePath: ./backend/Dockerfile + dockerContext: ./backend + healthCheckPath: /actuator/health + envVars: + - key: SPRING_PROFILES_ACTIVE + value: prod + - key: DB_URL + sync: false + - key: DB_USERNAME + sync: false + - key: DB_PASSWORD + sync: false + - key: CORS_ALLOWED_ORIGIN + sync: false + - key: DB_MAX_POOL_SIZE + value: "3" + - key: DB_MIN_IDLE + value: "0" + - key: JAVA_TOOL_OPTIONS + value: -XX:InitialRAMPercentage=10.0 -XX:MaxRAMPercentage=60.0 -XX:MaxMetaspaceSize=128m -XX:+ExitOnOutOfMemoryError + +previews: + generation: none diff --git a/tools/validate-production-deployment.ps1 b/tools/validate-production-deployment.ps1 new file mode 100644 index 0000000..bb0906c --- /dev/null +++ b/tools/validate-production-deployment.ps1 @@ -0,0 +1,88 @@ +[CmdletBinding()] +param( + [string]$RepositoryRoot +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if ([string]::IsNullOrWhiteSpace($RepositoryRoot)) { + $RepositoryRoot = Split-Path -Parent $PSScriptRoot +} + +$failures = [System.Collections.Generic.List[string]]::new() + +function Require-Path { + param([string]$Path) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + $script:failures.Add("Missing required file: $Path") + } +} + +function Require-Text { + param( + [string]$Content, + [string]$Pattern, + [string]$Description + ) + + if ($Content -notmatch $Pattern) { + $script:failures.Add("Missing contract: $Description") + } +} + +$renderPath = Join-Path $RepositoryRoot 'render.yaml' +$dockerfilePath = Join-Path $RepositoryRoot 'backend/Dockerfile' +$prodConfigPath = Join-Path $RepositoryRoot 'backend/src/main/resources/application-prod.yml' +$vercelPath = Join-Path $RepositoryRoot 'frontend/vercel.json' +$runbookPath = Join-Path $RepositoryRoot 'docs/production-deployment.md' + +@($renderPath, $dockerfilePath, $prodConfigPath, $vercelPath, $runbookPath) | ForEach-Object { Require-Path $_ } + +if ($failures.Count -eq 0) { + $render = Get-Content -Raw -LiteralPath $renderPath + $dockerfile = Get-Content -Raw -LiteralPath $dockerfilePath + $prodConfig = Get-Content -Raw -LiteralPath $prodConfigPath + $vercel = Get-Content -Raw -LiteralPath $vercelPath + $runbook = Get-Content -Raw -LiteralPath $runbookPath + + Require-Text $render '(?m)^\s*- type: web\r?$' 'a Render web service' + Require-Text $render '(?m)^\s+name: roleimpact-api\r?$' 'the production API service name' + Require-Text $render '(?m)^\s+runtime: docker\r?$' 'the Docker runtime' + Require-Text $render '(?m)^\s+plan: free\r?$' 'the Free service plan' + Require-Text $render '(?m)^\s+region: oregon\r?$' 'the Neon-adjacent Oregon region' + Require-Text $render '(?m)^\s+branch: main\r?$' 'main as the production branch' + Require-Text $render '(?m)^\s+autoDeploy: true\r?$' 'automatic reviewed deployments' + Require-Text $render 'dockerfilePath:\s*\./backend/Dockerfile' 'the backend Dockerfile path' + Require-Text $render 'dockerContext:\s*\./backend' 'the backend Docker build context' + Require-Text $render 'healthCheckPath:\s*/actuator/health' 'the API health check' + + @('DB_URL', 'DB_USERNAME', 'DB_PASSWORD', 'CORS_ALLOWED_ORIGIN') | ForEach-Object { + $secretPattern = '(?ms)- key:\s*' + [regex]::Escape($_) + '\s+sync:\s*false' + Require-Text $render $secretPattern "dashboard-managed secret $_" + } + + Require-Text $dockerfile '(?m)^FROM .* AS build\r?$' 'a multi-stage container build' + Require-Text $dockerfile '(?m)^USER 10001:10001\r?$' 'a non-root runtime user' + Require-Text $dockerfile 'ENTRYPOINT \["java", "-jar", "/app/app.jar"\]' 'the API container entry point' + + Require-Text $prodConfig 'port:\s*\$\{PORT:8080\}' 'Render PORT binding' + Require-Text $prodConfig 'allowed-origin:\s*\$\{CORS_ALLOWED_ORIGIN\}' 'production CORS isolation' + Require-Text $prodConfig '(?ms)bootui:\s+enabled:\s*false' 'disabled production BootUI' + Require-Text $prodConfig '(?ms)include:\s*health,info' 'minimal actuator exposure' + + Require-Text $vercel '"source":\s*"/:path\*"' 'Vercel SPA route matching' + Require-Text $vercel '"destination":\s*"/index.html"' 'Vercel SPA fallback' + + @('Render', 'Vercel', 'Neon', 'Flyway', 'DB_URL', 'CORS_ALLOWED_ORIGIN', '/actuator/health', 'rollback') | ForEach-Object { + Require-Text $runbook ("(?i)" + [regex]::Escape($_)) "runbook coverage for $_" + } +} + +if ($failures.Count -gt 0) { + Write-Error ("Production deployment validation failed:`n - " + ($failures -join "`n - ")) + exit 1 +} + +Write-Host 'Production deployment validation passed.'