diff --git a/README.md b/README.md index 37c5b5b..8f9739c 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,33 @@ Authentication is HTTP **Basic auth**, as requested, scoped per tenant. --- +## Run with Docker (quickest — no local toolchain) + +A new developer needs **only Docker Desktop** — no local Go, Node, or +PostgreSQL. One script sets up, starts and stops the whole stack: + +```powershell +# one-time: set the DB password the containers will use +cp backend\.env.example backend\.env # then edit DB_PASSWORD + +.\docker.ps1 setup # build images, start everything, seed the database +.\docker.ps1 stop # stop (keeps data) +.\docker.ps1 start # start again +.\docker.ps1 reset # wipe data and re-seed with fresh flight dates +.\docker.ps1 logs -Follow +``` + +Then open **http://localhost:4200** (nginx serves the SPA and proxies `/api` to +the Go API on `:8080`). Sign in with any demo account, password `demo1234`. + +The database is created and seeded on first `setup` via the migrations mounted +into PostgreSQL. Don't run the native `app.ps1` at the same time — it uses the +same ports. + +For local development without containers, use the native setup below. + +--- + ## Prerequisites | Tool | Version used here | diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..64e79f1 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,11 @@ +# Keep secrets and local artifacts out of the build context and image layers. +.env +*.env +!.env.example +bin/ +coverage +coverage.out +*.exe +*.test +Dockerfile +.dockerignore diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..17adad4 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,20 @@ +# --- build ----------------------------------------------------------------- +# Static Go binary so the runtime image can be distroless (no shell, tiny, +# minimal attack surface). pgx is pure Go, so CGO can be disabled. +FROM golang:1.26 AS build +WORKDIR /src + +# Cache modules separately from source for faster rebuilds. +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/api ./cmd/api + +# --- runtime --------------------------------------------------------------- +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /out/api /api +EXPOSE 8080 +# Config comes from the environment (DB_HOST, DB_PASSWORD, PORT, …), set by +# docker-compose. No .env file is baked into the image. +ENTRYPOINT ["/api"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4093b95 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,57 @@ +# TravelHub full stack on Docker. Drive it with docker.ps1 (which supplies the +# DB_* values from backend/.env), or run docker compose directly after exporting +# DB_USER / DB_PASSWORD / DB_NAME yourself. +# +# PostgreSQL internal only (data in the pgdata volume) +# api Go API -> http://localhost:8080 +# web Angular via nginx -> http://localhost:4200 (proxies /api to api) + +services: + postgres: + image: postgres:17 + environment: + POSTGRES_USER: ${DB_USER:-postgres} + POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD (docker.ps1 reads it from backend/.env)} + POSTGRES_DB: ${DB_NAME:-travelhub} + volumes: + - pgdata:/var/lib/postgresql/data + # Migrations run once, on first creation of an empty data directory, in + # order (001_init, 002_seed, 003_demo_bookings). The seed generates + # flights relative to now(), so a fresh volume always has future-dated + # inventory. `docker.ps1 reset` wipes the volume to re-seed. + - ./backend/migrations:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-travelhub}'] + interval: 5s + timeout: 5s + retries: 12 + restart: unless-stopped + + api: + build: ./backend + environment: + DB_HOST: postgres + DB_PORT: '5432' + DB_USER: ${DB_USER:-postgres} + DB_PASSWORD: ${DB_PASSWORD} + DB_NAME: ${DB_NAME:-travelhub} + DB_SSLMODE: disable + PORT: '8080' + CORS_ORIGINS: http://localhost:4200 + depends_on: + postgres: + condition: service_healthy + ports: + - '8080:8080' + restart: unless-stopped + + web: + build: ./frontend + depends_on: + - api + ports: + - '4200:80' + restart: unless-stopped + +volumes: + pgdata: diff --git a/docker.ps1 b/docker.ps1 new file mode 100644 index 0000000..e70046b --- /dev/null +++ b/docker.ps1 @@ -0,0 +1,197 @@ +#requires -Version 5.1 +<# +.SYNOPSIS + Set up, start and stop the entire TravelHub stack on Docker. A new developer + needs only Docker Desktop -- no local Go, Node, or PostgreSQL. + +.DESCRIPTION + Wraps docker compose for the three services (PostgreSQL + Go API + Angular + via nginx). Database credentials are read from backend/.env, so there is one + source of truth and no secret is duplicated. + + URLs once up: + App -> http://localhost:4200 (nginx serves the SPA and proxies /api) + API -> http://localhost:8080 + + Demo sign-in: any seeded account, password demo1234 + (e.g. agent@skyline.test, agency "skyline"). + +.PARAMETER Action + setup Build images and bring everything up, waiting until healthy. The + database is created and seeded on this first run. One command to + onboard. + start Bring the stack up (images already built). + stop Stop and remove the containers; the database volume is kept. + restart stop then start. + reset Stop and DELETE the database volume, then start fresh (re-seeds). + rebuild Rebuild images (use after pulling code changes) and start. + status Show container state. + logs Tail logs (-Follow to stream; -Service to pick one). + +.PARAMETER Service + logs: which service (postgres | api | web). Default: all. + +.PARAMETER Follow + logs: stream instead of printing the tail once. + +.EXAMPLE + .\docker.ps1 setup +.EXAMPLE + .\docker.ps1 logs -Service api -Follow +.EXAMPLE + .\docker.ps1 reset # wipe data and re-seed with fresh flight dates +#> +[CmdletBinding()] +param( + [Parameter(Position = 0)] + [ValidateSet('setup', 'start', 'stop', 'restart', 'reset', 'rebuild', 'status', 'logs', 'help')] + [string]$Action = 'help', + + [ValidateSet('postgres', 'api', 'web')] + [string]$Service, + + [switch]$Follow +) + +$ErrorActionPreference = 'Stop' +$Root = $PSScriptRoot + +function Write-Step($m) { Write-Host "==> $m" -ForegroundColor Cyan } +function Write-Ok($m) { Write-Host " + $m" -ForegroundColor Green } +function Write-Warn2($m){ Write-Host " ! $m" -ForegroundColor Yellow } + +# --- docker availability --------------------------------------------------- + +function Require-Docker { + if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { + throw "Docker not found. Install Docker Desktop and ensure it is running: https://www.docker.com/products/docker-desktop/" + } + # `docker info` fails fast if the engine/daemon is not running. + docker info *> $null + if ($LASTEXITCODE -ne 0) { + throw 'Docker is installed but the engine is not running. Start Docker Desktop and retry.' + } + # Compose v2 (the `docker compose` subcommand) is required. + docker compose version *> $null + if ($LASTEXITCODE -ne 0) { + throw "This script needs Docker Compose v2 (the 'docker compose' subcommand). Update Docker Desktop." + } +} + +# --- credentials from backend/.env ----------------------------------------- + +# Load DB_USER / DB_PASSWORD / DB_NAME from backend/.env into this process so +# docker compose can substitute them. Nothing is printed or copied to disk. +function Import-DbEnv { + $envFile = Join-Path $Root 'backend\.env' + if (-not (Test-Path $envFile)) { + throw "backend\.env not found. Copy backend\.env.example to backend\.env and set DB_PASSWORD first." + } + foreach ($line in Get-Content $envFile) { + $t = $line.Trim() + if ($t -eq '' -or $t.StartsWith('#')) { continue } + $i = $t.IndexOf('=') + if ($i -lt 1) { continue } + $key = $t.Substring(0, $i).Trim() + $val = $t.Substring($i + 1).Trim().Trim('"').Trim("'") + if ($key -in @('DB_USER', 'DB_PASSWORD', 'DB_NAME')) { + Set-Item -Path "Env:$key" -Value $val + } + } + if (-not $env:DB_PASSWORD) { + throw 'DB_PASSWORD is not set in backend\.env.' + } + if (-not $env:DB_USER) { $env:DB_USER = 'postgres' } + if (-not $env:DB_NAME) { $env:DB_NAME = 'travelhub' } +} + +# --- compose helpers ------------------------------------------------------- + +function Compose { + param([Parameter(ValueFromRemainingArguments = $true)] $ComposeArgs) + Push-Location $Root + try { & docker compose @ComposeArgs } finally { Pop-Location } +} + +function Wait-ForUrl($url, $timeoutSec) { + $deadline = (Get-Date).AddSeconds($timeoutSec) + while ((Get-Date) -lt $deadline) { + try { Invoke-WebRequest $url -UseBasicParsing -TimeoutSec 3 | Out-Null; return $true } + catch { Start-Sleep -Seconds 2 } + } + return $false +} + +function Show-Ready { + Write-Host '' + if (Wait-ForUrl 'http://localhost:8080/api/health' 90) { Write-Ok 'API healthy -> http://localhost:8080' } + else { Write-Warn2 'API not healthy yet -- check: .\docker.ps1 logs -Service api' } + if (Wait-ForUrl 'http://localhost:4200/' 60) { Write-Ok 'App ready -> http://localhost:4200' } + else { Write-Warn2 'Web not ready yet -- check: .\docker.ps1 logs -Service web' } + Write-Host '' + Write-Host 'Sign in with any demo account, password demo1234 (e.g. agent@skyline.test / skyline).' + Write-Host 'Note: stop any native .\app.ps1 first -- it uses the same ports.' -ForegroundColor DarkGray +} + +# --- actions --------------------------------------------------------------- + +switch ($Action) { + 'setup' { + Require-Docker; Import-DbEnv + Write-Step 'Building images (first run downloads base images)' + Compose build + Write-Step 'Starting stack and seeding the database' + Compose up -d + Show-Ready + Write-Ok 'Setup complete.' + } + 'start' { + Require-Docker; Import-DbEnv + Write-Step 'Starting stack' + Compose up -d + Show-Ready + } + 'stop' { + Require-Docker; Import-DbEnv + Write-Step 'Stopping stack (database volume kept)' + Compose down + Write-Ok 'Stopped.' + } + 'restart' { + Require-Docker; Import-DbEnv + Compose down + Compose up -d + Show-Ready + } + 'reset' { + Require-Docker; Import-DbEnv + Write-Warn2 'This DELETES the database volume. All data will be lost.' + Write-Step 'Removing containers and the data volume' + Compose down -v + Write-Step 'Starting fresh (re-seeds with current flight dates)' + Compose up -d + Show-Ready + Write-Ok 'Reset complete.' + } + 'rebuild' { + Require-Docker; Import-DbEnv + Write-Step 'Rebuilding images' + Compose build --no-cache + Compose up -d + Show-Ready + } + 'status' { + Require-Docker; Import-DbEnv + Compose ps + } + 'logs' { + Require-Docker; Import-DbEnv + $logArgs = @('logs') + if ($Follow) { $logArgs += '-f' } else { $logArgs += @('--tail', '80') } + if ($Service) { $logArgs += $Service } + Compose @logArgs + } + default { + Get-Help $PSCommandPath -Detailed + } +} diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..6bcbc95 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +.angular/ +.env +*.env +coverage/ +Dockerfile +.dockerignore diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..36980fb --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,17 @@ +# --- build ----------------------------------------------------------------- +FROM node:24-alpine AS build +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci + +COPY . . +RUN npm run build +# Angular's application builder emits the browser bundle to dist/frontend/browser. + +# --- runtime --------------------------------------------------------------- +FROM nginx:1.27-alpine +# Serve the SPA and proxy /api to the api service (same origin, so no CORS). +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist/frontend/browser /usr/share/nginx/html +EXPOSE 80 diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..af9c1e8 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,26 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # API calls go to the Go service over the compose network. Because the + # browser reaches both the app and /api on the same origin (localhost:4200), + # there is no CORS to configure. + location /api/ { + proxy_pass http://api:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # Pass through the auth and tenant headers the SPA sends. + proxy_pass_request_headers on; + } + + # Angular is a single-page app: unknown paths fall back to index.html so + # client-side routing works on reload / deep link. + location / { + try_files $uri $uri/ /index.html; + } +}