diff --git a/README.md b/README.md index 155e7ab..4fdb78a 100644 --- a/README.md +++ b/README.md @@ -481,19 +481,78 @@ flowchart LR ## 🛠️ Useful Commands +### Local Development + ```bash +# Run a service with hot-reload (cargo-watch required) +cargo dev-auth # auth-service :3001 +cargo dev-urls # url-service :3002 +cargo dev-consumer # consumer-service + +# Run tests per service +cargo test-auth # auth-service tests (uses localhost:5432) +cargo test-urls # url-service tests (uses localhost:5433) + +# Or use the convenience script (handles DATABASE_URL switching) +./scripts/test.sh # all services +./scripts/test.sh auth # auth-service only +./scripts/test.sh url # url-service only + # Wipe databases and restart fresh docker compose down -v && docker compose up -d -# Run full local CI checks (same as GitHub Actions) -./scripts/check.sh +# Production build +cargo build --release +``` + +### Kubernetes -# API endpoint smoke tests -./scripts/api-test.sh +```bash +# Full cluster lifecycle +./scripts/k8s.sh up # create kind cluster, install CNPG operator, load images, apply manifests +./scripts/k8s.sh deploy # rebuild images + re-apply manifests on an existing cluster +./scripts/k8s.sh status # show nodes, pods, and services +./scripts/k8s.sh down # tear down the kind cluster -# Load test (k6 via kubectl inside cluster) +# Load test (k6 — runs inside the cluster via kubectl) ./scripts/load-test.sh -# Production build -cargo build --release +# Port-forward internal services for local inspection +kubectl port-forward -n bittuly svc/rabbitmq 15672:15672 # RabbitMQ management UI +kubectl port-forward -n bittuly svc/jaeger 16686:16686 # Jaeger trace UI + +# Delete the legacy standalone Postgres pods (superseded by CloudNativePG) +kubectl delete deployment postgres-auth postgres-urls -n bittuly ``` + +### Database (CloudNativePG) + +```bash +# Connect to auth primary +kubectl exec -it postgres-auth-1 -n bittuly -- psql -U postgres -d bittuly_auth + +# Connect to urls primary +kubectl exec -it postgres-urls-1 -n bittuly -- psql -U postgres -d bittuly_urls + +# Check cluster health +kubectl get clusters -n bittuly +``` + +--- + +## 🪝 Git Hooks + +Two local git hooks are provided in `scripts/` and must be installed manually once: + +```bash +# Install hooks +cp scripts/pre-commit.sh .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit +cp scripts/pre-push.sh .git/hooks/pre-push && chmod +x .git/hooks/pre-push +``` + +| Hook | Trigger | What it does | +|---|---|---| +| `pre-commit` | Every `git commit` | Runs `cargo fmt --all` and re-stages any formatted files automatically | +| `pre-push` | Every `git push` | Runs the full local CI suite: `cargo fmt`, `cargo clippy`, `cargo test`, `npm typecheck`, `npm build` | + +> **Tip:** The pre-push hook runs the same checks as the GitHub Actions CI pipeline, so you catch failures locally before they hit the remote. diff --git a/k8s/base/configmap.yaml b/k8s/base/configmap.yaml index 236d2e8..08ead6d 100644 --- a/k8s/base/configmap.yaml +++ b/k8s/base/configmap.yaml @@ -5,21 +5,30 @@ metadata: namespace: bittuly data: # Shared - MODE: "development" + MODE: "production" RUST_LOG: "auth_service=info,url_service=info,consumer_service=info,shared=info" # Auth service AUTH_HOST: "0.0.0.0" AUTH_PORT: "3001" - AUTH_DATABASE_URL: "postgres://bittu:bittu@postgres-auth:5432/bittuly_auth" + # -rw → always the CNPG primary (handles writes + reads when replica is down) + AUTH_DATABASE_URL: "postgres://bittu:bittu@postgres-auth-rw:5432/bittuly_auth" + # -ro → load-balanced across replicas (read-only queries) + AUTH_DATABASE_READ_URL: "postgres://bittu:bittu@postgres-auth-ro:5432/bittuly_auth" CORS_ORIGIN: "http://localhost:8000" # URL service URL_HOST: "0.0.0.0" URL_PORT: "3002" - URL_DATABASE_URL: "postgres://bittu:bittu@postgres-urls:5432/bittuly_urls" + # -rw → always the CNPG primary + URL_DATABASE_URL: "postgres://bittu:bittu@postgres-urls-rw:5432/bittuly_urls" + # -ro → load-balanced across replicas + URL_DATABASE_READ_URL: "postgres://bittu:bittu@postgres-urls-ro:5432/bittuly_urls" REDIS_URL: "redis://redis:6379" + # Consumer service — writes only, always use primary + # URL_DATABASE_URL is re-used (same var, primary only) + # Shared infrastructure RABBITMQ_URL: "amqp://bittu:bittu@rabbitmq:5672" diff --git a/k8s/base/postgres-auth/postgres-auth-cluster.yaml b/k8s/base/postgres-auth/postgres-auth-cluster.yaml new file mode 100644 index 0000000..2ae1d66 --- /dev/null +++ b/k8s/base/postgres-auth/postgres-auth-cluster.yaml @@ -0,0 +1,59 @@ +apiVersion: postgresql.cnpg.io/v1 +kind: Cluster +metadata: + name: postgres-auth + namespace: bittuly +spec: + instances: 2 # 1 primary + 1 read replica + + # PostgreSQL version + imageName: ghcr.io/cloudnative-pg/postgresql:17 + + # Storage for each instance + storage: + size: 1Gi + + # App credentials — CNPG creates a Secret named postgres-auth-app + # with keys: username, password, uri, jdbc-uri, pgpass + bootstrap: + initdb: + database: bittuly_auth + owner: bittu + secret: + name: bittuly-secrets # key: POSTGRES_AUTH_PASSWORD + postInitApplicationSQL: + - "CREATE EXTENSION IF NOT EXISTS pgcrypto" + - | + CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + + # Connection pooler on top of replicas (read-only) + # CNPG auto-creates two Services: + # postgres-auth-rw → always the primary (writes) + # postgres-auth-ro → all replicas (reads) + # postgres-auth-r → all instances (any) + + # Resource limits (suitable for local Kind cluster) + resources: + requests: + memory: "256Mi" + cpu: "250m" + limits: + memory: "512Mi" + cpu: "500m" + + # Promote the first replica automatically if the primary fails + failoverDelay: 0 + + # PostgreSQL tuning + postgresql: + parameters: + max_connections: "100" + shared_buffers: "128MB" + effective_cache_size: "256MB" diff --git a/k8s/base/postgres-urls/postgres-urls-cluster.yaml b/k8s/base/postgres-urls/postgres-urls-cluster.yaml new file mode 100644 index 0000000..be6f3d3 --- /dev/null +++ b/k8s/base/postgres-urls/postgres-urls-cluster.yaml @@ -0,0 +1,59 @@ +apiVersion: postgresql.cnpg.io/v1 +kind: Cluster +metadata: + name: postgres-urls + namespace: bittuly +spec: + instances: 2 # 1 primary + 1 read replica + + imageName: ghcr.io/cloudnative-pg/postgresql:17 + + storage: + size: 1Gi + + bootstrap: + initdb: + database: bittuly_urls + owner: bittu + secret: + name: bittuly-secrets # key: POSTGRES_URLS_PASSWORD + postInitApplicationSQL: + - "CREATE EXTENSION IF NOT EXISTS pgcrypto" + - "CREATE EXTENSION IF NOT EXISTS pg_trgm" + - | + CREATE TABLE IF NOT EXISTS urls ( + url_id BIGSERIAL PRIMARY KEY, + short_code TEXT UNIQUE, + original_url TEXT NOT NULL, + user_id UUID NOT NULL, + click_count BIGINT NOT NULL DEFAULT 0, + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(original_url, user_id) + ) + - "CREATE INDEX IF NOT EXISTS idx_urls_user_id ON urls(user_id, url_id DESC)" + - "CREATE INDEX IF NOT EXISTS idx_urls_short_code ON urls(short_code)" + - "CREATE INDEX IF NOT EXISTS idx_urls_original_url_trgm ON urls USING GIN (original_url gin_trgm_ops)" + - "CREATE INDEX IF NOT EXISTS idx_urls_short_code_trgm ON urls USING GIN (short_code gin_trgm_ops)" + - "CREATE INDEX IF NOT EXISTS idx_urls_expires_at ON urls(expires_at)" + + # CNPG auto-creates: + # postgres-urls-rw → primary (writes) + # postgres-urls-ro → replicas (reads) + + resources: + requests: + memory: "256Mi" + cpu: "250m" + limits: + memory: "512Mi" + cpu: "500m" + + failoverDelay: 0 + + postgresql: + parameters: + max_connections: "100" + shared_buffers: "128MB" + effective_cache_size: "256MB" diff --git a/k8s/base/secret.yaml b/k8s/base/secret.yaml index d67874d..027a268 100644 --- a/k8s/base/secret.yaml +++ b/k8s/base/secret.yaml @@ -6,7 +6,7 @@ metadata: type: Opaque stringData: # JWT — replace with `openssl rand -base64 32` for production - JWT_SECRET: "this-is-a-production-distributed-url-shortener" + JWT_SECRET: "zXoP9/N+yM2rG7K4lWfJ8QeA1D3b5c6vT0mHqYhI=" # SMTP (only used when MODE=production) SMTP_USER: "bitweb26@gmail.com" diff --git a/libs/shared/src/config.rs b/libs/shared/src/config.rs index 20396ca..f0b9cc3 100644 --- a/libs/shared/src/config.rs +++ b/libs/shared/src/config.rs @@ -2,6 +2,8 @@ use std::env; pub struct AuthConfig { pub database_url: String, + /// Read-replica URL. Falls back to `database_url` if not set (e.g. docker-compose). + pub database_read_url: String, pub server_port: u16, pub mode: String, pub server_addr: String, @@ -10,6 +12,8 @@ pub struct AuthConfig { pub struct UrlConfig { pub database_url: String, + /// Read-replica URL. Falls back to `database_url` if not set (e.g. docker-compose). + pub database_read_url: String, pub redis_url: String, pub server_port: u16, pub server_addr: String, @@ -28,10 +32,12 @@ impl AuthConfig { Ok(Self { database_url: env::var("AUTH_DATABASE_URL")?, + database_read_url: env::var("AUTH_DATABASE_READ_URL").unwrap_or_else(|_| { + env::var("AUTH_DATABASE_URL").expect("AUTH_DATABASE_URL must be set") + }), server_port, server_addr: env::var("AUTH_HOST").unwrap_or_else(|_| "0.0.0.0".to_owned()), mode: env::var("MODE").unwrap_or_else(|_| "production".to_owned()), - // Default to the NGINX gateway port — do not default to a raw Vite port. cors_origin: env::var("CORS_ORIGIN") .unwrap_or_else(|_| "http://localhost:8000".to_owned()), }) @@ -49,12 +55,14 @@ impl UrlConfig { Ok(Self { database_url: env::var("URL_DATABASE_URL")?, + database_read_url: env::var("URL_DATABASE_READ_URL").unwrap_or_else(|_| { + env::var("URL_DATABASE_URL").expect("URL_DATABASE_URL must be set") + }), redis_url: env::var("REDIS_URL") .unwrap_or_else(|_| "redis://127.0.0.1:6379".to_owned()), server_port, server_addr: env::var("URL_HOST").unwrap_or_else(|_| "0.0.0.0".to_owned()), mode: env::var("MODE").unwrap_or_else(|_| "production".to_owned()), - // Default to the NGINX gateway port — do not default to a raw Vite port. cors_origin: env::var("CORS_ORIGIN") .unwrap_or_else(|_| "http://localhost:8000".to_owned()), }) diff --git a/scripts/k8s.sh b/scripts/k8s.sh index 4012c03..e952b94 100755 --- a/scripts/k8s.sh +++ b/scripts/k8s.sh @@ -49,6 +49,13 @@ cmd_up() { --for=condition=available deployment/ingress-nginx-controller \ --timeout=120s + log "Installing CloudNativePG operator..." + kubectl apply --server-side -f \ + https://raw.githubusercontent.com/cloudnative-pg/cloudnative-pg/release-1.25/releases/cnpg-1.25.0.yaml + kubectl wait --namespace cnpg-system \ + --for=condition=available deployment/cnpg-controller-manager \ + --timeout=180s + log "Building and deploying all services..." cmd_deploy } @@ -68,8 +75,13 @@ cmd_deploy() { kind load docker-image bittuly/frontend-service:local --name "${CLUSTER_NAME}" log "Pre-loading third-party dependencies from local Docker cache to speed up boot..." - for img in postgres:17-alpine redis:7-alpine rabbitmq:3-management jaegertracing/all-in-one:latest; do - docker pull "$img" >/dev/null 2>&1 || true + # Include CNPG postgres image used by the Cluster CRDs + for img in \ + ghcr.io/cloudnative-pg/postgresql:17 \ + redis:7-alpine \ + rabbitmq:3-management \ + jaegertracing/all-in-one:latest; do + docker pull "$img" > /dev/null 2>&1 || true kind load docker-image "$img" --name "${CLUSTER_NAME}" 2>/dev/null || true done @@ -77,17 +89,20 @@ cmd_deploy() { kubectl apply -f k8s/base/namespace.yaml kubectl apply -f k8s/base/configmap.yaml kubectl apply -f k8s/base/secret.yaml - kubectl apply -f k8s/base/postgres-auth/init-configmap.yaml - kubectl apply -f k8s/base/postgres-auth/postgres-auth.yaml - kubectl apply -f k8s/base/postgres-urls/init-configmap.yaml - kubectl apply -f k8s/base/postgres-urls/postgres-urls.yaml + # CNPG Cluster CRDs — schema is embedded in the CRD (no separate init-configmap needed) + kubectl apply -f k8s/base/postgres-auth/postgres-auth-cluster.yaml + kubectl apply -f k8s/base/postgres-urls/postgres-urls-cluster.yaml kubectl apply -f k8s/base/redis/redis.yaml kubectl apply -f k8s/base/rabbitmq/rabbitmq.yaml kubectl apply -f k8s/base/jaeger/jaeger.yaml - log "Waiting for databases to be ready..." - kubectl wait -n "${NAMESPACE}" --for=condition=available deployment/postgres-auth --timeout=300s - kubectl wait -n "${NAMESPACE}" --for=condition=available deployment/postgres-urls --timeout=300s + log "Waiting for CNPG database clusters to be Ready (primary + replica each)..." + kubectl wait -n "${NAMESPACE}" \ + --for=condition=Ready cluster/postgres-auth \ + --timeout=300s + kubectl wait -n "${NAMESPACE}" \ + --for=condition=Ready cluster/postgres-urls \ + --timeout=300s kubectl apply -f k8s/base/auth-service/auth-service.yaml kubectl apply -f k8s/base/url-service/url-service.yaml diff --git a/services/auth-service/src/handlers.rs b/services/auth-service/src/handlers.rs index e597e10..03d79c1 100644 --- a/services/auth-service/src/handlers.rs +++ b/services/auth-service/src/handlers.rs @@ -36,7 +36,8 @@ pub async fn login( if let Err(errors) = payload.validate() { return validation_error_response(errors).into_response(); } - match user_service::login(&*state.repo, &payload.email, &payload.password).await { + // Route to read replica — login is a SELECT by email, safe for replicas. + match user_service::login(&*state.read_repo, &payload.email, &payload.password).await { Ok(auth) => { let mut response = (StatusCode::OK, Json(auth.user)).into_response(); if let Err(e) = set_token_cookies(&mut response, &auth.token, &auth.refresh_token) { @@ -83,7 +84,8 @@ pub async fn get_user_by_id( return StatusCode::FORBIDDEN.into_response(); } - match user_service::get_user_by_id(&*state.repo, user_id).await { + // Route to read replica — SELECT by id (PK), safe for replicas. + match user_service::get_user_by_id(&*state.read_repo, user_id).await { Ok(Some(user)) => (StatusCode::OK, Json(user)).into_response(), Ok(None) => ( StatusCode::NOT_FOUND, diff --git a/services/auth-service/src/main.rs b/services/auth-service/src/main.rs index e90e341..f0f2e7f 100644 --- a/services/auth-service/src/main.rs +++ b/services/auth-service/src/main.rs @@ -18,7 +18,10 @@ async fn main() { let db = postgres::init_pg_pool(&settings.database_url) .await - .expect("Failed to connect to Database"); + .expect("Failed to connect to primary database"); + let read_db = postgres::init_pg_pool(&settings.database_read_url) + .await + .expect("Failed to connect to read-replica database"); let rabbitmq = shared::rabbitmq::init_rabbitmq_pool(&amqp_url) .await @@ -29,7 +32,13 @@ async fn main() { .expect("Failed to install Prometheus recorder"); let pg_repo = Arc::new(repository::PgUserRepo(db)); - let auth_state = Arc::new(models::AuthState::new(pg_repo, rabbitmq, prometheus_handle)); + let read_pg_repo = Arc::new(repository::PgUserRepo(read_db)); + let auth_state = Arc::new(models::AuthState::new( + pg_repo, + read_pg_repo, + rabbitmq, + prometheus_handle, + )); let cors = CorsLayer::new() .allow_origin( diff --git a/services/auth-service/src/models.rs b/services/auth-service/src/models.rs index 945bcd1..2124580 100644 --- a/services/auth-service/src/models.rs +++ b/services/auth-service/src/models.rs @@ -11,7 +11,10 @@ use uuid::Uuid; use validator::Validate; pub struct AuthState { + /// Write pool — always the CNPG primary. pub repo: Arc, + /// Read pool — CNPG replica(s). Falls back to primary if replicas unavailable. + pub read_repo: Arc, pub rabbitmq: shared::deadpool_lapin::Pool, #[allow(dead_code)] pub started_at: Instant, @@ -22,11 +25,13 @@ pub struct AuthState { impl AuthState { pub fn new( repo: Arc, + read_repo: Arc, rabbitmq: shared::deadpool_lapin::Pool, prometheus_handle: PrometheusHandle, ) -> Self { Self { repo, + read_repo, rabbitmq, started_at: Instant::now(), prometheus_handle, diff --git a/services/auth-service/tests/api_tests.rs b/services/auth-service/tests/api_tests.rs index 8de1353..cc9ca49 100644 --- a/services/auth-service/tests/api_tests.rs +++ b/services/auth-service/tests/api_tests.rs @@ -28,7 +28,12 @@ async fn setup_app() -> TestServer { }) .clone(); - let auth_state = Arc::new(AuthState::new(repo, rabbitmq, prometheus_handle)); + let auth_state = Arc::new(AuthState::new( + repo.clone(), + repo, + rabbitmq, + prometheus_handle, + )); let app = auth_routes().with_state(auth_state); TestServer::new(app) diff --git a/services/url-service/src/handlers.rs b/services/url-service/src/handlers.rs index f6cb06e..6077a8a 100644 --- a/services/url-service/src/handlers.rs +++ b/services/url-service/src/handlers.rs @@ -50,7 +50,8 @@ pub async fn get_all_urls( let limit = params.limit.unwrap_or(DEFAULT_PAGE_LIMIT); let search = params.search.filter(|s| !s.trim().is_empty()); - match url_service::get_urls_page(&*state.repo, claims.sub, cursor, limit, search).await { + // Route to read replica — this is a pure SELECT, safe for replicas. + match url_service::get_urls_page(&*state.read_repo, claims.sub, cursor, limit, search).await { Ok(page) => ( StatusCode::OK, Json(UrlsPageResponse { @@ -131,9 +132,9 @@ pub async fn get_original_url( return Ok::<_, axum::http::StatusCode>(Some((original_url, None))); } - // 2. Try DB + // Route to read replica — this is a pure SELECT by short_code. metrics::counter!("cache_misses").increment(1); - match url_service::get_original_url(&*state.repo, &short_code).await { + match url_service::get_original_url(&*state.read_repo, &short_code).await { Ok(Some((original_url, expires_at))) => { // 3. Update Redis cache in background let original_url_clone = original_url.clone(); diff --git a/services/url-service/src/main.rs b/services/url-service/src/main.rs index 611652a..8b96108 100644 --- a/services/url-service/src/main.rs +++ b/services/url-service/src/main.rs @@ -17,7 +17,10 @@ async fn main() { let settings = config::UrlConfig::from_env().expect("Failed to load setting from environment"); let db = postgres::init_pg_pool(&settings.database_url) .await - .expect("Failed to connect to Database"); + .expect("Failed to connect to primary database"); + let read_db = postgres::init_pg_pool(&settings.database_read_url) + .await + .expect("Failed to connect to read-replica database"); let redis = redis::init_redis(&settings.redis_url) .await .expect("Failed to connect to Redis"); @@ -32,10 +35,12 @@ async fn main() { .expect("Failed to install Prometheus recorder"); let pg_repo = Arc::new(repository::PgUrlRepo(db)); + let read_pg_repo = Arc::new(repository::PgUrlRepo(read_db)); let url_state = Arc::new(models::UrlState::new( rabbitmq, redis, pg_repo, + read_pg_repo, settings.cors_origin.clone(), prometheus_handle, )); diff --git a/services/url-service/src/models.rs b/services/url-service/src/models.rs index 018d20c..61a9286 100644 --- a/services/url-service/src/models.rs +++ b/services/url-service/src/models.rs @@ -16,7 +16,10 @@ pub type CachedUrlResult = Option<(String, Option>)>; pub struct UrlState { pub rabbitmq: shared::deadpool_lapin::Pool, + /// Write pool — always the CNPG primary. pub repo: Arc, + /// Read pool — CNPG replica(s). Falls back to primary if replicas unavailable. + pub read_repo: Arc, pub redis: RedisConn, #[allow(dead_code)] pub started_at: Instant, @@ -31,6 +34,7 @@ impl UrlState { rabbitmq: shared::deadpool_lapin::Pool, redis: RedisConn, repo: Arc, + read_repo: Arc, cors_origin: String, prometheus_handle: PrometheusHandle, ) -> Self { @@ -42,6 +46,7 @@ impl UrlState { Self { rabbitmq, repo, + read_repo, redis, started_at: Instant::now(), cors_origin, diff --git a/services/url-service/tests/api_tests.rs b/services/url-service/tests/api_tests.rs index d42e1da..13b5cdb 100644 --- a/services/url-service/tests/api_tests.rs +++ b/services/url-service/tests/api_tests.rs @@ -36,6 +36,7 @@ async fn setup_app() -> TestServer { let state = Arc::new(UrlState::new( rabbitmq, redis, + repo.clone(), repo, "http://localhost:8000".to_string(), prometheus_handle,