Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 66 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
15 changes: 12 additions & 3 deletions k8s/base/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
59 changes: 59 additions & 0 deletions k8s/base/postgres-auth/postgres-auth-cluster.yaml
Original file line number Diff line number Diff line change
@@ -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"
59 changes: 59 additions & 0 deletions k8s/base/postgres-urls/postgres-urls-cluster.yaml
Original file line number Diff line number Diff line change
@@ -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"
2 changes: 1 addition & 1 deletion k8s/base/secret.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
12 changes: 10 additions & 2 deletions libs/shared/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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()),
})
Expand All @@ -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()),
})
Expand Down
33 changes: 24 additions & 9 deletions scripts/k8s.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -68,26 +75,34 @@ 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

log "Applying Kubernetes manifests..."
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
Expand Down
6 changes: 4 additions & 2 deletions services/auth-service/src/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading