diff --git a/Readme.md b/Readme.md index 033eb8c..c0c5216 100644 --- a/Readme.md +++ b/Readme.md @@ -12,6 +12,9 @@ A high-performance URL shortener service built with Go, using Cassandra for pers - πŸ“ **API Documentation**: Swagger/OpenAPI documentation in development mode - πŸ₯ **Health Checks**: Built-in health check endpoint - πŸ§ͺ **Test Coverage**: Comprehensive test suite with isolated test databases +- πŸ” **Observability**: OpenTelemetry integration for distributed tracing +- βš–οΈ **Load Balancing**: Nginx load balancer with 4 app replicas +- πŸ”„ **HAProxy**: Cassandra load balancing for high availability ## Architecture @@ -19,12 +22,41 @@ The project follows a clean architecture pattern with clear separation of concer - **Domain Layer**: Business logic and entities - **Gateway Layer**: External integrations (HTTP, Cassandra) -- **Extension Layer**: Infrastructure utilities (config, logger, Redis) +- **Extension Layer**: Infrastructure utilities (config, logger, Redis, OpenTelemetry) + +### System Architecture + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Frontend β”‚ β†’ http://localhost:8888 +β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ + β”‚ +β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β” +β”‚ Nginx β”‚ (Port 8888) - Load Balancer +β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ + β”‚ +β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β” +β”‚ App (x4) β”‚ (Load balanced replicas) +β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ + β”Œβ”€β”€β”€β”΄β”€β”€β” +β”Œβ”€β”€β–Όβ”€β”€β” β”Œβ”€β–Όβ”€β”€β” +β”‚Redisβ”‚ β”‚ DB β”‚ +β””β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”¬β”€β”˜ + β”‚ + β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β” + β”‚HAProxy β”‚ (Cassandra Load Balancer) + β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜ + β”‚ + β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β” + β”‚Cassandraβ”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + ## Prerequisites - Go 1.24 or higher -- Docker and Docker Compose (for running Cassandra and Redis) +- Docker and Docker Compose (for running all services) - Make (optional, for using Makefile commands) ## Installation @@ -35,88 +67,98 @@ git clone git@github.com:eulixir/lnk.git cd lnk ``` -2. Install dependencies: +2. Install backend dependencies: ```bash +cd backend go mod download ``` -3. Start required services using Docker Compose: -```bash -docker-compose up -d -``` - -This will start: -- Redis on port `6379` -- Cassandra on port `9042` - -> **⚠️ Important**: The Cassandra setup can take a significant amount of time (30-60 seconds or more) to fully initialize and be ready to accept connections. Wait for Cassandra to be healthy before starting the backend application. You can check readiness with: -> ```bash -> docker exec lnk-cassandra nodetool status -> ``` -> When Cassandra is ready, you should see the node status as `UN` (Up Normal). - -4. Create a `.env` file in the project root with the following configuration: +3. Copy `.env.example` to `.env` file in the `backend` directory with the following configuration: ```env -# Application Configuration -ENV=development -PORT=8080 -GIN_MODE=debug -BASE62_SALT=your-secret-salt-here - -# Cassandra Configuration -CASSANDRA_HOST=localhost +# Cassandra +CASSANDRA_HOST=cassandra-lb CASSANDRA_PORT=9042 CASSANDRA_USERNAME=cassandra CASSANDRA_PASSWORD=cassandra CASSANDRA_KEYSPACE=lnk CASSANDRA_AUTO_MIGRATE=true -# Redis Configuration -REDIS_HOST=localhost +# APP +ENV=development +PORT=8080 +GIN_MODE=debug +BASE62_SALT=banana + +# Redis +REDIS_HOST=redis REDIS_PORT=6379 REDIS_PASSWORD= REDIS_DB=0 -COUNTER_KEY=url_counter -COUNTER_START_VAL=1000000 +COUNTER_KEY=short_url_counter +COUNTER_START_VAL=14000000 + +# Log +LOG_LEVEL=debug + +# Otel +SERVICE_NAME=lnk-backend +OTEL_EXPORTER_OTLP_ENDPOINT=grafana:4317 +``` -# Logger Configuration -LOG_LEVEL=info +**Note**: +- Make sure to set a secure `BASE62_SALT` value in production +- Use Docker service names (e.g., `cassandra-lb`, `redis`, `grafana`) when running in Docker +- Use `localhost` when running services locally outside Docker + +4. Start all services using Docker Compose: +```bash +cd backend +docker-compose up -d ``` -**Note**: Make sure to set a secure `BASE62_SALT` value in production. +This will start all services. See [Docker Services](#docker-services) section for details. ## Running the Application -### Development Mode +### Docker Compose (Recommended) + +All services are managed via Docker Compose: -Run the application directly: ```bash -go run main.go +cd backend +docker-compose up -d # Start all services +docker-compose logs -f app # View app logs +docker-compose down # Stop all services ``` -The server will start on `http://localhost:8080` (or the port specified in your `.env` file). +The API will be available at `http://localhost:8888` (via Nginx load balancer). -### Using Make +### Development Mode (Local) -The project includes a Makefile with useful commands: +If you want to run the application locally outside Docker: -```bash -# Run tests -make test +1. Update `.env` to use `localhost` instead of Docker service names: +```env +CASSANDRA_HOST=localhost +REDIS_HOST=localhost +OTEL_EXPORTER_OTLP_ENDPOINT=localhost:4317 +``` -# Generate test coverage report -make coverage +2. Start only the infrastructure services: +```bash +cd backend +docker-compose up -d cassandra redis grafana +``` -# Generate Swagger documentation -make swagger +3. Run the application: +```bash +cd backend +go run cmd/app/main.go +``` -# Generate migrations (requires migrate tool) -make generate-migration NAME=your_migration_name +The server will start on `http://localhost:8080` (or the port specified in your `.env` file). -# Generate all (swagger + mocks) -make generate -``` ## API Endpoints @@ -177,51 +219,54 @@ Retrieve the original URL from a short code. In development mode, Swagger documentation is available at: ``` -http://localhost:8080/swagger/index.html +http://localhost:8888/swagger/index.html ``` -## Testing - -Run the full test suite: -```bash -go test ./... -``` -Or use the Makefile: -```bash -make test -``` +## Testing -Generate test coverage: ```bash -make coverage +cd backend +make test # Run tests +make coverage # Generate coverage report ``` -The tests use isolated test databases that are automatically created and cleaned up for each test. +Tests use isolated test databases that are automatically created and cleaned up. ## Project Structure ``` lnk/ -β”œβ”€β”€ domain/ # Domain layer -β”‚ └── entities/ -β”‚ β”œβ”€β”€ helpers/ # URL encoding/decoding utilities -β”‚ └── usecases/ # Business logic -β”œβ”€β”€ gateways/ # Gateway layer -β”‚ β”œβ”€β”€ gocql/ # Cassandra integration -β”‚ β”‚ β”œβ”€β”€ migrations/ # Database migrations -β”‚ β”‚ └── repositories/ # Data access layer -β”‚ └── http/ # HTTP handlers and router -β”œβ”€β”€ extensions/ # Infrastructure extensions -β”‚ β”œβ”€β”€ config/ # Configuration management -β”‚ β”œβ”€β”€ logger/ # Logging utilities -β”‚ β”œβ”€β”€ redis/ # Redis client -β”‚ └── gocqltesting/ # Testing utilities -β”œβ”€β”€ docs/ # Swagger documentation -β”œβ”€β”€ main.go # Application entry point -β”œβ”€β”€ docker-compose.yml # Docker services configuration -β”œβ”€β”€ Makefile # Build automation -└── README.md # This file +β”œβ”€β”€ backend/ +β”‚ β”œβ”€β”€ cmd/ +β”‚ β”‚ β”œβ”€β”€ app/ +β”‚ β”‚ β”‚ └── main.go # Application entry point +β”‚ β”‚ └── migrator/ +β”‚ β”‚ └── main.go # Migration service +β”‚ β”œβ”€β”€ domain/ # Domain layer +β”‚ β”‚ └── entities/ +β”‚ β”‚ β”œβ”€β”€ helpers/ # URL encoding/decoding utilities +β”‚ β”‚ └── usecases/ # Business logic +β”‚ β”œβ”€β”€ gateways/ # Gateway layer +β”‚ β”‚ β”œβ”€β”€ gocql/ # Cassandra integration +β”‚ β”‚ β”‚ β”œβ”€β”€ migrations/ # Database migrations +β”‚ β”‚ β”‚ └── repositories/ # Data access layer +β”‚ β”‚ └── http/ # HTTP handlers and router +β”‚ β”œβ”€β”€ extensions/ # Infrastructure extensions +β”‚ β”‚ β”œβ”€β”€ config/ # Configuration management +β”‚ β”‚ β”œβ”€β”€ logger/ # Logging utilities +β”‚ β”‚ β”œβ”€β”€ redis/ # Redis client +β”‚ β”‚ └── opentelemetry/ # OpenTelemetry setup +β”‚ β”œβ”€β”€ nginx/ +β”‚ β”‚ └── nginx.conf # Nginx load balancer config +β”‚ β”œβ”€β”€ haproxy/ +β”‚ β”‚ └── haproxy.cfg # HAProxy Cassandra LB config +β”‚ β”œβ”€β”€ docker-compose.yml # Docker services configuration +β”‚ β”œβ”€β”€ Dockerfile # Multi-stage build (migrator + app) +β”‚ β”œβ”€β”€ Makefile # Build automation +β”‚ └── .env # Environment configuration +β”œβ”€β”€ frontend/ # Next.js frontend application +└── Readme.md # This file ``` ## Database Schema @@ -241,6 +286,7 @@ CREATE TABLE urls ( This design ensures fast lookups when retrieving URLs by their short code. + ## Frontend The frontend is a modern Next.js application that provides a user-friendly interface for the URL shortener service. @@ -281,7 +327,7 @@ npm run generate:api bun run generate:api ``` -**Note**: Make sure the backend is running and Swagger documentation is available at `http://localhost:8080/swagger/doc.json` before generating the API client. +**Note**: Make sure the backend is running and Swagger documentation is available at `http://localhost:8888/swagger/doc.json` before generating the API client. ### Running the Frontend @@ -295,6 +341,8 @@ bun run dev The frontend will start on `http://localhost:3000` (default Next.js port). +**Important**: Update the API base URL in `frontend/src/api/undici-instance.ts` to point to `http://localhost:8888` (Nginx load balancer). + #### Production Build ```bash @@ -315,19 +363,6 @@ bun run start - `npm run format` / `bun run format`: Format code - `npm run generate:api` / `bun run generate:api`: Generate API client from Swagger -### Frontend Technologies - -- **Next.js 16**: React framework with App Router -- **React 19**: UI library -- **TypeScript**: Type safety -- **Tailwind CSS**: Utility-first CSS framework -- **shadcn/ui**: High-quality component library -- **Orval**: OpenAPI client generator -- **Biome**: Fast linter and formatter -- **React Hook Form**: Form management -- **Sonner**: Toast notifications -- **Lucide React**: Icon library - ### Frontend Project Structure ``` @@ -361,8 +396,11 @@ frontend/ - **Cassandra (gocql)**: Database for URL storage - **Redis**: Counter management for URL generation - **Zap**: Structured logging +- **OpenTelemetry**: Distributed tracing - **Swagger/OpenAPI**: API documentation - **Docker Compose**: Local development environment +- **Nginx**: Load balancer +- **HAProxy**: Cassandra load balancer - **Testify**: Testing framework ### Frontend @@ -373,58 +411,111 @@ frontend/ - **shadcn/ui**: Component library - **Orval**: API client generator -## Configuration +## Development -The application uses environment variables for configuration. All configuration options can be set in a `.env` file or as environment variables. +### Makefile Commands -### Required Environment Variables +```bash +cd backend -- `BASE62_SALT`: Secret salt for URL encoding -- `CASSANDRA_HOST`: Cassandra host address -- `CASSANDRA_PORT`: Cassandra port -- `CASSANDRA_USERNAME`: Cassandra username -- `CASSANDRA_PASSWORD`: Cassandra password -- `CASSANDRA_KEYSPACE`: Cassandra keyspace name -- `REDIS_HOST`: Redis host address -- `REDIS_PORT`: Redis port -- `REDIS_PASSWORD`: Redis password -- `REDIS_DB`: Redis database number -- `COUNTER_KEY`: Redis key for URL counter -- `COUNTER_START_VAL`: Starting value for URL counter +# Testing +make test # Run tests +make coverage # Generate test coverage report -### Optional Environment Variables +# Documentation +make swagger # Generate Swagger documentation -- `ENV`: Environment name (default: `development`) -- `PORT`: Server port (default: `8080`) -- `GIN_MODE`: Gin mode (default: `debug`) -- `LOG_LEVEL`: Logging level (default: `info`) -- `CASSANDRA_AUTO_MIGRATE`: Auto-run migrations (default: `false`) +# Migrations +make generate-migration NAME=your_migration_name # Create new migration +# Migrations auto-run via migrator service when using docker-compose -## Development +# Code Generation +make generate # Generate all (swagger + mocks) +``` + +### Database Migrations -### Adding a New Migration +Migrations run automatically via the `migrator` service before the app starts. To add a new migration: ```bash +cd backend make generate-migration NAME=your_migration_name ``` -This will create up and down migration files in `gateways/gocql/migrations/`. +This creates migration files in `gateways/gocql/migrations/` that will run automatically on next `docker-compose up`. + +## Docker Services + +### Service Overview + +| Service | Port | Description | +|---------|------|-------------| +| **Nginx** | 8888 | Load balancer (frontend access point) | +| **App** | 8080 | Application service (4 replicas, internal) | +| **Migrator** | - | Runs database migrations, then exits | +| **HAProxy** | 9042 | Cassandra load balancer | +| **Cassandra** | - | Primary database (accessed via HAProxy) | +| **Redis** | 6379 | Counter management and caching | +| **Grafana** | 8081, 4317 | Observability UI (8081) and OTLP gRPC (4317) | -### Generating Swagger Documentation +### Service Startup Order -After updating API endpoints with Swagger annotations: +Services start in the following order with health checks: +1. **Cassandra** β†’ waits until healthy (`nodetool status`) +2. **HAProxy** β†’ waits for Cassandra, checks with `pgrep haproxy` +3. **Redis** β†’ checks with `redis-cli ping` +4. **Grafana** β†’ observability stack +5. **Migrator** β†’ waits for Cassandra, runs migrations, exits +6. **App** β†’ waits for migrator completion, 4 replicas, HTTP health check +7. **Nginx** β†’ waits for app service + +Check service status: ```bash -make swagger +docker-compose ps ``` -### Code Generation +### Observability + +OpenTelemetry traces are automatically exported to Grafana: +- **OTLP Endpoint**: `grafana:4317` (gRPC) when running in Docker +- **Grafana UI**: `http://localhost:8081` (admin/admin) +- **Service Name**: `lnk-backend` (configurable via `SERVICE_NAME` env var) + +## Troubleshooting -Generate mocks and documentation: +### Services not starting +Check service logs: ```bash -make generate +docker-compose logs -f [service-name] ``` +Check service status: +```bash +docker-compose ps +``` + +### Migrations failing + +Check migrator logs: +```bash +docker-compose logs migrator +``` + +Ensure Cassandra is healthy: +```bash +docker-compose exec cassandra nodetool status +``` + +### Can't connect to services + +- Verify service names in `.env` match Docker service names +- Check that services are on the same Docker network (`lnk-network`) +- Verify ports are not already in use +### Frontend can't reach backend +- Ensure backend is running: `http://localhost:8888/health` +- Check CORS configuration in `gateways/http/middleware/middleware.go` +- Verify API base URL in frontend configuration diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..ecd68ba --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,23 @@ +FROM golang:1.24.10-alpine AS builder + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download && go mod verify + +COPY . . + +# Build both migrator and app binaries +RUN go build -o migrator ./cmd/migrator/main.go +RUN go build -o app ./cmd/app/main.go + +FROM alpine:latest + +WORKDIR /app + +# Copy both binaries +COPY --from=builder /app/migrator . +COPY --from=builder /app/app . + +# Default to running the app +CMD ["./app"] \ No newline at end of file diff --git a/backend/Makefile b/backend/Makefile index 1e0ece9..02409bf 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -29,4 +29,12 @@ lint: .PHONY: fmt format: @gofmt -w . - @gofumpt -w . \ No newline at end of file + @gofumpt -w . + +.PHONY: run-migrator +run-migrator: + @docker compose run migrator + +.PHONY: run-app +run-app: + @docker compose run app \ No newline at end of file diff --git a/backend/main.go b/backend/cmd/app/main.go similarity index 72% rename from backend/main.go rename to backend/cmd/app/main.go index 7f9f2a8..61b061c 100644 --- a/backend/main.go +++ b/backend/cmd/app/main.go @@ -35,27 +35,37 @@ func main() { appLogger.Fatal("Failed to setup OpenTelemetry", zap.Error(err)) } defer func() { - if err := shutdownOTel(ctx); err != nil { - appLogger.Error("Failed to shutdown OpenTelemetry", zap.Error(err)) + if shutdownErr := shutdownOTel(ctx); shutdownErr != nil { + appLogger.Error("Failed to shutdown OpenTelemetry", zap.Error(shutdownErr)) } }() - session := setupDatabase(cfg, appLogger) + session, err := setupDatabase(cfg, appLogger) + if err != nil { + appLogger.Fatal("Failed to setup database", zap.Error(err)) + } defer session.Close() - redisClient := setupRedis(ctx, cfg, appLogger) + redisClient, err := setupRedis(ctx, cfg, appLogger) + if err != nil { + appLogger.Fatal("Failed to setup Redis", zap.Error(err)) + } defer func() { - err := redisClient.Close() - if err != nil { - appLogger.Error("Failed to close Redis client", zap.Error(err)) + if closeErr := redisClient.Close(); closeErr != nil { + appLogger.Error("Failed to close Redis client", zap.Error(closeErr)) } }() - initializeCounter(ctx, redisClient, cfg, appLogger) - + err = initializeCounter(ctx, redisClient, cfg, appLogger) + if err != nil { + appLogger.Fatal("Failed to initialize counter", zap.Error(err)) + } useCase := createUseCase(cfg, appLogger, session, redisClient) - server := createAndStartServer(cfg, appLogger, useCase) + server, err := createAndStartServer(cfg, appLogger, useCase) + if err != nil { + appLogger.Fatal("Failed to create and start server", zap.Error(err)) + } shutdownServer(ctx, appLogger, server) } @@ -84,29 +94,31 @@ func setupConfigAndLogger() (*config.Config, *zap.Logger) { return cfg, appLogger } -func setupDatabase(cfg *config.Config, appLogger *zap.Logger) *gocql.Session { - session, err := gocqlPackage.SetupDatabase(&cfg.Gocql, appLogger) +func setupDatabase(cfg *config.Config, appLogger *zap.Logger) (*gocql.Session, error) { + session, err := gocqlPackage.SetupDatabase(&cfg.Gocql, appLogger, false) if err != nil { - appLogger.Fatal("Failed to setup database", zap.Error(err)) + return nil, fmt.Errorf("failed to setup database: %w", err) } - return session + return session, nil } -func setupRedis(ctx context.Context, cfg *config.Config, appLogger *zap.Logger) *redis.Client { +func setupRedis(ctx context.Context, cfg *config.Config, appLogger *zap.Logger) (*redis.Client, error) { redisClient, err := redisPackage.SetupRedis(ctx, &cfg.Redis, appLogger) if err != nil { - appLogger.Fatal("Failed to setup Redis", zap.Error(err)) + return nil, fmt.Errorf("failed to setup Redis: %w", err) } - return redisClient + return redisClient, nil } -func initializeCounter(ctx context.Context, redisClient *redis.Client, cfg *config.Config, appLogger *zap.Logger) { - setInitialCounter, err := redisPackage.SetInitialCounterValue(ctx, redisClient, &cfg.Redis, appLogger) - if err != nil && !setInitialCounter { - appLogger.Fatal("Failed to set initial counter", zap.Error(err)) +func initializeCounter(ctx context.Context, redisClient *redis.Client, cfg *config.Config, appLogger *zap.Logger) error { + _, err := redisPackage.SetInitialCounterValue(ctx, redisClient, &cfg.Redis, appLogger) + if err != nil { + return fmt.Errorf("failed to set initial counter: %w", err) } + + return nil } func createUseCase(cfg *config.Config, appLogger *zap.Logger, session *gocql.Session, redisClient *redis.Client) *usecases.UseCase { @@ -122,7 +134,7 @@ func createUseCase(cfg *config.Config, appLogger *zap.Logger, session *gocql.Ses }) } -func createAndStartServer(cfg *config.Config, appLogger *zap.Logger, useCase *usecases.UseCase) *httpServer.Server { +func createAndStartServer(cfg *config.Config, appLogger *zap.Logger, useCase *usecases.UseCase) (*httpServer.Server, error) { httpHandlers := handlers.NewHandlers(appLogger, useCase) router := httpServer.NewRouter(httpServer.RouterConfig{ @@ -136,10 +148,10 @@ func createAndStartServer(cfg *config.Config, appLogger *zap.Logger, useCase *us err := server.Start() if err != nil { - appLogger.Fatal("Failed to start HTTP server", zap.Error(err)) + return nil, fmt.Errorf("failed to start HTTP server: %w", err) } - return server + return server, nil } func shutdownServer(ctx context.Context, appLogger *zap.Logger, server *httpServer.Server) { diff --git a/backend/cmd/migrator/main.go b/backend/cmd/migrator/main.go new file mode 100644 index 0000000..d3103ac --- /dev/null +++ b/backend/cmd/migrator/main.go @@ -0,0 +1,51 @@ +package main + +import ( + "fmt" + "log" + + "lnk/extensions/config" + "lnk/extensions/logger" + gocqlPackage "lnk/gateways/gocql" + + gocql "github.com/apache/cassandra-gocql-driver/v2" + "go.uber.org/zap" +) + +func main() { + cfg, appLogger := setupConfigAndLogger() + + session, err := setupDatabase(cfg, appLogger) + if err != nil { + log.Fatalf("Failed to setup database: %v", err) + } + + defer session.Close() + + appLogger.Info("Migrations completed successfully") +} + +func setupConfigAndLogger() (*config.Config, *zap.Logger) { + cfg, err := config.LoadConfig() + if err != nil { + log.Fatalf("Failed to load config: %v", err) + } + + appLogger, err := logger.NewLogger(cfg.Logger) + if err != nil { + log.Fatalf("Failed to create logger: %v", err) + } + + appLogger.Info("Starting application") + + return cfg, appLogger +} + +func setupDatabase(cfg *config.Config, appLogger *zap.Logger) (*gocql.Session, error) { + session, err := gocqlPackage.SetupDatabase(&cfg.Gocql, appLogger, cfg.Gocql.AutoMigrate) + if err != nil { + return nil, fmt.Errorf("failed to setup database: %w", err) + } + + return session, nil +} diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index e456c37..0dcf50a 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -1,23 +1,103 @@ services: - redis: - image: redis:latest - container_name: lnk-redis + nginx: + image: nginx:alpine + container_name: lnk-nginx ports: - - "6379:6379" + - "8888:80" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + depends_on: + - app + networks: + - lnk-network - cassandra: - image: cassandra:latest - container_name: lnk-cassandra + migrator: + build: . + container_name: lnk-migrator + command: ["./migrator"] + volumes: + - ./.env:/app/.env:ro + networks: + - lnk-network + depends_on: + cassandra-lb: + condition: service_healthy + env_file: + - .env + restart: "no" + + app: + build: . + volumes: + - ./.env:/app/.env:ro + networks: + - lnk-network + depends_on: + migrator: + condition: service_completed_successfully + cassandra-lb: + condition: service_healthy + redis: + condition: service_started + deploy: + replicas: 4 + env_file: + - .env + + cassandra-lb: + image: haproxy:2.8-alpine + container_name: lnk-cassandra-lb + command: ["haproxy", "-f", "/usr/local/etc/haproxy/haproxy.cfg"] ports: - "9042:9042" + - "8404:8404" + volumes: + - ./haproxy/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro + depends_on: + cassandra: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "pgrep haproxy > /dev/null"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 10s + networks: + - lnk-network + + cassandra: + image: cassandra:latest environment: - CASSANDRA_CLUSTER_NAME=lnk-cluster - CASSANDRA_DC=datacenter1 - - CASSANDRA_RACK=rack1 + - CASSANDRA_ENDPOINT_SNITCH=GossipingPropertyFileSnitch - CASSANDRA_AUTHENTICATOR=PasswordAuthenticator - CASSANDRA_AUTHORIZER=CassandraAuthorizer - CASSANDRA_USER=cassandra - CASSANDRA_PASSWORD=cassandra + volumes: + - cassandra-data:/var/lib/cassandra + healthcheck: + test: ["CMD-SHELL", "nodetool status | grep -E '^UN'"] + interval: 10s + timeout: 5s + retries: 30 + start_period: 60s + networks: + - lnk-network + + redis: + image: redis:latest + container_name: lnk-redis + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 + networks: + - lnk-network grafana: image: grafana/otel-lgtm:latest @@ -27,6 +107,12 @@ services: - "4317:4317" environment: - GF_SECURITY_ADMIN_PASSWORD=admin - depends_on: - - cassandra - - redis + networks: + - lnk-network + +volumes: + cassandra-data: + +networks: + lnk-network: + driver: bridge diff --git a/backend/gateways/gocql/setup.go b/backend/gateways/gocql/setup.go index 9e81c6c..a1b5c41 100644 --- a/backend/gateways/gocql/setup.go +++ b/backend/gateways/gocql/setup.go @@ -7,12 +7,13 @@ import ( "strconv" "time" + "lnk/gateways/gocql/migrations" + gocql "github.com/apache/cassandra-gocql-driver/v2" "github.com/golang-migrate/migrate/v4" _ "github.com/golang-migrate/migrate/v4/database/cassandra" "github.com/golang-migrate/migrate/v4/source/iofs" "go.uber.org/zap" - "lnk/gateways/gocql/migrations" ) const ( @@ -21,7 +22,7 @@ const ( shutdownTimeout = 5 * time.Second ) -func SetupDatabase(config *Config, logger *zap.Logger) (*gocql.Session, error) { +func SetupDatabase(config *Config, logger *zap.Logger, autoMigrate bool) (*gocql.Session, error) { cluster := gocql.NewCluster(config.Host) cluster.Port = config.Port cluster.Authenticator = gocql.PasswordAuthenticator{ @@ -57,7 +58,7 @@ func SetupDatabase(config *Config, logger *zap.Logger) (*gocql.Session, error) { session.Close() session = sessionWithKeyspace - if config.AutoMigrate { + if autoMigrate { err := runMigrations(config, logger) if err != nil { return nil, fmt.Errorf("failed to run migrations: %w", err) diff --git a/backend/gateways/http/middleware/middleware.go b/backend/gateways/http/middleware/middleware.go index 167d8f2..7570d94 100644 --- a/backend/gateways/http/middleware/middleware.go +++ b/backend/gateways/http/middleware/middleware.go @@ -47,6 +47,7 @@ func Recovery(logger *zap.Logger) gin.HandlerFunc { func CORS() gin.HandlerFunc { return func(c *gin.Context) { c.Writer.Header().Set("Access-Control-Allow-Origin", "*") + c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With") c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE, PATCH") diff --git a/backend/haproxy/haproxy.cfg b/backend/haproxy/haproxy.cfg new file mode 100644 index 0000000..d242b94 --- /dev/null +++ b/backend/haproxy/haproxy.cfg @@ -0,0 +1,31 @@ +global + log stdout format raw local0 + maxconn 4096 + +defaults + log global + mode tcp + timeout connect 5000ms + timeout client 50000ms + timeout server 50000ms + option tcplog + +# Stats endpoint +listen stats + bind *:8404 + mode http + stats enable + stats uri /stats + stats refresh 30s + +# Cassandra backend +frontend cassandra_frontend + bind *:9042 + default_backend cassandra_backend + +backend cassandra_backend + balance roundrobin + option tcp-check + tcp-check connect + server cassandra cassandra:9042 check + diff --git a/backend/nginx/nginx.conf b/backend/nginx/nginx.conf new file mode 100644 index 0000000..1445906 --- /dev/null +++ b/backend/nginx/nginx.conf @@ -0,0 +1,60 @@ +# nginx/nginx.conf +events { + worker_connections 1024; +} + +http { + log_format upstream_log '$time_iso8601 | $remote_addr | "$request" | ' + 'status $status | upstream $upstream_addr | ' + 'response_time $request_time'; + + upstream lnk_backend { + least_conn; + server app:8080 max_fails=3 fail_timeout=30s; + keepalive 32; + } + + limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s; + + server { + listen 80; + server_name localhost; + + access_log /var/log/nginx/access.log upstream_log; + error_log /var/log/nginx/error.log; + + location /nginx-health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } + + location /api/ { + limit_req zone=api burst=20 nodelay; + + proxy_pass http://lnk_backend; + 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; + + proxy_connect_timeout 5s; + proxy_send_timeout 10s; + proxy_read_timeout 10s; + + add_header X-Upstream-Server $upstream_addr; + add_header X-Response-Time $request_time; + } + + location / { + proxy_pass http://lnk_backend; + 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; + + proxy_cache_valid 200 302 10m; + proxy_cache_valid 404 1m; + } + } +}