diff --git a/.forgejo/workflows/docker.yml b/.forgejo/workflows/docker.yml new file mode 100644 index 0000000..200184e --- /dev/null +++ b/.forgejo/workflows/docker.yml @@ -0,0 +1,86 @@ +name: Docker Build + +on: + push: + branches: [ main, docker ] + +env: + # Should speed up builds. + CARGO_INCREMENTAL: 0 + # Should reduce the size of ./target to improve cache load/store. + CARGO_PROFILE_TEST_DEBUG: 0 + +jobs: + docker-backend: + name: Build and Push Backend Image + runs-on: docker + steps: + - name: Checkout Repo + uses: https://github.com/actions/checkout@v4 + + - name: Set up Docker Buildx + uses: https://github.com/docker/setup-buildx-action@v3 + + - name: Login to Forgejo Container Registry + uses: https://github.com/docker/login-action@v3 + with: + registry: ${{ gitea.server_url }} + username: ${{ gitea.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: https://github.com/docker/metadata-action@v5 + with: + images: ${{ gitea.server_url }}/${{ gitea.repository }}/backend + tags: | + type=ref,event=branch + type=sha,prefix={{branch}}- + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push image + uses: https://github.com/docker/build-push-action@v5 + with: + context: ./backend + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + docker-frontend: + name: Build and Push Frontend Image + runs-on: docker + steps: + - name: Checkout Repo + uses: https://github.com/actions/checkout@v4 + + - name: Set up Docker Buildx + uses: https://github.com/docker/setup-buildx-action@v3 + + - name: Login to Forgejo Container Registry + uses: https://github.com/docker/login-action@v3 + with: + registry: ${{ gitea.server_url }} + username: ${{ gitea.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: https://github.com/docker/metadata-action@v5 + with: + images: ${{ gitea.server_url }}/${{ gitea.repository }}/frontend + tags: | + type=ref,event=branch + type=sha,prefix={{branch}}- + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push image + uses: https://github.com/docker/build-push-action@v5 + with: + context: ./frontend + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 8a21554..207f7de 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -289,6 +289,7 @@ dependencies = [ "serde", "sqlx", "tokio", + "tower-http", "tracing", "tracing-subscriber", "uuid", @@ -3490,6 +3491,20 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +dependencies = [ + "bitflags 2.9.3", + "bytes", + "http 1.3.1", + "pin-project-lite", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-layer" version = "0.3.3" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 46f000f..a4a0401 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -12,6 +12,7 @@ chrono = { version = "0.4.41", features = ["serde"] } serde = "1.0.219" sqlx = { version = "0.8.6", features = ["sqlite", "runtime-tokio", "uuid", "chrono"] } tokio = { version = "1.47.1", features = ["rt-multi-thread", "tracing"] } +tower-http = { version = "0.6.0", features = ["cors"] } tracing = "0.1.41" tracing-subscriber = "0.3.19" uuid = { version = "1.18.0", features = ["serde", "v4"] } diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..6ce2a7e --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,47 @@ +# Multi-stage Dockerfile for Rust backend +FROM rust:1.90-alpine as builder + +# Install build dependencies +RUN apk add --no-cache \ + musl-dev \ + pkgconfig \ + openssl-dev \ + clang + +# Set working directory +WORKDIR /app + +# Copy Cargo files for dependency caching +COPY Cargo.toml Cargo.lock ./ + +# Create a dummy main.rs to build dependencies +RUN mkdir src && echo "fn main() {}" > src/main.rs + +# Build dependencies (cached layer) +RUN cargo build --release && rm -rf src/ + +# Copy source code +COPY src/ src/ +COPY migrations/ migrations/ + +# Build the application +RUN cargo build --release + +# Runtime stage +FROM alpine:latest + +# Install runtime dependencies +RUN apk add --no-cache ca-certificates curl + +# Copy the binary from builder stage +COPY --from=builder /app/target/release/backend /usr/local/bin/app + +# Expose port +EXPOSE 3000 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:3000/health || exit 1 + +# Run the application +CMD ["app"] diff --git a/backend/src/main.rs b/backend/src/main.rs index 9ae1c80..635d45d 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,4 +1,5 @@ use axum::{Router, routing::get}; +use tower_http::cors::{Any, CorsLayer}; mod database; mod models; @@ -13,13 +14,33 @@ async fn main() { let pool = database::create_pool(&binding) .await .expect("Failed to create database pool"); + let cors = if std::env::var("RUST_ENV").unwrap_or_default() == "production" { + CorsLayer::new() + .allow_origin([ + "https://tiramisu.one" + .parse::() + .unwrap(), + "https://*.tiramisu.one" + .parse::() + .unwrap(), + ]) + .allow_methods(Any) + .allow_headers(Any) + } else { + CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any) + }; + let app = Router::new() .route("/health", get(health)) .nest("/api/tasks", services::create_task_router()) + .layer(cors) .with_state(pool); let port = std::env::var("PORT").unwrap_or("3000".to_string()); - let addr = format!("127.0.0.1:{}", port); + let addr = format!("0.0.0.0:{}", port); let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cfedf61 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,36 @@ +version: '3.8' + +services: + backend: + build: + context: ./backend + dockerfile: Dockerfile + ports: + - "3000:3000" + environment: + - DATABASE_URL=sqlite:///app/data/local.db + - RUST_LOG=info + volumes: + - backend_data:/app/data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + ports: + - "5173:5173" + depends_on: + backend: + condition: service_healthy + environment: + - VITE_API_URL=http://localhost:3000 + - API_URL=http://backend:3000 + +volumes: + backend_data: diff --git a/frontend/app/services/api.test.ts b/frontend/app/services/api.test.ts index 3853bc1..1eee218 100644 --- a/frontend/app/services/api.test.ts +++ b/frontend/app/services/api.test.ts @@ -52,9 +52,12 @@ describe('API Client', () => { const result = await apiClient.listTasks() - expect(mockFetch).toHaveBeenCalledWith('/api/tasks', { - headers: { 'Content-Type': 'application/json' }, - }) + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:3000/api/tasks', + { + headers: { 'Content-Type': 'application/json' }, + } + ) expect(result).toEqual(mockTasks) }) @@ -94,9 +97,12 @@ describe('API Client', () => { const result = await apiClient.getTask(mockTask.id) - expect(mockFetch).toHaveBeenCalledWith(`/api/tasks/${mockTask.id}`, { - headers: { 'Content-Type': 'application/json' }, - }) + expect(mockFetch).toHaveBeenCalledWith( + `http://localhost:3000/api/tasks/${mockTask.id}`, + { + headers: { 'Content-Type': 'application/json' }, + } + ) expect(result).toEqual(mockTask) }) @@ -130,11 +136,14 @@ describe('API Client', () => { const result = await apiClient.createTask(newTaskData) - expect(mockFetch).toHaveBeenCalledWith('/api/tasks', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(newTaskData), - }) + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:3000/api/tasks', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(newTaskData), + } + ) expect(result).toEqual(mockTask) }) @@ -174,11 +183,14 @@ describe('API Client', () => { const result = await apiClient.updateTask(mockTask.id, updateData) - expect(mockFetch).toHaveBeenCalledWith(`/api/tasks/${mockTask.id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(updateData), - }) + expect(mockFetch).toHaveBeenCalledWith( + `http://localhost:3000/api/tasks/${mockTask.id}`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updateData), + } + ) expect(result).toEqual(updatedTask) }) @@ -211,10 +223,13 @@ describe('API Client', () => { const result = await apiClient.deleteTask(mockTask.id) expect(result).toBeNull() - expect(mockFetch).toHaveBeenCalledWith(`/api/tasks/${mockTask.id}`, { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - }) + expect(mockFetch).toHaveBeenCalledWith( + `http://localhost:3000/api/tasks/${mockTask.id}`, + { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + } + ) }) it('should handle delete errors', async () => { diff --git a/frontend/app/services/api.ts b/frontend/app/services/api.ts index 6edffcb..3c91e8e 100644 --- a/frontend/app/services/api.ts +++ b/frontend/app/services/api.ts @@ -6,7 +6,7 @@ import type { ApiError, } from '~/types/task' -const API_BASE_URL = '/api' +const API_BASE_URL = `${import.meta.env.VITE_API_URL || 'http://localhost:3000'}/api` class ApiClient { private async fetchWrapper( diff --git a/frontend/package.json b/frontend/package.json index a0c2abe..058687d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,7 +5,7 @@ "scripts": { "build": "react-router build", "dev": "react-router dev", - "start": "react-router-serve ./build/server/index.js", + "start": "PORT=5173 react-router-serve ./build/server/index.js", "test": "vitest", "test:coverage": "vitest --coverage", "test:run": "vitest run", diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 5442d2b..42e9a8c 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -7,7 +7,7 @@ export default defineConfig({ server: { proxy: { "/api": { - target: "http://localhost:3000", + target: process.env.VITE_API_URL || "http://127.0.0.1:3000", changeOrigin: true, }, },