diff --git a/Taskfile.yml b/Taskfile.yml index 96a4062..840dad4 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -47,14 +47,7 @@ tasks: cmds: - tilt logs - # Development tasks dev:items-service: - desc: Run items-service in development mode with hot reload - dir: apps/items-service - cmds: - - bun install && bun run dev - - dev:items-service:postgres: desc: Run items-service locally with PostgreSQL connection (requires port-forward) dir: apps/items-service cmds: @@ -77,6 +70,65 @@ tasks: cmds: - bun install && bun run dev + dev:semcache-service: + desc: Run semcache-service locally with Air + K8s PostgreSQL (auto port-forwards) + dir: apps/semcache-service + cmds: + - | + echo "🔥 Starting semcache-service with Air hot reload + K8s PostgreSQL..." + echo "📝 Edit any .go file and see changes in ~1 second!" + echo "" + + # Check if port-forward is already running + if lsof -Pi :5432 -sTCP:LISTEN -t >/dev/null 2>&1; then + echo "✅ PostgreSQL port-forward already running on localhost:5432" + else + echo "🔌 Starting PostgreSQL port-forward in background..." + kubectl port-forward svc/postgres 5432:5432 > /dev/null 2>&1 & + PF_PID=$! + echo " Port-forward PID: $PF_PID" + sleep 2 + + # Verify port-forward is working + if ! lsof -Pi :5432 -sTCP:LISTEN -t >/dev/null 2>&1; then + echo "❌ Failed to start port-forward" + exit 1 + fi + echo "✅ Port-forward established" + fi + + echo "" + if ! command -v air &> /dev/null; then + echo "Installing Air..." + go install github.com/air-verse/air@latest + fi + + set -a + source ../../.env + set +a + export PORT=8090 + export DB_HOST=localhost + export DB_PORT=5432 + export DB_USER=$POSTGRES_USER + export DB_PASSWORD=$POSTGRES_PASSWORD + export DB_NAME=$POSTGRES_DB + export DB_SSLMODE=disable + export OTEL_ENABLED=false + + echo "🚀 Starting Air hot reload on port 8090..." + echo " http://localhost:8090/health" + echo " http://localhost:8090/v1/greetings" + echo "" + air -c .air.toml + +# dev:stop-port-forwards: +# desc: Stop all kubectl port-forward processes +# cmds: +# - | +# echo "🛑 Stopping all kubectl port-forward processes..." +# pkill -f "kubectl port-forward" || echo "No port-forwards running" +# echo "✅ Done" + # Docker tasks docker:build:items-service: desc: Build Docker image for items-service @@ -88,11 +140,17 @@ tasks: cmds: - docker build -t website-app:local ./apps/website-app + docker:build:semcache-service: + desc: Build Docker image for semcache-service + cmds: + - docker build -t semcache-service:local ./apps/semcache-service + docker:build:all: desc: Build Docker images for all apps cmds: - task: docker:build:items-service - task: docker:build:website-app + - task: docker:build:semcache-service docker:run:items-service: desc: Run items-service Docker container locally @@ -104,6 +162,11 @@ tasks: cmds: - docker run --rm -p 8080:8080 website-app:local + docker:run:semcache-service: + desc: Run semcache-service Docker container locally + cmds: + - docker run --rm -p 8080:8080 semcache-service:local + # Terraform tasks tf:init: desc: Initialize Terraform @@ -205,6 +268,11 @@ tasks: cmds: - kubectl --kubeconfig={{.KUBECONFIG}} logs -l app=website-app --tail=100 -f + k8s:logs:semcache-service: + desc: Get logs from semcache-service + cmds: + - kubectl --kubeconfig={{.KUBECONFIG}} logs -l app=semcache-service --tail=100 -f + k8s:describe:items-service: desc: Describe items-service deployment cmds: @@ -215,6 +283,11 @@ tasks: cmds: - kubectl --kubeconfig={{.KUBECONFIG}} describe deployment website-app + k8s:describe:semcache-service: + desc: Describe semcache-service deployment + cmds: + - kubectl --kubeconfig={{.KUBECONFIG}} describe deployment semcache-service + # Deployment tasks deploy:items-service: desc: Deploy items-service to Kubernetes @@ -229,6 +302,14 @@ tasks: cmds: - kubectl --kubeconfig={{.KUBECONFIG}} apply -f {{.K8S_DIR}}/apps/website-app-deployment.yaml + deploy:semcache-service: + desc: Deploy semcache-service to Kubernetes + cmds: + - kubectl --kubeconfig={{.KUBECONFIG}} apply -f {{.K8S_DIR}}/apps/semcache-service-deployment.yaml + - echo "✅ semcache-service deployed" + - echo "Waiting for rollout to complete..." + - kubectl --kubeconfig={{.KUBECONFIG}} rollout status deployment/semcache-service --timeout=120s + deploy:headlamp-readonly: desc: Deploy Headlamp Kubernetes Dashboard (Read-Only Public) cmds: @@ -242,6 +323,7 @@ tasks: cmds: - task: deploy:items-service - task: deploy:website-app + - task: deploy:semcache-service - task: deploy:headlamp-readonly deploy:items-service:with-postgres: @@ -335,6 +417,11 @@ tasks: cmds: - kubectl --kubeconfig={{.KUBECONFIG}} rollout restart deployment/website-app + rollout:restart:semcache-service: + desc: Restart semcache-service deployment + cmds: + - kubectl --kubeconfig={{.KUBECONFIG}} rollout restart deployment/semcache-service + rollout:status:items-service: desc: Check rollout status for items-service cmds: @@ -345,6 +432,11 @@ tasks: cmds: - kubectl --kubeconfig={{.KUBECONFIG}} rollout status deployment/website-app + rollout:status:semcache-service: + desc: Check rollout status for semcache-service + cmds: + - kubectl --kubeconfig={{.KUBECONFIG}} rollout status deployment/semcache-service + # DNS and networking tasks dns:clear-cache: desc: Clear DNS cache (macOS) @@ -369,21 +461,31 @@ tasks: cmds: - curl -s https://roussev.com/health | jq + health:semcache-service: + desc: Check health of semcache-service (local) + cmds: + - curl -s https://app.roussev.com/semcache/v1/health | jq + clean:docker: desc: Clean Docker images cmds: - - docker rmi items-service:local website-app:local || true + - docker rmi items-service:local website-app:local semcache-service:local || true # Port forwarding tasks - port-forward:items-service: - desc: Port forward to items-service pod - cmds: - - kubectl --kubeconfig={{.KUBECONFIG}} port-forward svc/items-service 8080:80 - - port-forward:website-app: - desc: Port forward to website-app pod - cmds: - - kubectl --kubeconfig={{.KUBECONFIG}} port-forward svc/website-app 8080:80 +# port-forward:items-service: +# desc: Port forward to items-service pod +# cmds: +# - kubectl --kubeconfig={{.KUBECONFIG}} port-forward svc/items-service 8080:80 + +# port-forward:website-app: +# desc: Port forward to website-app pod +# cmds: +# - kubectl --kubeconfig={{.KUBECONFIG}} port-forward svc/website-app 8080:80 + +# port-forward:semcache-service: +# desc: Port forward to semcache-service pod +# cmds: +# - kubectl --kubeconfig={{.KUBECONFIG}} port-forward svc/semcache-service 8080:80 # SSH tasks ssh: diff --git a/Tiltfile b/Tiltfile index 30a8f56..8cdeb45 100644 --- a/Tiltfile +++ b/Tiltfile @@ -181,6 +181,37 @@ k8s_resource( ] ) +# ============================================================================ +# Semcache Service (Go + Echo) +# ============================================================================ + +# Build Docker image for semcache-service (using dev Dockerfile with hot reload) +docker_build( + 'semcache-service', + context='./apps/semcache-service', + dockerfile='./apps/semcache-service/Dockerfile.dev', + live_update=[ + sync('./apps/semcache-service', '/app'), + ] +) + +# Deploy semcache-service +k8s_yaml('infra/k8s/local/semcache-service-local.yaml') + +# Configure semcache-service resource +k8s_resource( + 'semcache-service', + port_forwards='8083:8080', + labels=['apps'], + resource_deps=['postgres', 'jaeger', 'prometheus'], + links=[ + link('http://localhost:8083/docs', 'API Documentation (Swagger UI)'), + link('http://localhost:8083/health', 'Health Check'), + link('http://localhost:8083/v1/create', 'API - Create (POST)'), + link('http://localhost:8083/v1/search', 'API - Search (POST)'), + ] +) + # ============================================================================ # Headlamp Kubernetes Dashboard (Read-Only) # ============================================================================ @@ -217,6 +248,9 @@ Services will be available at: - Metrics: http://localhost:8081/metrics 🌐 Website App: http://localhost:8082 - Health: http://localhost:8082/health + 👋 Hello Service: http://localhost:8083 + - Health: http://localhost:8083/health + - API: http://localhost:8083/v1/greetings 👁️ Headlamp: http://localhost:8084 (Read-Only) 📊 Observability: diff --git a/apps/semcache-service/.air.toml b/apps/semcache-service/.air.toml new file mode 100644 index 0000000..f8d70a9 --- /dev/null +++ b/apps/semcache-service/.air.toml @@ -0,0 +1,45 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./tmp/main" + cmd = "go build -o ./tmp/main ./cmd/server" + delay = 500 # Faster delay for local dev (500ms vs 1000ms) + exclude_dir = ["assets", "tmp", "vendor", "testdata"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + time = true + +[misc] + clean_on_exit = false + +[screen] + clear_on_rebuild = true # Clear screen on rebuild for cleaner output + keep_scroll = true + diff --git a/apps/semcache-service/.gitignore b/apps/semcache-service/.gitignore new file mode 100644 index 0000000..b9fbf52 --- /dev/null +++ b/apps/semcache-service/.gitignore @@ -0,0 +1,38 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool +*.out + +# Go workspace file +go.work + +# Dependency directories +vendor/ + +# Air temporary files +tmp/ +build-errors.log + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Environment variables +.env +.env.local + diff --git a/apps/semcache-service/Dockerfile b/apps/semcache-service/Dockerfile new file mode 100644 index 0000000..3d69a4f --- /dev/null +++ b/apps/semcache-service/Dockerfile @@ -0,0 +1,40 @@ +# Build stage +FROM golang:1.25-alpine AS builder + +WORKDIR /app + +# Install build dependencies +RUN apk add --no-cache git + +# Copy go mod files +COPY go.mod go.sum* ./ + +# Download dependencies +RUN go mod download + +# Copy source code +COPY . . + +# Build the application +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server ./cmd/server + +# Runtime stage +FROM alpine:3.19 + +# Install ca-certificates for HTTPS +RUN apk --no-cache add ca-certificates + +WORKDIR /app + +# Copy binary from builder +COPY --from=builder /app/server . + +# Expose port +EXPOSE 8080 + +# Set environment variables +ENV PORT=8080 + +# Run the application +CMD ["./server"] + diff --git a/apps/semcache-service/Dockerfile.dev b/apps/semcache-service/Dockerfile.dev new file mode 100644 index 0000000..cefddab --- /dev/null +++ b/apps/semcache-service/Dockerfile.dev @@ -0,0 +1,29 @@ +# Development Dockerfile with hot reload using Air +FROM golang:1.25-alpine + +WORKDIR /app + +# Install build dependencies +RUN apk add --no-cache git + +# Install Air for hot reload +RUN go install github.com/air-verse/air@latest + +# Copy go mod files +COPY go.mod go.sum ./ + +# Download dependencies +RUN go mod download && go mod verify + +# Copy source code +COPY . . + +# Pre-build the application to catch any errors early and speed up first start +RUN go build -v -o ./tmp/main ./cmd/server + +# Expose port +EXPOSE 8080 + +# Run with Air for hot reload +CMD ["air", "-c", ".air.toml"] + diff --git a/apps/semcache-service/README.md b/apps/semcache-service/README.md new file mode 100644 index 0000000..64d6cc2 --- /dev/null +++ b/apps/semcache-service/README.md @@ -0,0 +1,261 @@ +# Hello Service + +A simple REST API service built with Go and Echo framework, featuring PostgreSQL database integration, OpenTelemetry tracing, and Kubernetes deployment. + +## Features + +- **Echo Framework** - Fast and minimalist Go web framework +- **PostgreSQL** database integration with connection pooling +- **RESTful API** for managing greetings +- **Health check** endpoint with database connectivity status +- **OpenTelemetry** distributed tracing support +- **Hot reload** in development mode with Air +- **Docker** support with development and production configurations +- **Kubernetes** ready with manifests for local and production deployments + +## API Endpoints + +### Health Check +```bash +GET /health +GET /v1/health +``` + +Returns service health status including database connectivity. + +### Greetings API + +#### Get all greetings +```bash +GET /v1/greetings +``` + +#### Create a greeting +```bash +POST /v1/greetings +Content-Type: application/json + +{ + "name": "World" +} +``` + +#### Get a greeting by ID +```bash +GET /v1/greetings/:id +``` + +## Local Development + +### Prerequisites + +- Go 1.22 or later +- PostgreSQL (or use the k3d cluster with Tilt) +- Task (taskfile.dev) + +### Run with Tilt (Recommended) + +The easiest way to run the service locally is with Tilt, which handles building, deploying, and hot-reloading: + +```bash +# Start the k3d cluster and all services +task local:start + +# The service will be available at: +# http://localhost:8083/health +# http://localhost:8083/v1/greetings +``` + +### Run Standalone + +```bash +# Run without database (will fail to connect) +task dev:hello-service + +# Run with PostgreSQL (requires port-forward) +# In terminal 1: +task postgres:port-forward + +# In terminal 2: +task dev:hello-service:postgres +``` + +### Manual Setup + +```bash +cd apps/hello-service + +# Install dependencies +go mod download + +# Run the service +go run ./cmd/server/main.go +``` + +## Environment Variables + +| Variable | Description | Default | +|----------|-------------|---------| +| `PORT` | Server port | `8080` | +| `COMMIT_SHA` | Git commit SHA for versioning | `unknown` | +| `DB_HOST` | PostgreSQL host | `localhost` | +| `DB_PORT` | PostgreSQL port | `5432` | +| `DB_USER` | PostgreSQL user | `postgres` | +| `DB_PASSWORD` | PostgreSQL password | `postgres` | +| `DB_NAME` | PostgreSQL database name | `itemsdb` | +| `DB_SSLMODE` | PostgreSQL SSL mode | `disable` | +| `OTEL_ENABLED` | Enable OpenTelemetry tracing | `true` | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP endpoint | `http://localhost:4318` | +| `OTEL_SERVICE_NAME` | Service name for tracing | `hello-service` | + +## Docker + +### Build + +```bash +# Build production image +task docker:build:hello-service + +# Or manually +docker build -t hello-service:local ./apps/hello-service +``` + +### Run + +```bash +# Run the container +task docker:run:hello-service + +# Or manually +docker run --rm -p 8080:8080 \ + -e DB_HOST=host.docker.internal \ + -e DB_USER=postgres \ + -e DB_PASSWORD=postgres \ + -e DB_NAME=itemsdb \ + hello-service:local +``` + +## Kubernetes Deployment + +### Local (k3d with Tilt) + +The service is automatically deployed when using Tilt: + +```bash +task local:start +``` + +### Production + +```bash +# Deploy to production cluster +task deploy:hello-service + +# Check deployment status +task rollout:status:hello-service + +# View logs +task k8s:logs:hello-service + +# Port forward to access locally +task port-forward:hello-service +``` + +## Testing the API + +```bash +# Health check +curl http://localhost:8083/health + +# Create a greeting +curl -X POST http://localhost:8083/v1/greetings \ + -H "Content-Type: application/json" \ + -d '{"name":"World"}' + +# Get all greetings +curl http://localhost:8083/v1/greetings + +# Get a specific greeting +curl http://localhost:8083/v1/greetings/1 +``` + +## Database Schema + +The service automatically creates the following table on startup: + +```sql +CREATE TABLE IF NOT EXISTS greetings ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + message TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); +``` + +## Tech Stack + +- **Language**: Go 1.22 +- **Framework**: [Echo v4](https://echo.labstack.com/) +- **Database**: PostgreSQL with [lib/pq](https://github.com/lib/pq) driver +- **Observability**: OpenTelemetry for distributed tracing +- **Container**: Docker with Alpine Linux +- **Orchestration**: Kubernetes (k3s) +- **Local Development**: Task and Tilt with k3d +- **Hot Reload**: [Air](https://github.com/cosmtrek/air) + +## Project Structure + +``` +hello-service/ +├── cmd/ +│ └── server/ +│ └── main.go # Application entry point +├── internal/ +│ ├── config/ +│ │ └── config.go # Configuration management +│ ├── database/ +│ │ └── database.go # Database connection and schema +│ ├── handlers/ +│ │ └── handlers.go # HTTP request handlers +│ └── models/ +│ └── greeting.go # Data models and repository +├── Dockerfile # Production Docker image +├── Dockerfile.dev # Development Docker image with hot reload +├── .air.toml # Air configuration for hot reload +├── go.mod # Go module dependencies +└── README.md # This file +``` + +## Development Tips + +1. **Hot Reload**: When running with Tilt, any changes to `.go` files will automatically trigger a rebuild and redeploy. + +2. **Database Access**: Use `task postgres:connect` to access the PostgreSQL database directly. + +3. **Logs**: View service logs with `task k8s:logs:hello-service` or through the Tilt UI. + +4. **Tracing**: View distributed traces in Jaeger at http://localhost:16686 when running with Tilt. + +5. **Health Checks**: The `/health` endpoint includes database connectivity status, making it easy to diagnose issues. + +## Troubleshooting + +### Service won't start +- Check if PostgreSQL is running and accessible +- Verify database credentials in environment variables or Kubernetes secrets +- Check logs: `task k8s:logs:hello-service` + +### Database connection errors +- Ensure PostgreSQL is deployed: `task deploy:postgres` +- Check PostgreSQL status: `task postgres:status` +- Verify the postgres-secret exists: `kubectl get secret postgres-secret` + +### Hot reload not working +- Make sure you're using the dev Dockerfile (Dockerfile.dev) +- Check that Air is installed in the container +- Verify file sync in Tilt UI + +## License + +MIT + diff --git a/apps/semcache-service/api/openapi.yaml b/apps/semcache-service/api/openapi.yaml new file mode 100644 index 0000000..be61a01 --- /dev/null +++ b/apps/semcache-service/api/openapi.yaml @@ -0,0 +1,333 @@ +openapi: 3.0.3 +info: + title: Semcache Service API + description: | + Semantic cache service for storing and searching key-value pairs with metadata and TTL support. + + ## Features + - Store key-value pairs with optional metadata + - TTL (Time To Live) support for automatic expiration + - Flexible search by key or metadata + - Unique key constraint + - Automatic expiration filtering + version: 1.0.0 + contact: + name: API Support + url: https://github.com/nextinterfaces/semcache-service + +servers: + - url: http://localhost:8083 + description: Local development (Tilt) + - url: http://localhost:8090 + description: Local development (Air hot reload) + - url: https://app.roussev.com/semcache + description: Production + +tags: + - name: cache + description: Cache operations + - name: health + description: Health check endpoints + +paths: + /health: + get: + tags: + - health + summary: Health check + description: Returns the health status of the service and database connectivity + operationId: getHealth + responses: + '200': + description: Service is healthy + content: + application/json: + schema: + $ref: '#/components/schemas/HealthResponse' + example: + status: ok + timestamp: "2024-01-15T10:30:00Z" + commit_sha: "abc123" + database: healthy + + /v1/health: + get: + tags: + - health + summary: Health check (v1) + description: Returns the health status of the service and database connectivity + operationId: getHealthV1 + responses: + '200': + description: Service is healthy + content: + application/json: + schema: + $ref: '#/components/schemas/HealthResponse' + + /v1/create: + post: + tags: + - cache + summary: Create cache entry + description: | + Creates a new cache entry with a unique key. If a key already exists, the request will fail. + + **TTL Support**: Optionally specify a TTL (time to live) in seconds. After the TTL expires, + the entry will be automatically filtered out from search results. + operationId: createCacheEntry + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateRequest' + examples: + simple: + summary: Simple cache entry + value: + key: "user:123" + value: "John Doe" + with_metadata: + summary: With metadata + value: + key: "user:456" + value: '{"name": "Jane Smith", "email": "jane@example.com"}' + metadata: "user profile" + with_ttl: + summary: With TTL (1 hour) + value: + key: "session:abc" + value: "session_data_here" + metadata: "user session" + ttl: 3600 + responses: + '201': + description: Cache entry created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/CacheEntry' + example: + id: 1 + key: "user:123" + value: "John Doe" + metadata: "user profile" + created_at: "2024-01-15T10:30:00Z" + expires_at: "2024-01-15T11:30:00Z" + '400': + description: Invalid request body or missing required fields + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: "Key is required" + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: "Failed to create cache entry" + + /v1/search: + post: + tags: + - cache + summary: Search cache entries + description: | + Search for cache entries by key or metadata. Supports partial matching (case-insensitive). + + **Automatic Expiration**: Expired entries (where `expires_at` < now) are automatically + filtered out from results. + + **Limit**: Maximum 100 results per request (default: 100). + operationId: searchCacheEntries + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SearchRequest' + examples: + by_key: + summary: Search by key + value: + key: "user" + limit: 10 + by_metadata: + summary: Search by metadata + value: + metadata: "profile" + limit: 20 + combined: + summary: Search by key and metadata + value: + key: "session" + metadata: "active" + limit: 50 + all: + summary: Get all (up to limit) + value: + limit: 100 + responses: + '200': + description: Search results + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/CacheEntry' + example: + - id: 1 + key: "user:123" + value: "John Doe" + metadata: "user profile" + created_at: "2024-01-15T10:30:00Z" + expires_at: null + - id: 2 + key: "user:456" + value: '{"name": "Jane Smith"}' + metadata: "user profile" + created_at: "2024-01-15T10:35:00Z" + expires_at: "2024-01-15T11:35:00Z" + '400': + description: Invalid request body + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: "Failed to search cache entries" + +components: + schemas: + HealthResponse: + type: object + required: + - status + - timestamp + - commit_sha + - database + properties: + status: + type: string + description: Overall service status + example: ok + timestamp: + type: string + format: date-time + description: Current server timestamp in RFC3339 format + example: "2024-01-15T10:30:00Z" + commit_sha: + type: string + description: Git commit SHA of the deployed version + example: "abc123def456" + database: + type: string + description: Database connectivity status + enum: [healthy, unhealthy] + example: healthy + + CreateRequest: + type: object + required: + - key + - value + properties: + key: + type: string + description: Unique key for the cache entry + maxLength: 255 + example: "user:123" + value: + type: string + description: Value to store (can be any string, including JSON) + example: "John Doe" + metadata: + type: string + description: Optional metadata for categorization and search + example: "user profile" + ttl: + type: integer + format: int32 + description: Time to live in seconds (optional). After TTL expires, entry is filtered from search results. + minimum: 1 + example: 3600 + + SearchRequest: + type: object + properties: + key: + type: string + description: Search by key (partial match, case-insensitive) + example: "user" + metadata: + type: string + description: Search by metadata (partial match, case-insensitive) + example: "profile" + limit: + type: integer + format: int32 + description: Maximum number of results to return (default 100, max 100) + minimum: 1 + maximum: 100 + default: 100 + example: 10 + + CacheEntry: + type: object + required: + - id + - key + - value + - created_at + properties: + id: + type: integer + format: int32 + description: Unique identifier for the cache entry + example: 1 + key: + type: string + description: Unique key for the cache entry + example: "user:123" + value: + type: string + description: Stored value + example: "John Doe" + metadata: + type: string + description: Optional metadata + example: "user profile" + created_at: + type: string + format: date-time + description: Timestamp when the entry was created + example: "2024-01-15T10:30:00Z" + expires_at: + type: string + format: date-time + nullable: true + description: Timestamp when the entry expires (null if no expiration) + example: "2024-01-15T11:30:00Z" + + ErrorResponse: + type: object + required: + - error + properties: + error: + type: string + description: Error message + example: "Invalid request body" + diff --git a/apps/semcache-service/cmd/server/main.go b/apps/semcache-service/cmd/server/main.go new file mode 100644 index 0000000..691f6ce --- /dev/null +++ b/apps/semcache-service/cmd/server/main.go @@ -0,0 +1,169 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "os/signal" + "syscall" + "time" + + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" + "github.com/nextinterfaces/semcache-service/internal/config" + "github.com/nextinterfaces/semcache-service/internal/database" + "github.com/nextinterfaces/semcache-service/internal/handlers" + "github.com/nextinterfaces/semcache-service/internal/models" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.24.0" +) + +func main() { + if err := run(); err != nil { + log.Fatalf("Application error: %v", err) + } +} + +func run() error { + // Load configuration + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + log.Printf("Starting semcache-service on port %d", cfg.Server.Port) + log.Printf("Commit SHA: %s", cfg.Server.CommitSHA) + + // Initialize OpenTelemetry if enabled + if cfg.OTEL.Enabled { + shutdown, err := initTracer(cfg) + if err != nil { + log.Printf("Warning: Failed to initialize tracer: %v", err) + } else { + defer shutdown() + log.Printf("OpenTelemetry tracing enabled: %s", cfg.OTEL.Endpoint) + } + } + + // Connect to database + db, err := database.New(&cfg.Database) + if err != nil { + return fmt.Errorf("failed to connect to database: %w", err) + } + defer db.Close() + + // Initialize database schema + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := db.InitSchema(ctx); err != nil { + return fmt.Errorf("failed to initialize schema: %w", err) + } + + // Create repositories + cacheRepo := models.NewCacheRepository(db.DB) + + // Create handlers + h := handlers.New(cacheRepo, cfg.Server.CommitSHA) + + // Create Echo instance + e := echo.New() + e.HideBanner = true + + // Middleware + e.Use(middleware.Logger()) + e.Use(middleware.Recover()) + e.Use(middleware.CORS()) + + // Routes + e.GET("/health", h.Health) + e.GET("/v1/health", h.Health) + + // API Documentation routes + e.GET("/docs", h.ServeSwaggerUI) + e.GET("/api/openapi.yaml", h.ServeOpenAPISpec) + + // API routes + api := e.Group("/v1") + api.POST("/create", h.Create) + api.POST("/search", h.Search) + + // Start server in a goroutine + go func() { + addr := fmt.Sprintf(":%d", cfg.Server.Port) + if err := e.Start(addr); err != nil { + log.Printf("Server error: %v", err) + } + }() + + log.Printf("Server started successfully on port %d", cfg.Server.Port) + log.Printf("Health check: http://localhost:%d/health", cfg.Server.Port) + log.Printf("API Documentation: http://localhost:%d/docs", cfg.Server.Port) + log.Printf("API endpoints:") + log.Printf(" POST http://localhost:%d/v1/create", cfg.Server.Port) + log.Printf(" POST http://localhost:%d/v1/search", cfg.Server.Port) + + // Wait for interrupt signal to gracefully shutdown the server + quit := make(chan os.Signal, 1) + signal.Notify(quit, os.Interrupt, syscall.SIGTERM) + <-quit + + log.Println("Shutting down server...") + + // Graceful shutdown with timeout + ctx, cancel = context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := e.Shutdown(ctx); err != nil { + return fmt.Errorf("server shutdown error: %w", err) + } + + log.Println("Server stopped") + return nil +} + +// initTracer initializes OpenTelemetry tracer +func initTracer(cfg *config.Config) (func(), error) { + ctx := context.Background() + + // Create OTLP HTTP exporter + exporter, err := otlptracehttp.New(ctx, + otlptracehttp.WithEndpoint(cfg.OTEL.Endpoint), + otlptracehttp.WithInsecure(), + ) + if err != nil { + return nil, fmt.Errorf("failed to create exporter: %w", err) + } + + // Create resource + res, err := resource.New(ctx, + resource.WithAttributes( + semconv.ServiceNameKey.String(cfg.OTEL.ServiceName), + semconv.ServiceVersionKey.String(cfg.Server.CommitSHA), + ), + ) + if err != nil { + return nil, fmt.Errorf("failed to create resource: %w", err) + } + + // Create tracer provider + tp := sdktrace.NewTracerProvider( + sdktrace.WithBatcher(exporter), + sdktrace.WithResource(res), + ) + + otel.SetTracerProvider(tp) + + return func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := tp.Shutdown(ctx); err != nil { + log.Printf("Error shutting down tracer provider: %v", err) + } + }, nil +} + diff --git a/apps/semcache-service/go.mod b/apps/semcache-service/go.mod new file mode 100644 index 0000000..91d964d --- /dev/null +++ b/apps/semcache-service/go.mod @@ -0,0 +1,37 @@ +module github.com/nextinterfaces/semcache-service + +go 1.25 + +require ( + github.com/labstack/echo/v4 v4.12.0 + github.com/lib/pq v1.10.9 + go.opentelemetry.io/otel v1.24.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 + go.opentelemetry.io/otel/sdk v1.24.0 +) + +require ( + github.com/cenkalti/backoff/v4 v4.2.1 // indirect + github.com/go-logr/logr v1.4.1 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1 // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 // indirect + go.opentelemetry.io/otel/metric v1.24.0 // indirect + go.opentelemetry.io/otel/trace v1.24.0 // indirect + go.opentelemetry.io/proto/otlp v1.1.0 // indirect + golang.org/x/crypto v0.22.0 // indirect + golang.org/x/net v0.24.0 // indirect + golang.org/x/sys v0.19.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/time v0.5.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240227224415-6ceb2ff114de // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de // indirect + google.golang.org/grpc v1.63.2 // indirect + google.golang.org/protobuf v1.33.0 // indirect +) diff --git a/apps/semcache-service/go.sum b/apps/semcache-service/go.sum new file mode 100644 index 0000000..f8a3d55 --- /dev/null +++ b/apps/semcache-service/go.sum @@ -0,0 +1,72 @@ +github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= +github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1 h1:/c3QmbOGMGTOumP2iT/rCwB7b0QDGLKzqOmktBjT+Is= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1/go.mod h1:5SN9VR2LTsRFsrEC6FHgRbTWrTHu6tqPeKxEQv15giM= +github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0= +github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= +go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 h1:t6wl9SPayj+c7lEIFgm4ooDBZVb01IhLB4InpomhRw8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0/go.mod h1:iSDOcsnSA5INXzZtwaBPrKp/lWu/V14Dd+llD0oI2EA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 h1:Xw8U6u2f8DK2XAkGRFV7BBLENgnTGX9i4rQRxJf+/vs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0/go.mod h1:6KW1Fm6R/s6Z3PGXwSJN2K4eT6wQB3vXX6CVnYX9NmM= +go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= +go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco= +go.opentelemetry.io/otel/sdk v1.24.0 h1:YMPPDNymmQN3ZgczicBY3B6sf9n62Dlj9pWD3ucgoDw= +go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg= +go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= +go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= +go.opentelemetry.io/proto/otlp v1.1.0 h1:2Di21piLrCqJ3U3eXGCTPHE9R8Nh+0uglSnOyxikMeI= +go.opentelemetry.io/proto/otlp v1.1.0/go.mod h1:GpBHCBWiqvVLDqmHZsoMM3C5ySeKTC7ej/RNTae6MdY= +golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= +golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de h1:F6qOa9AZTYJXOUEr4jDysRDLrm4PHePlge4v4TGAlxY= +google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de/go.mod h1:VUhTRKeHn9wwcdrk73nvdC9gF178Tzhmt/qyaFcPLSo= +google.golang.org/genproto/googleapis/api v0.0.0-20240227224415-6ceb2ff114de h1:jFNzHPIeuzhdRwVhbZdiym9q0ory/xY3sA+v2wPg8I0= +google.golang.org/genproto/googleapis/api v0.0.0-20240227224415-6ceb2ff114de/go.mod h1:5iCWqnniDlqZHrd3neWVTOwvh/v6s3232omMecelax8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de h1:cZGRis4/ot9uVm639a+rHCUaG0JJHEsdyzSQTMX+suY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de/go.mod h1:H4O17MA/PE9BsGx3w+a+W2VOLLD1Qf7oJneAoU6WktY= +google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM= +google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/apps/semcache-service/internal/config/config.go b/apps/semcache-service/internal/config/config.go new file mode 100644 index 0000000..33ff293 --- /dev/null +++ b/apps/semcache-service/internal/config/config.go @@ -0,0 +1,118 @@ +package config + +import ( + "fmt" + "os" + "strconv" +) + +// Config holds all configuration for the application +type Config struct { + Server ServerConfig + Database DatabaseConfig + OTEL OTELConfig +} + +// ServerConfig holds server configuration +type ServerConfig struct { + Port int + CommitSHA string +} + +// DatabaseConfig holds database configuration +type DatabaseConfig struct { + Host string + Port int + User string + Password string + Database string + SSLMode string +} + +// OTELConfig holds OpenTelemetry configuration +type OTELConfig struct { + Enabled bool + Endpoint string + ServiceName string +} + +// Load loads configuration from environment variables +func Load() (*Config, error) { + port, err := getEnvAsInt("PORT", 8080) + if err != nil { + return nil, fmt.Errorf("invalid PORT: %w", err) + } + + dbPort, err := getEnvAsInt("DB_PORT", 5432) + if err != nil { + return nil, fmt.Errorf("invalid DB_PORT: %w", err) + } + + otelEnabled, err := getEnvAsBool("OTEL_ENABLED", true) + if err != nil { + return nil, fmt.Errorf("invalid OTEL_ENABLED: %w", err) + } + + return &Config{ + Server: ServerConfig{ + Port: port, + CommitSHA: getEnv("COMMIT_SHA", "unknown"), + }, + Database: DatabaseConfig{ + Host: getEnv("DB_HOST", "localhost"), + Port: dbPort, + User: getEnv("DB_USER", "postgres"), + Password: getEnv("DB_PASSWORD", "postgres"), + Database: getEnv("DB_NAME", "itemsdb"), + SSLMode: getEnv("DB_SSLMODE", "disable"), + }, + OTEL: OTELConfig{ + Enabled: otelEnabled, + Endpoint: getEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318"), + ServiceName: getEnv("OTEL_SERVICE_NAME", "semcache-service"), + }, + }, nil +} + +// getEnv gets an environment variable or returns a default value +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +// getEnvAsInt gets an environment variable as an integer or returns a default value +func getEnvAsInt(key string, defaultValue int) (int, error) { + valueStr := os.Getenv(key) + if valueStr == "" { + return defaultValue, nil + } + value, err := strconv.Atoi(valueStr) + if err != nil { + return 0, err + } + return value, nil +} + +// getEnvAsBool gets an environment variable as a boolean or returns a default value +func getEnvAsBool(key string, defaultValue bool) (bool, error) { + valueStr := os.Getenv(key) + if valueStr == "" { + return defaultValue, nil + } + value, err := strconv.ParseBool(valueStr) + if err != nil { + return false, err + } + return value, nil +} + +// ConnectionString returns the PostgreSQL connection string +func (c *DatabaseConfig) ConnectionString() string { + return fmt.Sprintf( + "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", + c.Host, c.Port, c.User, c.Password, c.Database, c.SSLMode, + ) +} + diff --git a/apps/semcache-service/internal/database/database.go b/apps/semcache-service/internal/database/database.go new file mode 100644 index 0000000..24b44ed --- /dev/null +++ b/apps/semcache-service/internal/database/database.go @@ -0,0 +1,82 @@ +package database + +import ( + "context" + "database/sql" + "fmt" + "log" + "time" + + _ "github.com/lib/pq" + "github.com/nextinterfaces/semcache-service/internal/config" +) + +// DB wraps the database connection +type DB struct { + *sql.DB +} + +// New creates a new database connection +func New(cfg *config.DatabaseConfig) (*DB, error) { + connStr := cfg.ConnectionString() + + db, err := sql.Open("postgres", connStr) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + + // Set connection pool settings + db.SetMaxOpenConns(25) + db.SetMaxIdleConns(5) + db.SetConnMaxLifetime(5 * time.Minute) + + // Test the connection + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := db.PingContext(ctx); err != nil { + return nil, fmt.Errorf("failed to ping database: %w", err) + } + + log.Printf("Connected to database: %s:%d/%s", cfg.Host, cfg.Port, cfg.Database) + + return &DB{db}, nil +} + +// InitSchema initializes the database schema +func (db *DB) InitSchema(ctx context.Context) error { + query := ` + CREATE TABLE IF NOT EXISTS semcache ( + id SERIAL PRIMARY KEY, + key VARCHAR(255) NOT NULL, + value TEXT NOT NULL, + metadata TEXT, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + expires_at TIMESTAMP, + CONSTRAINT unique_key UNIQUE (key) + ); + + CREATE INDEX IF NOT EXISTS idx_semcache_key ON semcache(key); + CREATE INDEX IF NOT EXISTS idx_semcache_expires_at ON semcache(expires_at); + CREATE INDEX IF NOT EXISTS idx_semcache_metadata ON semcache(metadata); + ` + + _, err := db.ExecContext(ctx, query) + if err != nil { + return fmt.Errorf("failed to create schema: %w", err) + } + + log.Println("Database schema initialized") + return nil +} + +// HealthCheck checks if the database is healthy +func (db *DB) HealthCheck(ctx context.Context) error { + return db.PingContext(ctx) +} + +// Close closes the database connection +func (db *DB) Close() error { + return db.DB.Close() +} + diff --git a/apps/semcache-service/internal/handlers/handlers.go b/apps/semcache-service/internal/handlers/handlers.go new file mode 100644 index 0000000..caab449 --- /dev/null +++ b/apps/semcache-service/internal/handlers/handlers.go @@ -0,0 +1,103 @@ +package handlers + +import ( + "context" + "net/http" + "time" + + "github.com/labstack/echo/v4" + "github.com/nextinterfaces/semcache-service/internal/models" +) + +type Handler struct { + cacheRepo *models.CacheRepository + commitSHA string +} + +func New(cacheRepo *models.CacheRepository, commitSHA string) *Handler { + return &Handler{ + cacheRepo: cacheRepo, + commitSHA: commitSHA, + } +} + +type HealthResponse struct { + Status string `json:"status"` + Timestamp string `json:"timestamp"` + CommitSHA string `json:"commit_sha"` + Database string `json:"database"` +} + +func (h *Handler) Health(c echo.Context) error { + ctx, cancel := context.WithTimeout(c.Request().Context(), 2*time.Second) + defer cancel() + + dbStatus := "healthy" + err := h.cacheRepo.HealthCheck(ctx) + if err != nil { + dbStatus = "unhealthy" + } + + response := HealthResponse{ + Status: "ok", + Timestamp: time.Now().UTC().Format(time.RFC3339), + CommitSHA: h.commitSHA, + Database: dbStatus, + } + + return c.JSON(http.StatusOK, response) +} + +func (h *Handler) Create(c echo.Context) error { + var req models.CreateRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "Invalid request body", + }) + } + + if req.Key == "" { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "Key is required", + }) + } + + if req.Value == "" { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "Value is required", + }) + } + + ctx, cancel := context.WithTimeout(c.Request().Context(), 5*time.Second) + defer cancel() + + entry, err := h.cacheRepo.Create(ctx, req) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": "Failed to create cache entry", + }) + } + + return c.JSON(http.StatusCreated, entry) +} + +func (h *Handler) Search(c echo.Context) error { + var req models.SearchRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{ + "error": "Invalid request body", + }) + } + + ctx, cancel := context.WithTimeout(c.Request().Context(), 5*time.Second) + defer cancel() + + entries, err := h.cacheRepo.Search(ctx, req) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": "Failed to search cache entries", + }) + } + + return c.JSON(http.StatusOK, entries) +} diff --git a/apps/semcache-service/internal/handlers/swagger.go b/apps/semcache-service/internal/handlers/swagger.go new file mode 100644 index 0000000..234a549 --- /dev/null +++ b/apps/semcache-service/internal/handlers/swagger.go @@ -0,0 +1,78 @@ +package handlers + +import ( + "net/http" + "os" + "path/filepath" + + "github.com/labstack/echo/v4" +) + +// ServeOpenAPISpec serves the OpenAPI specification file +func (h *Handler) ServeOpenAPISpec(c echo.Context) error { + // Read the OpenAPI spec file + specPath := filepath.Join("api", "openapi.yaml") + data, err := os.ReadFile(specPath) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{ + "error": "Failed to read OpenAPI specification", + }) + } + + return c.Blob(http.StatusOK, "application/yaml", data) +} + +// ServeSwaggerUI serves the Swagger UI HTML page +func (h *Handler) ServeSwaggerUI(c echo.Context) error { + html := ` + + + + + Semcache Service API Documentation + + + + +
+ + + + +` + + return c.HTML(http.StatusOK, html) +} + diff --git a/apps/semcache-service/internal/models/cache.go b/apps/semcache-service/internal/models/cache.go new file mode 100644 index 0000000..ca4f2b8 --- /dev/null +++ b/apps/semcache-service/internal/models/cache.go @@ -0,0 +1,143 @@ +package models + +import ( + "context" + "database/sql" + "fmt" + "time" +) + +// CacheEntry represents a semantic cache entry +type CacheEntry struct { + ID int `json:"id"` + Key string `json:"key"` + Value string `json:"value"` + Metadata string `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` +} + +// CreateRequest represents the request to create a cache entry +type CreateRequest struct { + Key string `json:"key" validate:"required"` + Value string `json:"value" validate:"required"` + Metadata string `json:"metadata,omitempty"` + TTL *int `json:"ttl,omitempty"` // TTL in seconds +} + +// SearchRequest represents the request to search cache entries +type SearchRequest struct { + Key string `json:"key,omitempty"` + Metadata string `json:"metadata,omitempty"` + Limit int `json:"limit,omitempty"` +} + +// CacheRepository handles database operations for cache entries +type CacheRepository struct { + db *sql.DB +} + +// NewCacheRepository creates a new cache repository +func NewCacheRepository(db *sql.DB) *CacheRepository { + return &CacheRepository{db: db} +} + +// Create creates a new cache entry +func (r *CacheRepository) Create(ctx context.Context, req CreateRequest) (*CacheEntry, error) { + var expiresAt *time.Time + if req.TTL != nil && *req.TTL > 0 { + expiry := time.Now().Add(time.Duration(*req.TTL) * time.Second) + expiresAt = &expiry + } + + query := ` + INSERT INTO semcache (key, value, metadata, expires_at) + VALUES ($1, $2, $3, $4) + RETURNING id, key, value, metadata, created_at, expires_at + ` + + entry := &CacheEntry{} + err := r.db.QueryRowContext(ctx, query, req.Key, req.Value, req.Metadata, expiresAt).Scan( + &entry.ID, + &entry.Key, + &entry.Value, + &entry.Metadata, + &entry.CreatedAt, + &entry.ExpiresAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to create cache entry: %w", err) + } + + return entry, nil +} + +// Search searches for cache entries based on criteria +func (r *CacheRepository) Search(ctx context.Context, req SearchRequest) ([]*CacheEntry, error) { + limit := req.Limit + if limit <= 0 || limit > 100 { + limit = 100 + } + + query := ` + SELECT id, key, value, metadata, created_at, expires_at + FROM semcache + WHERE (expires_at IS NULL OR expires_at > NOW()) + ` + args := []interface{}{} + argCount := 0 + + if req.Key != "" { + argCount++ + query += fmt.Sprintf(" AND key ILIKE $%d", argCount) + args = append(args, "%"+req.Key+"%") + } + + if req.Metadata != "" { + argCount++ + query += fmt.Sprintf(" AND metadata ILIKE $%d", argCount) + args = append(args, "%"+req.Metadata+"%") + } + + argCount++ + query += fmt.Sprintf(" ORDER BY created_at DESC LIMIT $%d", argCount) + args = append(args, limit) + + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("failed to search cache entries: %w", err) + } + defer rows.Close() + + var entries []*CacheEntry + for rows.Next() { + entry := &CacheEntry{} + err := rows.Scan( + &entry.ID, + &entry.Key, + &entry.Value, + &entry.Metadata, + &entry.CreatedAt, + &entry.ExpiresAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan cache entry: %w", err) + } + entries = append(entries, entry) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating cache entries: %w", err) + } + + return entries, nil +} + +// HealthCheck performs a simple query to check database connectivity +func (r *CacheRepository) HealthCheck(ctx context.Context) error { + query := "SELECT 1" + var result int + err := r.db.QueryRowContext(ctx, query).Scan(&result) + return err +} + diff --git a/infra/k8s/apps/semcache-service-deployment.yaml b/infra/k8s/apps/semcache-service-deployment.yaml new file mode 100644 index 0000000..7b77716 --- /dev/null +++ b/infra/k8s/apps/semcache-service-deployment.yaml @@ -0,0 +1,97 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: semcache-service + namespace: default + labels: + app: semcache-service +spec: + replicas: 1 + selector: + matchLabels: + app: semcache-service + template: + metadata: + labels: + app: semcache-service + spec: + containers: + - name: semcache-service + image: ghcr.io/nextinterfaces/semcache-service:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 8080 + name: http + env: + - name: PORT + value: "8080" + - name: COMMIT_SHA + value: "unknown" + # PostgreSQL connection + - name: DB_HOST + value: "postgres" + - name: DB_PORT + value: "5432" + - name: DB_USER + valueFrom: + secretKeyRef: + name: postgres-secret + key: POSTGRES_USER + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: postgres-secret + key: POSTGRES_PASSWORD + - name: DB_NAME + valueFrom: + secretKeyRef: + name: postgres-secret + key: POSTGRES_DB + - name: DB_SSLMODE + value: "disable" + # OpenTelemetry configuration + - name: OTEL_ENABLED + value: "true" + - name: OTEL_EXPORTER_OTLP_ENDPOINT + value: "jaeger:4318" + - name: OTEL_SERVICE_NAME + value: "semcache-service" + resources: + requests: + memory: "64Mi" + cpu: "50m" + limits: + memory: "256Mi" + cpu: "500m" + livenessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 5 + readinessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 3 +--- +apiVersion: v1 +kind: Service +metadata: + name: semcache-service + namespace: default + labels: + app: semcache-service +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 8080 + protocol: TCP + name: http + selector: + app: semcache-service + diff --git a/infra/k8s/local/semcache-service-local.yaml b/infra/k8s/local/semcache-service-local.yaml new file mode 100644 index 0000000..33cfae6 --- /dev/null +++ b/infra/k8s/local/semcache-service-local.yaml @@ -0,0 +1,96 @@ +# Semcache Service for local development +apiVersion: apps/v1 +kind: Deployment +metadata: + name: semcache-service + namespace: default + labels: + app: semcache-service +spec: + replicas: 1 + selector: + matchLabels: + app: semcache-service + template: + metadata: + labels: + app: semcache-service + spec: + containers: + - name: semcache-service + image: semcache-service + imagePullPolicy: IfNotPresent + ports: + - containerPort: 8080 + name: http + env: + - name: PORT + value: "8080" + - name: COMMIT_SHA + value: "local-dev" + # PostgreSQL connection + - name: DB_HOST + value: "postgres" + - name: DB_PORT + value: "5432" + - name: DB_USER + valueFrom: + secretKeyRef: + name: postgres-secret + key: POSTGRES_USER + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: postgres-secret + key: POSTGRES_PASSWORD + - name: DB_NAME + valueFrom: + secretKeyRef: + name: postgres-secret + key: POSTGRES_DB + - name: DB_SSLMODE + value: "disable" + # OpenTelemetry configuration + - name: OTEL_ENABLED + value: "true" + - name: OTEL_EXPORTER_OTLP_ENDPOINT + value: "jaeger:4318" + - name: OTEL_SERVICE_NAME + value: "semcache-service" + resources: + requests: + memory: "64Mi" + cpu: "50m" + limits: + memory: "128Mi" + cpu: "200m" + livenessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /health + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 5 +--- +apiVersion: v1 +kind: Service +metadata: + name: semcache-service + namespace: default + labels: + app: semcache-service +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 8080 + protocol: TCP + name: http + selector: + app: semcache-service + diff --git a/scripts/k3d-setup.sh b/scripts/k3d-setup.sh index 9b6b7ac..4fc460b 100755 --- a/scripts/k3d-setup.sh +++ b/scripts/k3d-setup.sh @@ -45,9 +45,31 @@ echo -e "${BLUE}🎯 Next Steps:${NC}" echo -e " 1. Start Tilt: ${GREEN}tilt up${NC}" echo -e " 2. Open Tilt UI in browser (press space in Tilt)" echo -e " 3. Access services:" -echo -e " - PostgreSQL: ${GREEN}localhost:5432${NC}" -echo -e " - Items Service: ${GREEN}http://localhost:8081${NC}" -echo -e " - Website App: ${GREEN}http://localhost:8082${NC}" +echo "" +echo -e " ${BLUE}📦 Infrastructure:${NC}" +echo -e " - PostgreSQL: ${GREEN}localhost:5432${NC}" +echo "" +echo -e " ${BLUE}🚀 Applications:${NC}" +echo -e " - Items Service: ${GREEN}http://localhost:8081${NC}" +echo -e " • Health: ${GREEN}http://localhost:8081/v1/health${NC}" +echo -e " • API Docs: ${GREEN}http://localhost:8081/docs${NC}" +echo -e " • Metrics: ${GREEN}http://localhost:8081/metrics${NC}" +echo "" +echo -e " - Website App: ${GREEN}http://localhost:8082${NC}" +echo -e " • Health: ${GREEN}http://localhost:8082/health${NC}" +echo "" +echo -e " - Hello Service: ${GREEN}http://localhost:8083${NC}" +echo -e " • Health: ${GREEN}http://localhost:8083/health${NC}" +echo -e " • API: ${GREEN}http://localhost:8083/v1/greetings${NC}" +echo "" +echo -e " ${BLUE}👁️ Dashboard:${NC}" +echo -e " - Headlamp: ${GREEN}http://localhost:8084${NC} (Read-Only)" +echo "" +echo -e " ${BLUE}📊 Observability:${NC}" +echo -e " - Jaeger UI: ${GREEN}http://localhost:16686${NC}" +echo -e " - Prometheus: ${GREEN}http://localhost:9090${NC}" +echo -e " - Loki: ${GREEN}http://localhost:3100${NC}" +echo -e " - Grafana: ${GREEN}http://localhost:3000${NC} (Metrics + Logs)" echo "" echo -e "${YELLOW}💡 Tip: Run 'tilt down' to stop all services${NC}" echo ""