diff --git a/.env.example b/.env.example index cd4b619..e1ffdd0 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,12 @@ DOMAIN=localhost ACME_EMAIL=admin@localhost ENVIRONMENT=development +# Published host ports (change if they collide with other services) +HTTP_PORT=3000 +API_PORT=8082 +POSTGRES_PORT=5432 +REDIS_PORT=6379 + # Database Configuration POSTGRES_DB=containr POSTGRES_USER=containr_user diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f0f260..fb7ba35 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,16 @@ on: push: branches: - main + tags: + - "v*" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read jobs: infra: @@ -20,10 +30,17 @@ jobs: - name: Validate Docker Compose with .env.example run: docker compose -f infra/docker-compose.yml --env-file .env.example config -q + - name: Validate root Docker Compose config + run: docker compose -f docker-compose.yml config -q + + - name: Validate root Docker Compose with .env.example + run: docker compose -f docker-compose.yml --env-file .env.example config -q + - name: Validate startup scripts run: | bash -n start-unified.sh bash -n start.sh + bash -n install.sh - name: Run startup preflight tests run: bash scripts/test-start-unified-preflight.sh @@ -44,6 +61,9 @@ jobs: go-version-file: app/backend/go.mod cache: true + - name: Vet + run: go vet ./... + - name: Run backend tests run: go test ./... @@ -86,13 +106,14 @@ jobs: - name: Boot stack run: | - export BETTER_AUTH_SECRET=$(openssl rand -hex 32) - export BETTER_AUTH_INTERNAL_TOKEN=$(openssl rand -hex 32) + BETTER_AUTH_SECRET=$(openssl rand -hex 32) + BETTER_AUTH_INTERNAL_TOKEN=$(openssl rand -hex 32) + export BETTER_AUTH_SECRET BETTER_AUTH_INTERNAL_TOKEN docker compose -f infra/docker-compose.yml up -d --build postgres redis backend frontend traefik - name: Wait for backend health run: | - for i in $(seq 1 60); do + for _ in $(seq 1 60); do if curl -sf http://localhost:8082/health; then exit 0; fi sleep 5 done @@ -101,7 +122,7 @@ jobs: - name: Check frontend responds run: | - for i in $(seq 1 30); do + for _ in $(seq 1 30); do if curl -sf http://localhost:3000/ -o /dev/null; then exit 0; fi sleep 2 done @@ -112,35 +133,92 @@ jobs: if: always() run: docker compose -f infra/docker-compose.yml down -v - images: - name: Publish images to GHCR + docker: + name: Build & publish images runs-on: ubuntu-latest needs: [backend, frontend, smoke] - if: startsWith(github.ref, 'refs/tags/v') permissions: - packages: write contents: read + packages: write steps: - name: Checkout uses: actions/checkout@v4 + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Login to GHCR + if: github.event_name != 'pull_request' uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and push backend + - name: Backend image metadata + id: meta-backend + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }}-backend + tags: | + type=ref,event=branch + type=ref,event=pr + type=ref,event=tag + type=semver,pattern={{version}} + type=sha,format=short + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} + + - name: Frontend image metadata + id: meta-frontend + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }}-frontend + tags: | + type=ref,event=branch + type=ref,event=pr + type=ref,event=tag + type=semver,pattern={{version}} + type=sha,format=short + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} + + - name: Build backend image uses: docker/build-push-action@v6 with: context: app/backend - push: true - tags: ghcr.io/${{ github.repository }}-backend:${{ github.ref_name }},ghcr.io/${{ github.repository }}-backend:latest + load: true + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta-backend.outputs.tags }} + labels: ${{ steps.meta-backend.outputs.labels }} + cache-from: type=gha,scope=backend + cache-to: type=gha,mode=max,scope=backend - - name: Build and push frontend + - name: Build frontend image uses: docker/build-push-action@v6 with: context: app/frontend - push: true - tags: ghcr.io/${{ github.repository }}-frontend:${{ github.ref_name }},ghcr.io/${{ github.repository }}-frontend:latest + load: true + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta-frontend.outputs.tags }} + labels: ${{ steps.meta-frontend.outputs.labels }} + cache-from: type=gha,scope=frontend + cache-to: type=gha,mode=max,scope=frontend + + - name: Scan backend image + uses: aquasecurity/trivy-action@v0.36.0 + with: + image-ref: ${{ fromJSON(steps.meta-backend.outputs.json).tags[0] }} + format: table + exit-code: "0" + ignore-unfixed: true + vuln-type: os,library + severity: CRITICAL,HIGH + + - name: Scan frontend image + uses: aquasecurity/trivy-action@v0.36.0 + with: + image-ref: ${{ fromJSON(steps.meta-frontend.outputs.json).tags[0] }} + format: table + exit-code: "0" + ignore-unfixed: true + vuln-type: os,library + severity: CRITICAL,HIGH diff --git a/README.md b/README.md index c1f648b..fcf6168 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,11 @@ Contributing
+ + ## What is Containr? Containr is an open-source control plane for Docker. It gives you a @@ -68,14 +73,31 @@ docs/ Guides, OpenAPI spec, design documents ## Quick Start -### Docker Compose (everything) +### Easy install (recommended) + +Requires Docker with the Compose plugin, `openssl`, and `curl`. + +```bash +git clone https://github.com/Dvorinka/Containr.git +cd Containr +./install.sh +``` + +The script creates `.env` from `.env.example`, fills in all required secrets +with random values, builds the images, and starts the stack. It keeps an +existing `.env` untouched, so it is safe to re-run. + +Frontend at `http://localhost:3000`, API at `http://localhost:8082`. + +### Docker Compose (manual) ```bash cp .env.example .env # fill in secrets -docker compose up -d +docker compose up -d --build ``` -Frontend at `http://localhost:3000`, API at `http://localhost:8082`. +Host ports can be remapped in `.env` (`HTTP_PORT`, `API_PORT`, +`POSTGRES_PORT`, `REDIS_PORT`) if the defaults collide with other services. ### Local development diff --git a/docker-compose.yml b/docker-compose.yml index 836098d..87f7de4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,5 @@ +name: containr + services: postgres: image: postgres:15-alpine @@ -6,7 +8,7 @@ services: POSTGRES_USER: ${POSTGRES_USER:-containr_user} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-dev_password_123} ports: - - "5432:5432" + - "${POSTGRES_PORT:-5432}:5432" volumes: - postgres_data:/var/lib/postgresql/data healthcheck: @@ -20,7 +22,7 @@ services: image: redis:7-alpine command: redis-server --requirepass ${REDIS_PASSWORD:-dev_redis_123} ports: - - "6379:6379" + - "${REDIS_PORT:-6379}:6379" volumes: - redis_data:/data healthcheck: @@ -35,7 +37,7 @@ services: context: ./app/backend dockerfile: Dockerfile ports: - - "8082:8080" + - "${API_PORT:-8082}:8080" environment: DATABASE_URL: postgres://${POSTGRES_USER:-containr_user}:${POSTGRES_PASSWORD:-dev_password_123}@postgres:5432/${POSTGRES_DB:-containr}?sslmode=disable REDIS_URL: redis://:${REDIS_PASSWORD:-dev_redis_123}@redis:6379/0 @@ -84,6 +86,12 @@ services: redis: condition: service_healthy restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:8080/health"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 30s frontend: build: @@ -93,10 +101,10 @@ services: VITE_API_URL: ${VITE_API_URL:-http://localhost:8082} VITE_AUTH_URL: ${VITE_AUTH_URL:-http://localhost:8082/api/auth} ports: - - "3000:80" + - "${HTTP_PORT:-3000}:80" depends_on: backend: - condition: service_started + condition: service_healthy restart: unless-stopped volumes: diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..d0b85df --- /dev/null +++ b/install.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Containr one-shot installer: creates .env with generated secrets, builds +# images, boots the stack, and waits for the API to report healthy. +set -euo pipefail + +cd "$(dirname "$0")" + +info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } +err() { printf '\033[1;31mError:\033[0m %s\n' "$*" >&2; } + +# Dependencies +command -v docker >/dev/null 2>&1 || { err "docker not found. Install Docker: https://docs.docker.com/get-docker/"; exit 1; } +docker compose version >/dev/null 2>&1 || { err "docker compose (v2) not found. Update Docker or install the compose plugin."; exit 1; } +docker info >/dev/null 2>&1 || { err "docker daemon is not running or not reachable."; exit 1; } +command -v openssl >/dev/null 2>&1 || { err "openssl not found (needed to generate secrets)."; exit 1; } +command -v curl >/dev/null 2>&1 || { err "curl not found (needed for the health check)."; exit 1; } + +# Env file +if [[ -f .env ]]; then + info "Existing .env found - keeping it." +else + info "Creating .env from .env.example with generated secrets..." + cp .env.example .env + + gen() { openssl rand -hex 32; } + setvar() { # setvar KEY VALUE (| is safe: generated values are hex) + sed -i.bak "s|^$1=.*|$1=$2|" .env + } + + PG_PASS="$(gen)"; REDIS_PASS="$(gen)" + setvar JWT_SECRET "$(gen)" + setvar BETTER_AUTH_SECRET "$(gen)" + setvar BETTER_AUTH_INTERNAL_TOKEN "$(gen)" + setvar CONTAINR_AGENT_AUTH_TOKEN "$(gen)" + + # Password placeholders appear in several keys; replace them all so + # POSTGRES_PASSWORD, DATABASE_URL and DB_PASSWORD stay in sync. + sed -i.bak "s|your_secure_postgres_password|$PG_PASS|g" .env + sed -i.bak "s|your_secure_redis_password|$REDIS_PASS|g" .env + rm -f .env.bak + info ".env created with random secrets." +fi + +# Ports (overridable via environment, matching docker-compose.yml vars) +HTTP_PORT="${HTTP_PORT:-3000}" +API_PORT="${API_PORT:-8082}" + +# Build and boot +info "Building images and starting the stack..." +docker compose up -d --build + +# Wait for the API +info "Waiting for the API on http://localhost:${API_PORT}/health ..." +healthy="" +for _ in $(seq 1 90); do + if curl -sf "http://localhost:${API_PORT}/health" >/dev/null 2>&1; then + healthy=1 + break + fi + sleep 2 +done + +if [[ -z "$healthy" ]]; then + err "API did not become healthy. Inspect with: docker compose logs backend" + exit 1 +fi + +info "Containr is running." +echo " UI: http://localhost:${HTTP_PORT}" +echo " API: http://localhost:${API_PORT}" +echo "" +echo "Manage with: docker compose logs -f | docker compose down"