From 01a48c1a7823744df614ffd738b0412e29322755 Mon Sep 17 00:00:00 2001 From: RayaneHassani Date: Mon, 22 Jun 2026 12:10:39 +0200 Subject: [PATCH 01/10] feat(deploy): add VPS installer, Caddy reverse proxy and prod overlay - install.sh: bootstrap (Docker, ufw, fail2ban), generates .env with random secrets, builds the stack and waits for backend health - Caddyfile: reverse proxy with automatic HTTPS, domain from DOMAIN env - docker-compose.prod.yml: production overlay running Caddy on 80/443 - docker-compose.yml: bind app ports to 127.0.0.1 (only Caddy is public) - .gitignore: ignore per-instance config/ - .gitattributes: enforce LF line endings --- .gitattributes | 10 +++ .gitignore | 4 + Caddyfile | 31 +++++++ docker-compose.prod.yml | 33 ++++++++ docker-compose.yml | 8 +- install.sh | 175 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 259 insertions(+), 2 deletions(-) create mode 100644 .gitattributes create mode 100644 Caddyfile create mode 100644 docker-compose.prod.yml create mode 100755 install.sh diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2fa5e36 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +# Force LF line endings for files that run on the Linux VPS, so they +# work regardless of the contributor's OS / git autocrlf setting. +# (CRLF in a shell script breaks the shebang: "bad interpreter ^M".) +*.sh text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +Caddyfile text eol=lf +Makefile text eol=lf +Dockerfile text eol=lf +mvnw text eol=lf diff --git a/.gitignore b/.gitignore index d39aa33..5698607 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,10 @@ *.key *.crt +# Per-instance branding/config (never committed — survives git pull) +/config/* +!/config/.gitkeep + # OS .DS_Store .AppleDouble diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..d152c2b --- /dev/null +++ b/Caddyfile @@ -0,0 +1,31 @@ +# ───────────────────────────────────────────────────────────── +# Caddy reverse proxy — automatic HTTPS (Let's Encrypt). +# The domain is injected at runtime from the DOMAIN env var, so this +# file stays generic: every deployment sets its own DOMAIN in .env. +# Only the frontend is exposed; backend & postgres stay on the +# internal Docker network and are never reachable from the Internet. +# ───────────────────────────────────────────────────────────── + +{$DOMAIN} { + # Compress responses (zstd preferred, gzip fallback). + encode zstd gzip + + # All public traffic goes to the Next.js frontend over the internal + # Docker network. The frontend talks to the backend server-side. + reverse_proxy frontend:3000 + + # Structured access logs to stdout — ready to be shipped to Loki later. + log { + output stdout + format json + } + + # Baseline security headers. + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + -Server + } +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..a3dd031 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,33 @@ +# ───────────────────────────────────────────────────────────── +# Production overlay — adds the Caddy reverse proxy (automatic HTTPS). +# +# Usage: +# docker compose -f docker-compose.yml -f docker-compose.prod.yml up --build -d +# +# In the base file, backend (8080) and frontend (3000) are bound to +# 127.0.0.1 only, so they are NOT public. Caddy reaches the frontend +# through the internal "codestar-net" network (service name "frontend"). +# ───────────────────────────────────────────────────────────── +services: + caddy: + image: caddy:2-alpine + container_name: codestar-caddy + restart: unless-stopped + depends_on: + - frontend + ports: + - "80:80" + - "443:443" + - "443:443/udp" # HTTP/3 (QUIC) + environment: + DOMAIN: ${DOMAIN} + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data # certificates & ACME account (persisted) + - caddy_config:/config + networks: + - codestar-net + +volumes: + caddy_data: + caddy_config: diff --git a/docker-compose.yml b/docker-compose.yml index 26deb9b..18ea01d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -48,7 +48,9 @@ services: # uploaded course images (persisted across restarts) - media_data:/app/media ports: - - "${BACKEND_PORT:-8080}:8080" + # Bound to loopback only: never public. In prod, Caddy reaches the + # backend indirectly via the frontend over the internal network. + - "127.0.0.1:${BACKEND_PORT:-8080}:8080" healthcheck: test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/actuator/health"] interval: 10s @@ -77,7 +79,9 @@ services: JWT_SECRET: ${JWT_SECRET} SITE_URL: ${SITE_URL:-http://localhost:3000} ports: - - "${FRONTEND_PORT:-3000}:3000" + # Bound to loopback only: never public. Caddy (prod overlay) proxies + # to this service over the internal "codestar-net" network. + - "127.0.0.1:${FRONTEND_PORT:-3000}:3000" networks: - codestar-net diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..798d031 --- /dev/null +++ b/install.sh @@ -0,0 +1,175 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────── +# Codestar — VPS bootstrap installer (day 0, run once). +# +# What it does: +# 1. Installs Docker + compose plugin (if missing) +# 2. Hardens the host (ufw firewall + fail2ban) +# 3. Creates .env (random secrets + your answers) — never overwrites +# an existing .env, so your secrets and customization are safe +# 4. Builds and starts the full stack behind Caddy (automatic HTTPS) +# 5. Waits for health and prints the final URL +# +# Interactive: +# sudo ./install.sh +# +# Non-interactive (CI / repeatable): +# sudo DOMAIN=codestar.example.com \ +# ADMIN_EMAIL=you@example.com \ +# ADMIN_PASSWORD='a-strong-password' \ +# ADMIN_NAME='Admin' \ +# SIGNUP_OPEN=false \ +# ./install.sh +# +# Re-running is safe: an existing .env is reused as-is; the stack is +# rebuilt and restarted (your data lives in Docker volumes, untouched). +# ───────────────────────────────────────────────────────────── +set -euo pipefail + +# ── pretty logging ─────────────────────────────────────────── +c_blue=$'\033[1;34m'; c_green=$'\033[1;32m'; c_yellow=$'\033[1;33m'; c_red=$'\033[1;31m'; c_reset=$'\033[0m' +log() { printf '%s==>%s %s\n' "$c_blue" "$c_reset" "$*"; } +ok() { printf '%s✓%s %s\n' "$c_green" "$c_reset" "$*"; } +warn() { printf '%s!%s %s\n' "$c_yellow" "$c_reset" "$*"; } +err() { printf '%s✗%s %s\n' "$c_red" "$c_reset" "$*" >&2; } + +# Run from the repo root (directory of this script). +cd "$(dirname "$0")" + +# ── privilege handling ─────────────────────────────────────── +if [ "$(id -u)" -eq 0 ]; then SUDO=""; else SUDO="sudo"; fi +if [ -n "$SUDO" ] && ! command -v sudo >/dev/null 2>&1; then + err "Please run as root (sudo not available)."; exit 1 +fi + +# ── helpers ────────────────────────────────────────────────── +# ask VARNAME "prompt" [secret] [default] +# Uses an existing environment value if set; otherwise prompts (TTY required). +ask() { + local __var="$1" __prompt="$2" __secret="${3:-}" __default="${4:-}" + local __cur="${!__var:-}" + if [ -n "$__cur" ]; then return 0; fi + if [ ! -t 0 ]; then + if [ -n "$__default" ]; then printf -v "$__var" '%s' "$__default"; return 0; fi + err "Missing required value '$__var' and no TTY to prompt."; exit 1 + fi + if [ "$__secret" = "secret" ]; then + read -rsp "$__prompt: " "$__var"; echo + else + local __hint=""; [ -n "$__default" ] && __hint=" [$__default]" + read -rp "$__prompt$__hint: " "$__var" + [ -z "${!__var}" ] && [ -n "$__default" ] && printf -v "$__var" '%s' "$__default" + fi +} + +# set_env KEY VALUE [file] — replace or append KEY=VALUE (literal, no sed pitfalls) +set_env() { + local key="$1" val="$2" file="${3:-.env}" + if grep -qE "^${key}=" "$file" 2>/dev/null; then + grep -vE "^${key}=" "$file" > "$file.tmp" && mv "$file.tmp" "$file" + fi + printf '%s=%s\n' "$key" "$val" >> "$file" +} + +rand_hex() { openssl rand -hex "$1"; } + +# wait_healthy CONTAINER [tries] +wait_healthy() { + local name="$1" tries="${2:-60}" status + for _ in $(seq 1 "$tries"); do + status="$(docker inspect -f '{{.State.Health.Status}}' "$name" 2>/dev/null || echo missing)" + [ "$status" = "healthy" ] && return 0 + [ "$status" = "missing" ] && { sleep 5; continue; } + printf ' backend health: %s\n' "$status" + sleep 5 + done + return 1 +} + +# ── 1. Docker ──────────────────────────────────────────────── +log "Checking Docker…" +if ! command -v docker >/dev/null 2>&1; then + log "Docker not found — installing via get.docker.com" + curl -fsSL https://get.docker.com | $SUDO sh + ok "Docker installed" +else + ok "Docker present: $(docker --version)" +fi +if ! docker compose version >/dev/null 2>&1; then + err "Docker Compose v2 plugin missing. Install 'docker-compose-plugin' and re-run."; exit 1 +fi + +# ── 2. Host hardening (firewall + fail2ban) ────────────────── +log "Hardening host (ufw + fail2ban)…" +if command -v apt-get >/dev/null 2>&1; then + $SUDO apt-get update -qq + $SUDO apt-get install -y -qq ufw fail2ban openssl >/dev/null + $SUDO ufw allow 22/tcp >/dev/null 2>&1 || true + $SUDO ufw allow 80/tcp >/dev/null 2>&1 || true + $SUDO ufw allow 443/tcp >/dev/null 2>&1 || true + $SUDO ufw --force enable >/dev/null 2>&1 || true + $SUDO systemctl enable --now fail2ban >/dev/null 2>&1 || true + ok "Firewall (22/80/443) + fail2ban active" +else + warn "Non-apt system: skipping ufw/fail2ban (configure your firewall manually)." +fi + +# ── 3. .env ────────────────────────────────────────────────── +if [ -f .env ]; then + ok "Existing .env found — reusing it (no secrets overwritten)." + DOMAIN="$(grep -E '^DOMAIN=' .env | head -1 | cut -d= -f2- || true)" + [ -z "${DOMAIN:-}" ] && { err "Existing .env has no DOMAIN= line. Add it and re-run."; exit 1; } +else + log "Creating .env…" + [ -f .env.example ] || { err ".env.example missing — are you in the repo root?"; exit 1; } + cp .env.example .env + + ask DOMAIN "Domain name (e.g. codestar.example.com)" + ask ADMIN_EMAIL "Super-admin email" + ask ADMIN_PASSWORD "Super-admin password" secret + ask ADMIN_NAME "Super-admin display name" "" "Admin" + ask SIGNUP_OPEN "Open signups without invitation? (true/false)" "" "false" + + set_env DB_USER "codestar" + set_env DB_NAME "codestardb" + set_env DB_PASSWORD "$(rand_hex 24)" + set_env JWT_SECRET "$(rand_hex 48)" # 96 hex chars (>= 64 required) + set_env SITE_URL "https://${DOMAIN}" + set_env DOMAIN "${DOMAIN}" + set_env SIGNUP_OPEN "${SIGNUP_OPEN}" + set_env CODESTAR_BOOTSTRAP_SUPER_ADMIN_EMAIL "${ADMIN_EMAIL}" + set_env CODESTAR_BOOTSTRAP_SUPER_ADMIN_PASSWORD "${ADMIN_PASSWORD}" + set_env CODESTAR_BOOTSTRAP_SUPER_ADMIN_DISPLAY_NAME "${ADMIN_NAME}" + + ok ".env created (random DB_PASSWORD & JWT_SECRET generated)" +fi + +# ── 4. Build & start ───────────────────────────────────────── +log "Building and starting the stack (this can take a few minutes)…" +$SUDO docker compose -f docker-compose.yml -f docker-compose.prod.yml up --build -d + +# ── 5. Wait for health ─────────────────────────────────────── +log "Waiting for the backend to become healthy…" +if wait_healthy codestar-backend 60; then + ok "Backend healthy" +else + warn "Backend not healthy yet. Check logs: docker compose logs -f backend" +fi + +cat <} + + First-time HTTPS note: + Caddy fetches a Let's Encrypt certificate on first request. + It only works once your DNS A record points ${DOMAIN} -> this server, + and ports 80/443 are open. First load may take ~30s. + + Useful commands: + docker compose -f docker-compose.yml -f docker-compose.prod.yml logs -f + docker compose -f docker-compose.yml -f docker-compose.prod.yml ps +${c_green}────────────────────────────────────────────────${c_reset} +EOF From 7f4c2b32952fdaec7e0eaf2891669cc2bdd3d2e4 Mon Sep 17 00:00:00 2001 From: RayaneHassani Date: Tue, 23 Jun 2026 19:45:21 +0200 Subject: [PATCH 02/10] fix(install): prevent early exit on first prompt under set -e - ask(): always return 0 (a non-empty answer with no default made the trailing test return non-zero, which set -e turned into an exit) - create .env only after all prompts succeed (no half-written file) --- install.sh | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/install.sh b/install.sh index 798d031..5cac366 100755 --- a/install.sh +++ b/install.sh @@ -47,8 +47,7 @@ fi # Uses an existing environment value if set; otherwise prompts (TTY required). ask() { local __var="$1" __prompt="$2" __secret="${3:-}" __default="${4:-}" - local __cur="${!__var:-}" - if [ -n "$__cur" ]; then return 0; fi + if [ -n "${!__var:-}" ]; then return 0; fi if [ ! -t 0 ]; then if [ -n "$__default" ]; then printf -v "$__var" '%s' "$__default"; return 0; fi err "Missing required value '$__var' and no TTY to prompt."; exit 1 @@ -58,8 +57,9 @@ ask() { else local __hint=""; [ -n "$__default" ] && __hint=" [$__default]" read -rp "$__prompt$__hint: " "$__var" - [ -z "${!__var}" ] && [ -n "$__default" ] && printf -v "$__var" '%s' "$__default" + if [ -z "${!__var}" ] && [ -n "$__default" ]; then printf -v "$__var" '%s' "$__default"; fi fi + return 0 } # set_env KEY VALUE [file] — replace or append KEY=VALUE (literal, no sed pitfalls) @@ -122,7 +122,6 @@ if [ -f .env ]; then else log "Creating .env…" [ -f .env.example ] || { err ".env.example missing — are you in the repo root?"; exit 1; } - cp .env.example .env ask DOMAIN "Domain name (e.g. codestar.example.com)" ask ADMIN_EMAIL "Super-admin email" @@ -130,6 +129,9 @@ else ask ADMIN_NAME "Super-admin display name" "" "Admin" ask SIGNUP_OPEN "Open signups without invitation? (true/false)" "" "false" + # Create .env only after all answers are collected (no half-written file). + cp .env.example .env + set_env DB_USER "codestar" set_env DB_NAME "codestardb" set_env DB_PASSWORD "$(rand_hex 24)" From 972ddea0db223339d4da5772093a60e4f53ad949 Mon Sep 17 00:00:00 2001 From: RayaneHassani Date: Tue, 23 Jun 2026 22:07:07 +0200 Subject: [PATCH 03/10] feat(deploy): add update.sh with DB backup and auto-rollback - update.sh: pull, rebuild, health-check; on failure rolls back code and restores the pre-update database dump. Flags: --yes, --no-backup. Single-instance lock; keeps the last 7 DB backups. - deploy/systemd: optional service + timer for scheduled local updates - .gitignore: ignore /backups/ --- .gitignore | 3 + deploy/systemd/codestar-update.service | 22 ++++ deploy/systemd/codestar-update.timer | 15 +++ update.sh | 140 +++++++++++++++++++++++++ 4 files changed, 180 insertions(+) create mode 100644 deploy/systemd/codestar-update.service create mode 100644 deploy/systemd/codestar-update.timer create mode 100755 update.sh diff --git a/.gitignore b/.gitignore index 5698607..7d68508 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ /config/* !/config/.gitkeep +# Database backups produced by update.sh (stay on the host only) +/backups/ + # OS .DS_Store .AppleDouble diff --git a/deploy/systemd/codestar-update.service b/deploy/systemd/codestar-update.service new file mode 100644 index 0000000..7b2c016 --- /dev/null +++ b/deploy/systemd/codestar-update.service @@ -0,0 +1,22 @@ +# Codestar — auto-update unit (oneshot, triggered by codestar-update.timer). +# +# Install (edit User and the repo path to match your setup): +# sudo cp deploy/systemd/codestar-update.* /etc/systemd/system/ +# sudo systemctl daemon-reload +# sudo systemctl enable --now codestar-update.timer +# +# Inspect: +# systemctl list-timers codestar-update.timer +# journalctl -u codestar-update.service -f +[Unit] +Description=Codestar instance auto-update +Wants=network-online.target +After=network-online.target docker.service + +[Service] +Type=oneshot +User=ray +WorkingDirectory=/home/ray/codestar +ExecStart=/home/ray/codestar/update.sh --yes +# Don't let a deploy hang forever. +TimeoutStartSec=1800 diff --git a/deploy/systemd/codestar-update.timer b/deploy/systemd/codestar-update.timer new file mode 100644 index 0000000..2cba7fc --- /dev/null +++ b/deploy/systemd/codestar-update.timer @@ -0,0 +1,15 @@ +# Codestar — schedule for the auto-update service. +# Default: every 15 minutes. Adjust OnCalendar to taste, e.g. "*-*-* 03:00:00" +# for a nightly update at 3am. +[Unit] +Description=Run Codestar auto-update on a schedule + +[Timer] +OnCalendar=*:0/15 +# Run a missed update if the VPS was off at the scheduled time. +Persistent=true +# Avoid all instances hitting GitHub at the same second. +RandomizedDelaySec=120 + +[Install] +WantedBy=timers.target diff --git a/update.sh b/update.sh new file mode 100755 index 0000000..49409c4 --- /dev/null +++ b/update.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────── +# Codestar — instance updater (day-N). +# +# Pulls the latest code, rebuilds the stack and verifies health. +# Safe by design: backs up the database first, and on a failed +# health check it rolls back BOTH the code and the database. +# +# Run as the repo owner (so git uses the deploy key) with Docker +# access (add the user to the "docker" group: usermod -aG docker ). +# +# Usage: +# ./update.sh interactive (asks before applying) +# ./update.sh --yes non-interactive (for systemd timer / CI) +# ./update.sh --no-backup skip the pre-update DB dump +# ./update.sh --help +# +# Exit codes: 0 = updated or already up to date · 1 = error/rolled back +# ───────────────────────────────────────────────────────────── +set -euo pipefail + +cd "$(dirname "$0")" + +# ── options ────────────────────────────────────────────────── +ASSUME_YES=0 +DO_BACKUP=1 +for arg in "$@"; do + case "$arg" in + --yes|-y) ASSUME_YES=1 ;; + --no-backup) DO_BACKUP=0 ;; + --help|-h) sed -n '2,20p' "$0"; exit 0 ;; + *) echo "Unknown option: $arg" >&2; exit 1 ;; + esac +done + +# ── logging ────────────────────────────────────────────────── +c_blue=$'\033[1;34m'; c_green=$'\033[1;32m'; c_yellow=$'\033[1;33m'; c_red=$'\033[1;31m'; c_reset=$'\033[0m' +log() { printf '%s==>%s %s\n' "$c_blue" "$c_reset" "$*"; } +ok() { printf '%s✓%s %s\n' "$c_green" "$c_reset" "$*"; } +warn() { printf '%s!%s %s\n' "$c_yellow" "$c_reset" "$*"; } +err() { printf '%s✗%s %s\n' "$c_red" "$c_reset" "$*" >&2; } + +# ── single-instance lock (avoid concurrent updates) ────────── +exec 9>/tmp/codestar-update.lock +if ! flock -n 9; then err "Another update is already running."; exit 1; fi + +# ── prerequisites ──────────────────────────────────────────── +[ -f .env ] || { err "No .env found — is this an installed instance?"; exit 1; } +command -v docker >/dev/null || { err "docker not found."; exit 1; } +docker info >/dev/null 2>&1 || { err "Cannot talk to Docker. Add your user to the 'docker' group."; exit 1; } + +COMPOSE="docker compose -f docker-compose.yml -f docker-compose.prod.yml" + +# DB credentials (read from .env without sourcing arbitrary content) +DB_USER="$(grep -E '^DB_USER=' .env | head -1 | cut -d= -f2-)" +DB_NAME="$(grep -E '^DB_NAME=' .env | head -1 | cut -d= -f2-)" +DB_CONTAINER="codestar-db" +BACKUP_DIR="./backups" +KEEP_BACKUPS=7 + +# ── helpers ────────────────────────────────────────────────── +wait_healthy() { + local name="$1" tries="${2:-60}" status + for _ in $(seq 1 "$tries"); do + status="$(docker inspect -f '{{.State.Health.Status}}' "$name" 2>/dev/null || echo missing)" + [ "$status" = "healthy" ] && return 0 + sleep 5 + done + return 1 +} + +backup_db() { + mkdir -p "$BACKUP_DIR" + local f="$BACKUP_DIR/db-$(date +%Y%m%d-%H%M%S).sql.gz" + log "Backing up database → $f" + if docker exec "$DB_CONTAINER" pg_dump --clean --if-exists -U "$DB_USER" "$DB_NAME" | gzip > "$f"; then + ok "Backup done" + echo "$f" + # rotation: keep the most recent $KEEP_BACKUPS + ls -1t "$BACKUP_DIR"/db-*.sql.gz 2>/dev/null | tail -n +$((KEEP_BACKUPS + 1)) | xargs -r rm -f + else + rm -f "$f"; err "Backup failed — aborting (no update applied)."; exit 1 + fi +} + +restore_db() { + local f="$1" + [ -n "$f" ] && [ -f "$f" ] || { warn "No backup to restore."; return 1; } + log "Restoring database from $f" + gunzip -c "$f" | docker exec -i "$DB_CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" >/dev/null + ok "Database restored" +} + +# ── 1. is there anything new? ──────────────────────────────── +log "Checking for updates…" +git fetch --quiet +OLD_COMMIT="$(git rev-parse HEAD)" +NEW_COMMIT="$(git rev-parse '@{u}')" +if [ "$OLD_COMMIT" = "$NEW_COMMIT" ]; then + ok "Already up to date ($(git rev-parse --short HEAD)). Nothing to do." + exit 0 +fi + +log "New version available:" +git --no-pager log --oneline "$OLD_COMMIT..$NEW_COMMIT" | sed 's/^/ /' + +if [ "$ASSUME_YES" -ne 1 ]; then + read -rp "Apply this update? [y/N] " a + [ "$a" = "y" ] || [ "$a" = "Y" ] || { warn "Cancelled."; exit 0; } +fi + +# ── 2. backup ──────────────────────────────────────────────── +BACKUP_FILE="" +[ "$DO_BACKUP" -eq 1 ] && BACKUP_FILE="$(backup_db)" + +# ── 3. apply ───────────────────────────────────────────────── +log "Pulling new code…" +git merge --ff-only "$NEW_COMMIT" + +log "Rebuilding and restarting…" +$COMPOSE up --build -d + +# ── 4. verify, else rollback ───────────────────────────────── +log "Waiting for backend health…" +if wait_healthy codestar-backend 60; then + ok "Update applied successfully → $(git rev-parse --short HEAD)" + docker image prune -f >/dev/null 2>&1 || true + exit 0 +fi + +err "Backend unhealthy after update. Rolling back to $(git rev-parse --short "$OLD_COMMIT")…" +git reset --hard "$OLD_COMMIT" +$COMPOSE up --build -d +[ -n "$BACKUP_FILE" ] && restore_db "$BACKUP_FILE" || warn "No DB restore (backup was skipped)." +if wait_healthy codestar-backend 60; then + warn "Rolled back successfully. The update was NOT applied." +else + err "Rollback finished but backend still unhealthy. Check: $COMPOSE logs backend" +fi +exit 1 From 9f66aa7f817a1c18f2ba29755a5b61b6748c7b5e Mon Sep 17 00:00:00 2001 From: RayaneHassani Date: Wed, 24 Jun 2026 00:09:08 +0200 Subject: [PATCH 04/10] feat(backend): expose Prometheus metrics and structured JSON logs - add micrometer-registry-prometheus; expose health, info, prometheus - enable liveness/readiness health probes - logback-spring.xml: console logs in dev, JSON logs in prod (Loki-ready) - permit /actuator/info and /actuator/prometheus (internal network only) - add JaCoCo plugin for coverage reports --- apps/backend/pom.xml | 32 +++++++++++++++++++ .../backend/config/SecurityConfig.java | 7 ++-- .../src/main/resources/application.properties | 10 ++++-- .../src/main/resources/logback-spring.xml | 32 +++++++++++++++++++ 4 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 apps/backend/src/main/resources/logback-spring.xml diff --git a/apps/backend/pom.xml b/apps/backend/pom.xml index 4056386..9fe7243 100644 --- a/apps/backend/pom.xml +++ b/apps/backend/pom.xml @@ -100,6 +100,20 @@ 2.7.0 + + + io.micrometer + micrometer-registry-prometheus + runtime + + + + + net.logstash.logback + logstash-logback-encoder + 8.0 + + @@ -108,6 +122,24 @@ org.springframework.boot spring-boot-maven-plugin + + + + org.jacoco + jacoco-maven-plugin + 0.8.12 + + + prepare-agent + prepare-agent + + + report + verify + report + + + diff --git a/apps/backend/src/main/java/com/codestar/backend/config/SecurityConfig.java b/apps/backend/src/main/java/com/codestar/backend/config/SecurityConfig.java index 72d8e82..fb9c990 100644 --- a/apps/backend/src/main/java/com/codestar/backend/config/SecurityConfig.java +++ b/apps/backend/src/main/java/com/codestar/backend/config/SecurityConfig.java @@ -49,10 +49,13 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/api/v1/settings/branding").permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/media/**").permitAll() - // actuator health + // actuator: health probes + info + Prometheus scrape. + // Not internet-facing (backend runs on loopback + internal network). .requestMatchers(HttpMethod.GET, "/actuator/health", - "/actuator/health/**").permitAll() + "/actuator/health/**", + "/actuator/info", + "/actuator/prometheus").permitAll() // swagger / OpenAPI .requestMatchers( "/v3/api-docs/**", diff --git a/apps/backend/src/main/resources/application.properties b/apps/backend/src/main/resources/application.properties index 2e01617..0c5e17a 100644 --- a/apps/backend/src/main/resources/application.properties +++ b/apps/backend/src/main/resources/application.properties @@ -12,9 +12,15 @@ spring.jpa.show-sql=false spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect spring.jpa.open-in-view=false -# Actuator — expose the health endpoint. Aggregate status includes DB connectivity -management.endpoints.web.exposure.include=health +# Actuator — health (DB connectivity), info, and Prometheus metrics. +# The backend is never public (loopback + internal Docker network), so the +# Prometheus scrape endpoint is only reachable by the monitoring stack. +management.endpoints.web.exposure.include=health,info,prometheus management.endpoint.health.show-details=never +# Kubernetes-style liveness/readiness probes (used by Docker healthcheck & CD) +management.endpoint.health.probes.enabled=true +management.health.livenessstate.enabled=true +management.health.readinessstate.enabled=true # Flyway spring.flyway.enabled=true diff --git a/apps/backend/src/main/resources/logback-spring.xml b/apps/backend/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..82c5ce1 --- /dev/null +++ b/apps/backend/src/main/resources/logback-spring.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + traceId + spanId + {"service":"codestar-backend"} + + + + + + + From 876d422ddd26e4560caeed4c015c23daaa3ed9ed Mon Sep 17 00:00:00 2001 From: RayaneHassani Date: Wed, 24 Jun 2026 00:12:53 +0200 Subject: [PATCH 05/10] ci: add fast checks workflow with governance guards - ci.yml: paths-filtered jobs on dev/main (push + PR), concurrency cancel - frontend: lint, typecheck, build - backend: compile, verify (test + JaCoCo), coverage artifact - governance: Flyway migration + i18n parity guards - secrets: gitleaks; dependency-review on PRs; docker compose build - ci-required: single aggregate status check for branch protection - scripts/ci: flyway-governance.sh, i18n-parity.sh (shared with GitLab later) - frontend: add typecheck script (tsc --noEmit) - third-party actions pinned by commit SHA - remove build-services.yml (superseded) --- .github/workflows/build-services.yml | 96 -------------- .github/workflows/ci.yml | 179 +++++++++++++++++++++++++++ apps/frontend/package.json | 3 +- scripts/ci/flyway-governance.sh | 57 +++++++++ scripts/ci/i18n-parity.sh | 37 ++++++ 5 files changed, 275 insertions(+), 97 deletions(-) delete mode 100644 .github/workflows/build-services.yml create mode 100644 .github/workflows/ci.yml create mode 100755 scripts/ci/flyway-governance.sh create mode 100755 scripts/ci/i18n-parity.sh diff --git a/.github/workflows/build-services.yml b/.github/workflows/build-services.yml deleted file mode 100644 index f9487f3..0000000 --- a/.github/workflows/build-services.yml +++ /dev/null @@ -1,96 +0,0 @@ -name: Build Services CI - -on: - push: - branches: [main] - pull_request: - branches: [main] - -permissions: - contents: read - -jobs: - changes: - name: Detect changed paths - runs-on: ubuntu-latest - outputs: - frontend: ${{ steps.filter.outputs.frontend }} - backend: ${{ steps.filter.outputs.backend }} - steps: - - uses: actions/checkout@v4 - - uses: dorny/paths-filter@v3 - id: filter - with: - filters: | - frontend: - - 'apps/frontend/**' - backend: - - 'apps/backend/**' - - frontend: - name: Frontend — Build - needs: changes - if: needs.changes.outputs.frontend == 'true' - runs-on: ubuntu-latest - defaults: - run: - working-directory: apps/frontend - steps: - - uses: actions/checkout@v4 - - - uses: pnpm/action-setup@v4 - with: - version: '9.15.0' - - - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: pnpm - cache-dependency-path: apps/frontend/pnpm-lock.yaml - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build - run: pnpm build - - backend: - name: Backend — Build - needs: changes - if: needs.changes.outputs.backend == 'true' - runs-on: ubuntu-latest - defaults: - run: - working-directory: apps/backend - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-java@v4 - with: - java-version: '17' - distribution: temurin - cache: maven - - - name: Make mvnw executable - run: chmod +x ./mvnw - - - name: Build - run: ./mvnw package -DskipTests -B - - docker: - name: Docker — Build images - runs-on: ubuntu-latest - needs: [frontend, backend] - if: | - always() && - !contains(needs.*.result, 'failure') && - !contains(needs.*.result, 'cancelled') && - (needs.frontend.result == 'success' || needs.backend.result == 'success') - steps: - - uses: actions/checkout@v4 - - - name: Create env file - run: cp .env.example .env - - - name: Build images - run: docker compose build diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1cab8d6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,179 @@ +name: CI + +# Fast feedback on every push / PR to dev and main. +# Heavy security scanning lives in security.yml (main + nightly). +on: + push: + branches: [dev, main] + pull_request: + branches: [dev, main] + +# Cancel superseded runs on the same ref. +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + changes: + name: Detect changed paths + runs-on: ubuntu-latest + outputs: + frontend: ${{ steps.filter.outputs.frontend }} + backend: ${{ steps.filter.outputs.backend }} + migrations: ${{ steps.filter.outputs.migrations }} + i18n: ${{ steps.filter.outputs.i18n }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3 + id: filter + with: + filters: | + frontend: + - 'apps/frontend/**' + backend: + - 'apps/backend/**' + migrations: + - 'apps/backend/src/main/resources/db/migration/**' + i18n: + - 'apps/frontend/messages/**' + + frontend: + name: Frontend — lint, typecheck, build + needs: changes + if: needs.changes.outputs.frontend == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: apps/frontend + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + with: + version: '9.15.0' + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: '20' + cache: pnpm + cache-dependency-path: apps/frontend/pnpm-lock.yaml + - name: Install + run: pnpm install --frozen-lockfile + - name: Lint + run: pnpm lint + - name: Type-check + run: pnpm typecheck + - name: Build + run: pnpm build + + backend: + name: Backend — compile, test, coverage + needs: changes + if: needs.changes.outputs.backend == 'true' + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: apps/backend + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 + with: + java-version: '17' + distribution: temurin + cache: maven + - name: Make mvnw executable + run: chmod +x ./mvnw + - name: Compile + run: ./mvnw compile -B --no-transfer-progress + - name: Test + coverage + run: ./mvnw verify -B --no-transfer-progress + - name: Upload coverage report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: jacoco-coverage + path: apps/backend/target/site/jacoco/ + retention-days: 7 + if-no-files-found: warn + compression-level: 6 + + governance: + name: Codestar governance (migrations, i18n) + needs: changes + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 # full history so guards can diff against the base branch + - name: Flyway migration governance + if: needs.changes.outputs.migrations == 'true' + env: + BASE_REF: origin/${{ github.base_ref || 'main' }} + run: bash scripts/ci/flyway-governance.sh + - name: i18n key parity + if: needs.changes.outputs.i18n == 'true' + run: bash scripts/ci/i18n-parity.sh + + secrets: + name: Secret scan (gitleaks) + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + - uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + dependency-review: + name: Dependency review + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/dependency-review-action@e58c696e52cac8e62d61cc21fda89565d71505d7 # v4.3.0 + with: + fail-on-severity: high + comment-summary-in-pr: on-failure + + docker: + name: Docker — compose build + needs: [frontend, backend] + if: | + always() && + !contains(needs.*.result, 'failure') && + !contains(needs.*.result, 'cancelled') && + (needs.frontend.result == 'success' || needs.backend.result == 'success') + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Create env file + run: cp .env.example .env + - name: Build images + run: docker compose build + + # Single required status check for branch protection. + ci-required: + name: CI required + needs: [frontend, backend, governance, secrets, docker] + if: always() + runs-on: ubuntu-latest + steps: + - name: Verify no required job failed + run: | + results='${{ join(needs.*.result, ',') }}' + echo "Upstream results: $results" + case "$results" in + *failure*|*cancelled*) echo "A required job failed."; exit 1 ;; + *) echo "All required jobs passed (or were skipped)." ;; + esac diff --git a/apps/frontend/package.json b/apps/frontend/package.json index e7ea010..e68ddd4 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -6,7 +6,8 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "typecheck": "tsc --noEmit" }, "engines": { "node": ">=20.9.0" diff --git a/scripts/ci/flyway-governance.sh b/scripts/ci/flyway-governance.sh new file mode 100755 index 0000000..48731d6 --- /dev/null +++ b/scripts/ci/flyway-governance.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────── +# Flyway migration governance gate. +# +# Flyway applies each V.sql once and records its checksum. Editing or +# deleting an already-applied migration makes Flyway refuse to start in +# production ("checksum mismatch"). This script enforces, on every PR: +# 1. existing migrations are never modified or deleted (only new ones added) +# 2. version numbers are unique and strictly increasing (no gap / collision) +# +# Usage: flyway-governance.sh [BASE_REF] +# BASE_REF defaults to $BASE_REF env, then origin/main. +# Exit: 0 ok · 1 violation +# ───────────────────────────────────────────────────────────── +set -euo pipefail + +MIG_DIR="apps/backend/src/main/resources/db/migration" +BASE="${1:-${BASE_REF:-origin/main}}" +fail=0 + +cd "$(git rev-parse --show-toplevel)" + +# Resolve base; if unknown (e.g. shallow clone), skip the diff-based check. +if ! git rev-parse --verify --quiet "$BASE" >/dev/null; then + echo "! base ref '$BASE' not found — skipping modified/deleted check" +else + echo "==> Checking migrations against $BASE" + # Status of migration files between base and HEAD: A=added M=modified D=deleted R=renamed + while IFS=$'\t' read -r status path rest; do + [ -z "$status" ] && continue + case "$status" in + M*) echo "✗ Modified an existing migration: $path (forbidden — add a new V### instead)"; fail=1 ;; + D*) echo "✗ Deleted an existing migration: $path (forbidden)"; fail=1 ;; + R*) echo "✗ Renamed an existing migration: $path -> $rest (forbidden)"; fail=1 ;; + A*) echo " + new migration: $path" ;; + esac + done < <(git diff --name-status "$BASE"...HEAD -- "$MIG_DIR") +fi + +# Numbering integrity on the current tree: unique + contiguous (V001, V002, …). +echo "==> Checking version numbering" +nums="$(ls "$MIG_DIR" 2>/dev/null | grep -oE '^V[0-9]+' | grep -oE '[0-9]+' | sed 's/^0*//;s/^$/0/' | sort -n || true)" +if [ -n "$nums" ]; then + dups="$(echo "$nums" | uniq -d)" + if [ -n "$dups" ]; then echo "✗ Duplicate migration version(s): $dups"; fail=1; fi + expected=1 + for n in $nums; do + if [ "$n" -ne "$expected" ]; then + echo "✗ Non-contiguous numbering: expected V$(printf '%03d' "$expected"), found V$(printf '%03d' "$n")" + fail=1; break + fi + expected=$((expected + 1)) + done +fi + +if [ "$fail" -eq 0 ]; then echo "✓ Flyway governance OK"; fi +exit "$fail" diff --git a/scripts/ci/i18n-parity.sh b/scripts/ci/i18n-parity.sh new file mode 100755 index 0000000..a34dcba --- /dev/null +++ b/scripts/ci/i18n-parity.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────── +# i18n key-parity gate. +# +# English is the default locale; French ships in v1. A key present in one +# file but missing in the other means a missing or orphan translation. +# This compares the full set of leaf key-paths between en.json and fr.json. +# +# Requires: jq +# Exit: 0 keys match · 1 mismatch +# ───────────────────────────────────────────────────────────── +set -euo pipefail + +cd "$(git rev-parse --show-toplevel)" +DIR="apps/frontend/messages" +EN="$DIR/en.json" +FR="$DIR/fr.json" + +command -v jq >/dev/null || { echo "✗ jq is required"; exit 1; } +[ -f "$EN" ] && [ -f "$FR" ] || { echo "✗ missing $EN or $FR"; exit 1; } + +# Leaf key-paths (e.g. nav.signin), sorted. +keys() { jq -r 'paths(scalars) | join(".")' "$1" | sort; } + +only_en="$(comm -23 <(keys "$EN") <(keys "$FR"))" +only_fr="$(comm -13 <(keys "$EN") <(keys "$FR"))" + +fail=0 +if [ -n "$only_en" ]; then + echo "✗ Keys in en.json missing from fr.json:"; echo "$only_en" | sed 's/^/ /'; fail=1 +fi +if [ -n "$only_fr" ]; then + echo "✗ Keys in fr.json missing from en.json:"; echo "$only_fr" | sed 's/^/ /'; fail=1 +fi + +if [ "$fail" -eq 0 ]; then echo "✓ i18n parity OK ($(keys "$EN" | wc -l | tr -d ' ') keys)"; fi +exit "$fail" From 81118290c88bb7cdbb333cf68b5facb6c7f9d509 Mon Sep 17 00:00:00 2001 From: RayaneHassani Date: Wed, 24 Jun 2026 00:14:38 +0200 Subject: [PATCH 06/10] ci: add heavy security workflow (main + nightly) - CodeQL SAST for Java and JavaScript/TypeScript - Trivy filesystem and config scans, results to GitHub code scanning - OpenSSF Scorecard with published results - CycloneDX SBOM with build-provenance attestation - third-party actions pinned by commit SHA --- .github/workflows/security.yml | 146 +++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 .github/workflows/security.yml diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..49e8569 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,146 @@ +name: Security + +# Heavy security scanning: runs on main, nightly, and on demand. +# Fast PR feedback (gitleaks, dependency-review) lives in ci.yml. +on: + push: + branches: [main] + schedule: + - cron: '27 3 * * *' # nightly at 03:27 UTC + workflow_dispatch: + +concurrency: + group: security-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + codeql-java: + name: CodeQL — Java + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + security-events: write + actions: read + contents: read + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 + with: + java-version: '17' + distribution: temurin + cache: maven + - uses: github/codeql-action/init@dd903d2e4f5405488e5ef1422510ee31c8b32357 # v3 + with: + languages: java-kotlin + build-mode: autobuild + - uses: github/codeql-action/analyze@dd903d2e4f5405488e5ef1422510ee31c8b32357 # v3 + with: + category: /language:java-kotlin + + codeql-js: + name: CodeQL — JavaScript/TypeScript + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + security-events: write + actions: read + contents: read + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: github/codeql-action/init@dd903d2e4f5405488e5ef1422510ee31c8b32357 # v3 + with: + languages: javascript-typescript + build-mode: none + - uses: github/codeql-action/analyze@dd903d2e4f5405488e5ef1422510ee31c8b32357 # v3 + with: + category: /language:javascript-typescript + + trivy: + name: Trivy — filesystem & config + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + security-events: write + contents: read + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Trivy filesystem scan (dependencies) + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + scan-type: fs + scan-ref: . + format: sarif + output: trivy-fs.sarif + severity: CRITICAL,HIGH + - name: Upload filesystem SARIF + uses: github/codeql-action/upload-sarif@dd903d2e4f5405488e5ef1422510ee31c8b32357 # v3 + if: always() + with: + sarif_file: trivy-fs.sarif + category: trivy-fs + + - name: Trivy config scan (Dockerfiles, compose) + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + scan-type: config + scan-ref: . + format: sarif + output: trivy-config.sarif + severity: CRITICAL,HIGH + - name: Upload config SARIF + uses: github/codeql-action/upload-sarif@dd903d2e4f5405488e5ef1422510ee31c8b32357 # v3 + if: always() + with: + sarif_file: trivy-config.sarif + category: trivy-config + + scorecard: + name: OpenSSF Scorecard + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + security-events: write + id-token: write + contents: read + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - uses: ossf/scorecard-action@62b2cac7ed8198b15735ed49ab1e5cf35480ba46 # v2.4.0 + with: + results_file: scorecard.sarif + results_format: sarif + publish_results: true + - uses: github/codeql-action/upload-sarif@dd903d2e4f5405488e5ef1422510ee31c8b32357 # v3 + if: always() + with: + sarif_file: scorecard.sarif + category: scorecard + + sbom: + name: SBOM + provenance attestation + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + id-token: write # required for keyless signing of the attestation + attestations: write # required to record the attestation + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Generate SBOM (CycloneDX) + uses: anchore/sbom-action@55dc4ee22412511ee8c3142cbea40418e6cec693 # v0.17.8 + with: + path: . + format: cyclonedx-json + output-file: sbom.cyclonedx.json + artifact-name: sbom.cyclonedx.json + - name: Attest build provenance for the SBOM + # Only on direct pushes to this repo (forks/PRs cannot write attestations). + if: github.event_name != 'pull_request' + uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2 + with: + subject-path: sbom.cyclonedx.json From 406ae0c72afe1295cb1ed9681453760cd2598a45 Mon Sep 17 00:00:00 2001 From: RayaneHassani Date: Wed, 24 Jun 2026 00:16:45 +0200 Subject: [PATCH 07/10] ci: add guarded SSH deploy, dependabot, GitLab mirror and badges - deploy.yml: SSH-based CD running update.sh on the server; prod (main) and staging (dev) targets, each gated by a repo variable so forks skip it - dependabot.yml: per-directory updates (maven, npm, docker, actions); groups minor/patch; ignores the Next.js fork - .gitlab-ci.yml: mirror reusing scripts/ci and native GitLab security templates - README: CI, Security, Scorecard and license badges --- .github/dependabot.yml | 49 +++++++++++++++++++++++++ .github/workflows/deploy.yml | 61 +++++++++++++++++++++++++++++++ .gitlab-ci.yml | 70 ++++++++++++++++++++++++++++++++++++ README.md | 5 +++ 4 files changed, 185 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/deploy.yml create mode 100644 .gitlab-ci.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d24a4b8 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,49 @@ +version: 2 + +updates: + # ── Backend (Maven) ────────────────────────────────────── + - package-ecosystem: maven + directory: /apps/backend + schedule: + interval: weekly + open-pull-requests-limit: 5 + labels: [dependencies, backend] + groups: + backend-minor-patch: + update-types: [minor, patch] + + # ── Frontend (npm/pnpm) ────────────────────────────────── + - package-ecosystem: npm + directory: /apps/frontend + schedule: + interval: weekly + open-pull-requests-limit: 5 + labels: [dependencies, frontend] + groups: + frontend-minor-patch: + update-types: [minor, patch] + ignore: + # Next.js is an internal fork — never auto-bump (see apps/frontend/AGENTS.md) + - dependency-name: next + + # ── Docker base images ─────────────────────────────────── + - package-ecosystem: docker + directory: /apps/backend + schedule: + interval: weekly + labels: [dependencies, docker] + - package-ecosystem: docker + directory: /apps/frontend + schedule: + interval: weekly + labels: [dependencies, docker] + + # ── GitHub Actions ─────────────────────────────────────── + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + labels: [dependencies, github-actions] + groups: + actions: + update-types: [minor, patch] diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..77bbc32 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,61 @@ +name: Deploy + +# Continuous deployment over SSH (build-on-VPS model): connect to the +# server and run ./update.sh, which pulls, rebuilds, health-checks and +# rolls back on failure. +# +# GUARDED: each job runs only if its host secret is set, so a fork without +# deployment secrets simply skips this workflow (no red runs). +# +# Required secrets (per environment): +# prod : SSH_HOST, SSH_USER, SSH_KEY (+ optional SSH_PORT, SSH_PATH) +# staging : SSH_HOST_STAGING, SSH_USER, SSH_KEY (+ optional SSH_PORT, SSH_PATH) +# SSH_PATH defaults to ~/codestar. +on: + push: + branches: [dev, main] + workflow_dispatch: + +concurrency: + group: deploy-${{ github.ref }} + cancel-in-progress: false # never interrupt a deployment mid-flight + +permissions: + contents: read + +jobs: + deploy-prod: + name: Deploy → production + if: github.ref == 'refs/heads/main' && vars.HAS_PROD == 'true' + runs-on: ubuntu-latest + environment: production + timeout-minutes: 30 + steps: + - name: Run remote update + uses: appleboy/ssh-action@7eaf76671a0d7eec5d98ee897acda4f968735a17 # v1.2.0 + with: + host: ${{ secrets.SSH_HOST }} + username: ${{ secrets.SSH_USER }} + key: ${{ secrets.SSH_KEY }} + port: ${{ secrets.SSH_PORT || 22 }} + script: | + cd ${{ secrets.SSH_PATH || '~/codestar' }} + ./update.sh --yes + + deploy-staging: + name: Deploy → staging + if: github.ref == 'refs/heads/dev' && vars.HAS_STAGING == 'true' + runs-on: ubuntu-latest + environment: staging + timeout-minutes: 30 + steps: + - name: Run remote update + uses: appleboy/ssh-action@7eaf76671a0d7eec5d98ee897acda4f968735a17 # v1.2.0 + with: + host: ${{ secrets.SSH_HOST_STAGING }} + username: ${{ secrets.SSH_USER }} + key: ${{ secrets.SSH_KEY }} + port: ${{ secrets.SSH_PORT || 22 }} + script: | + cd ${{ secrets.SSH_PATH || '~/codestar' }} + ./update.sh --yes diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..13bdcf1 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,70 @@ +# GitLab CI mirror of .github/workflows/ci.yml. +# Same logic, driven by the shared scripts in scripts/ci/ and the same +# build commands, so a fork hosted on GitLab gets equivalent coverage. +# +# Native GitLab security templates provide SAST / secret detection / +# dependency scanning (the GitLab equivalents of CodeQL / gitleaks / Trivy). +include: + - template: Security/SAST.gitlab-ci.yml + - template: Security/Secret-Detection.gitlab-ci.yml + - template: Security/Dependency-Scanning.gitlab-ci.yml + +stages: [build, test, security] + +default: + interruptible: true + +# ── Frontend ───────────────────────────────────────────────── +frontend: + stage: build + image: node:20-alpine + rules: + - changes: [apps/frontend/**/*] + cache: + key: + files: [apps/frontend/pnpm-lock.yaml] + paths: [apps/frontend/.pnpm-store] + before_script: + - corepack enable && corepack prepare pnpm@9.15.0 --activate + - cd apps/frontend + - pnpm config set store-dir .pnpm-store + - pnpm install --frozen-lockfile + script: + - pnpm lint + - pnpm typecheck + - pnpm build + +# ── Backend ────────────────────────────────────────────────── +backend: + stage: build + image: maven:3.9-eclipse-temurin-17 + rules: + - changes: [apps/backend/**/*] + cache: + key: maven + paths: [.m2/repository] + variables: + MAVEN_OPTS: "-Dmaven.repo.local=$CI_PROJECT_DIR/.m2/repository" + script: + - cd apps/backend + - ./mvnw verify -B --no-transfer-progress + artifacts: + when: always + paths: [apps/backend/target/site/jacoco/] + expire_in: 7 days + +# ── Codestar governance guards ─────────────────────────────── +governance: + stage: test + image: alpine:3.20 + before_script: + - apk add --no-cache bash git jq + script: + - | + if git diff --name-only "origin/$CI_DEFAULT_BRANCH"...HEAD -- apps/backend/src/main/resources/db/migration | grep -q .; then + BASE_REF="origin/$CI_DEFAULT_BRANCH" bash scripts/ci/flyway-governance.sh + fi + - | + if git diff --name-only "origin/$CI_DEFAULT_BRANCH"...HEAD -- apps/frontend/messages | grep -q .; then + bash scripts/ci/i18n-parity.sh + fi diff --git a/README.md b/README.md index 19530d7..a9b7472 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # Codestar +[![CI](https://github.com/CodeStar-Project/codestar/actions/workflows/ci.yml/badge.svg)](https://github.com/CodeStar-Project/codestar/actions/workflows/ci.yml) +[![Security](https://github.com/CodeStar-Project/codestar/actions/workflows/security.yml/badge.svg)](https://github.com/CodeStar-Project/codestar/actions/workflows/security.yml) +[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/CodeStar-Project/codestar/badge)](https://scorecard.dev/viewer/?uri=github.com/CodeStar-Project/codestar) +[![License](https://img.shields.io/badge/license-GPLv3-blue.svg)](LICENSE) + Open-source & self-hosted e-learning platform template to build yours easly. ## Backend From 49ea9b7d4053f61d565d185425b94e9bb751b7d2 Mon Sep 17 00:00:00 2001 From: RayaneHassani Date: Wed, 24 Jun 2026 01:32:21 +0200 Subject: [PATCH 08/10] ci: fix backend DB service and gitleaks scan - backend job: add ephemeral Postgres service so @SpringBootTest can load - secrets job: run gitleaks binary directly (no org license required) --- .github/workflows/ci.yml | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1cab8d6..4305122 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,6 +77,24 @@ jobs: defaults: run: working-directory: apps/backend + # Ephemeral Postgres for @SpringBootTest (context load runs Flyway against it). + # Created before the steps, reachable at localhost:5432, destroyed with the runner. + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: codestar + POSTGRES_PASSWORD: codestar + POSTGRES_DB: codestardb + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U codestar -d codestardb" + --health-interval 10s --health-timeout 5s --health-retries 5 + env: + DB_URL: jdbc:postgresql://localhost:5432/codestardb + DB_USER: codestar + DB_PASSWORD: codestar steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 @@ -126,9 +144,15 @@ jobs: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 - - uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2 + # Run the gitleaks binary directly: the GitHub Action requires a paid + # license for organisation repos, the open-source binary does not. + - name: Run gitleaks env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITLEAKS_VERSION: 8.21.2 + run: | + curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ + | tar -xz gitleaks + ./gitleaks detect --source . --redact --verbose --exit-code 1 dependency-review: name: Dependency review From b20e1c8cf59bd5f7ce2f58198c9a2ee61005119f Mon Sep 17 00:00:00 2001 From: RayaneHassani Date: Wed, 24 Jun 2026 02:19:05 +0200 Subject: [PATCH 09/10] ci: notify Discord with the CI pipeline result - notify job aggregates frontend, backend, governance, secrets, docker - no-ops when DISCORD_WEBHOOK_URL is unset (fork-safe); action pinned by SHA --- .github/workflows/ci.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4305122..0a7de5d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -201,3 +201,17 @@ jobs: *failure*|*cancelled*) echo "A required job failed."; exit 1 ;; *) echo "All required jobs passed (or were skipped)." ;; esac + + # Discord notification. No-ops when DISCORD_WEBHOOK_URL is unset (fork-safe). + notify: + name: Discord notification + needs: [frontend, backend, governance, secrets, docker] + if: always() + runs-on: ubuntu-latest + steps: + - uses: sarisia/actions-status-discord@eb045afee445dc055c18d3d90bd0f244fd062708 # v1 + with: + webhook: ${{ secrets.DISCORD_WEBHOOK_URL }} + status: ${{ (contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')) && 'failure' || 'success' }} + title: "CI pipeline" + username: Codestar CI From 6be91baeb722d35b04e0218a4d0bdc9cb3716843 Mon Sep 17 00:00:00 2001 From: RayaneHassani Date: Fri, 3 Jul 2026 22:00:46 +0200 Subject: [PATCH 10/10] feat(monitoring): add optional observability overlay Add docker-compose.monitoring.yml with Prometheus, Grafana, Loki, Promtail, node-exporter and cAdvisor, all bound to the internal network with no public port. Grafana is reachable via SSH tunnel only. Prometheus scrapes the backend, host, containers and Caddy; retention is 15d for metrics and 7d for logs. Grafana ships provisioned datasources and dashboards (JVM/Spring, VPS system, containers, logs). Expose Caddy's admin metrics endpoint on the internal network and add a `make monitoring` target plus README instructions. --- .env.example | 8 ++ Caddyfile | 10 ++ Makefile | 6 +- README.md | 28 ++++ docker-compose.monitoring.yml | 125 ++++++++++++++++++ monitoring/grafana/dashboards/containers.json | 48 +++++++ monitoring/grafana/dashboards/jvm-spring.json | 70 ++++++++++ monitoring/grafana/dashboards/logs.json | 44 ++++++ monitoring/grafana/dashboards/vps-system.json | 56 ++++++++ .../provisioning/dashboards/dashboards.yml | 14 ++ .../provisioning/datasources/datasources.yml | 18 +++ monitoring/loki/loki-config.yml | 48 +++++++ monitoring/prometheus/prometheus.yml | 33 +++++ monitoring/promtail/promtail-config.yml | 29 ++++ 14 files changed, 536 insertions(+), 1 deletion(-) create mode 100644 docker-compose.monitoring.yml create mode 100644 monitoring/grafana/dashboards/containers.json create mode 100644 monitoring/grafana/dashboards/jvm-spring.json create mode 100644 monitoring/grafana/dashboards/logs.json create mode 100644 monitoring/grafana/dashboards/vps-system.json create mode 100644 monitoring/grafana/provisioning/dashboards/dashboards.yml create mode 100644 monitoring/grafana/provisioning/datasources/datasources.yml create mode 100644 monitoring/loki/loki-config.yml create mode 100644 monitoring/prometheus/prometheus.yml create mode 100644 monitoring/promtail/promtail-config.yml diff --git a/.env.example b/.env.example index be6c5af..a8c81ad 100644 --- a/.env.example +++ b/.env.example @@ -41,3 +41,11 @@ AI_CONNECT_TIMEOUT_SECONDS=5 AI_TIMEOUT_SECONDS=90 AI_RATE_LIMIT_CAPACITY=5 AI_RATE_LIMIT_REFILL_PER_MINUTE=2 + +# Monitoring stack (docker-compose.monitoring.yml — optional overlay). +# Grafana is never public: reach it via SSH tunnel only: +# ssh -L 3000:localhost:3001 user@vps # then http://localhost:3000 +# Generate a strong password once on the host: +# echo "GRAFANA_ADMIN_PASSWORD=$(openssl rand -base64 24)" >> .env +GRAFANA_ADMIN_USER=admin +GRAFANA_ADMIN_PASSWORD=change_me_strong_password diff --git a/Caddyfile b/Caddyfile index d152c2b..1393717 100644 --- a/Caddyfile +++ b/Caddyfile @@ -6,6 +6,16 @@ # internal Docker network and are never reachable from the Internet. # ───────────────────────────────────────────────────────────── +{ + # Admin API on the internal Docker network so the monitoring stack can + # scrape Caddy's own metrics. This port is never published to the host, + # so it stays unreachable from the Internet. + admin 0.0.0.0:2019 + servers { + metrics + } +} + {$DOMAIN} { # Compress responses (zstd preferred, gzip fallback). encode zstd gzip diff --git a/Makefile b/Makefile index a11b3ba..dfd5204 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: setup prod dev down logs ps clean +.PHONY: setup prod dev down logs ps clean monitoring ## First-time setup: copy env template setup: @@ -27,3 +27,7 @@ logs: ## Service status ps: docker compose ps + +## Monitoring stack: Prometheus + Grafana + Loki (reach Grafana via SSH tunnel) +monitoring: + docker compose -f docker-compose.yml -f docker-compose.prod.yml -f docker-compose.monitoring.yml up -d diff --git a/README.md b/README.md index a9b7472..016c733 100644 --- a/README.md +++ b/README.md @@ -81,3 +81,31 @@ Or with frontend hot-reload via Docker: ```bash docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build -d ``` + +## Monitoring (optional) + +An overlay adds Prometheus, Grafana, Loki, Promtail, node-exporter and cAdvisor. +Every container stays on the internal network — no public port is opened. + +**1. Generate the Grafana admin password (one-time, on the host)** + +```bash +echo "GRAFANA_ADMIN_PASSWORD=$(openssl rand -base64 24)" >> .env +``` + +**2. Start the stack** + +```bash +make monitoring +# equivalent to: +# docker compose -f docker-compose.yml -f docker-compose.prod.yml -f docker-compose.monitoring.yml up -d +``` + +**3. Reach Grafana through an SSH tunnel** (it is never exposed publicly) + +```bash +ssh -L 3000:localhost:3001 user@vps # then open http://localhost:3000 +``` + +Log in with `admin` and the password you generated. Dashboards are provisioned +automatically: Spring/JVM, VPS system, containers, and Loki logs. diff --git a/docker-compose.monitoring.yml b/docker-compose.monitoring.yml new file mode 100644 index 0000000..443d8f9 --- /dev/null +++ b/docker-compose.monitoring.yml @@ -0,0 +1,125 @@ +# ───────────────────────────────────────────────────────────── +# Monitoring overlay — Prometheus + Grafana + Loki stack. +# +# Usage (always combined with the base, prod optional): +# docker compose \ +# -f docker-compose.yml \ +# -f docker-compose.prod.yml \ +# -f docker-compose.monitoring.yml up -d +# +# The app runs perfectly WITHOUT this file — monitoring is opt-in. +# +# Security model (same as the rest of the stack): +# • No service is published to the Internet. +# • Grafana is bound to 127.0.0.1 only → reach it through an SSH tunnel: +# ssh -L 3000:localhost:3001 user@vps # then open http://localhost:3000 +# • All collectors live on the internal "codestar-net" network and scrape +# the app containers by their service/container names. +# ───────────────────────────────────────────────────────────── +services: + + # ── Metrics store ────────────────────────────────────────── + prometheus: + image: prom/prometheus:v2.53.0 + container_name: codestar-prometheus + restart: unless-stopped + command: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.path=/prometheus" + - "--storage.tsdb.retention.time=15d" # retention decided: 15 days + - "--web.enable-lifecycle" + volumes: + - ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus_data:/prometheus + networks: + - codestar-net + + # ── Logs store ───────────────────────────────────────────── + loki: + image: grafana/loki:3.1.0 + container_name: codestar-loki + restart: unless-stopped + command: "-config.file=/etc/loki/loki-config.yml" + volumes: + - ./monitoring/loki/loki-config.yml:/etc/loki/loki-config.yml:ro + - loki_data:/loki + networks: + - codestar-net + + # ── Log collector (containers' stdout → Loki) ────────────── + promtail: + image: grafana/promtail:3.1.0 + container_name: codestar-promtail + restart: unless-stopped + depends_on: + - loki + command: "-config.file=/etc/promtail/promtail-config.yml" + volumes: + - ./monitoring/promtail/promtail-config.yml:/etc/promtail/promtail-config.yml:ro + # Container logs + Docker socket for service discovery & labels. + - /var/lib/docker/containers:/var/lib/docker/containers:ro + - /var/run/docker.sock:/var/run/docker.sock:ro + - promtail_positions:/tmp + networks: + - codestar-net + + # ── Host metrics (CPU / RAM / disk of the VPS) ───────────── + node-exporter: + image: prom/node-exporter:v1.8.1 + container_name: codestar-node-exporter + restart: unless-stopped + command: + - "--path.rootfs=/host" + pid: host + volumes: + - /:/host:ro,rslave + networks: + - codestar-net + + # ── Per-container metrics (CPU / RAM / net per service) ──── + cadvisor: + image: gcr.io/cadvisor/cadvisor:v0.49.1 + container_name: codestar-cadvisor + restart: unless-stopped + privileged: true + devices: + - /dev/kmsg + volumes: + - /:/rootfs:ro + - /var/run:/var/run:ro + - /sys:/sys:ro + - /var/lib/docker/:/var/lib/docker:ro + - /dev/disk/:/dev/disk:ro + networks: + - codestar-net + + # ── Dashboards (SSH-tunnel access only) ──────────────────── + grafana: + image: grafana/grafana:11.1.0 + container_name: codestar-grafana + restart: unless-stopped + depends_on: + - prometheus + - loki + environment: + GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin} + GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:?set GRAFANA_ADMIN_PASSWORD in .env} + # Never exposed publicly → sign-up off, anonymous off. + GF_USERS_ALLOW_SIGN_UP: "false" + GF_AUTH_ANONYMOUS_ENABLED: "false" + volumes: + - ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro + - ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro + - grafana_data:/var/lib/grafana + ports: + # Loopback only. Tunnel in: ssh -L 3000:localhost:3001 user@vps + # (host port 3001 because the frontend already owns 3000). + - "127.0.0.1:3001:3000" + networks: + - codestar-net + +volumes: + prometheus_data: + loki_data: + grafana_data: + promtail_positions: diff --git a/monitoring/grafana/dashboards/containers.json b/monitoring/grafana/dashboards/containers.json new file mode 100644 index 0000000..ec479fb --- /dev/null +++ b/monitoring/grafana/dashboards/containers.json @@ -0,0 +1,48 @@ +{ + "uid": "codestar-containers", + "title": "Codestar — Containers", + "tags": ["codestar", "docker"], + "timezone": "browser", + "schemaVersion": 39, + "version": 1, + "refresh": "30s", + "time": { "from": "now-6h", "to": "now" }, + "templating": { "list": [] }, + "panels": [ + { + "id": 1, "type": "timeseries", "title": "CPU per container (cores)", + "gridPos": { "h": 9, "w": 12, "x": 0, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ + { "refId": "A", "expr": "sum by (name) (rate(container_cpu_usage_seconds_total{name=~\"codestar-.+\"}[5m]))", "legendFormat": "{{name}}" } + ] + }, + { + "id": 2, "type": "timeseries", "title": "Memory per container", + "gridPos": { "h": 9, "w": 12, "x": 12, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] }, + "targets": [ + { "refId": "A", "expr": "sum by (name) (container_memory_usage_bytes{name=~\"codestar-.+\"})", "legendFormat": "{{name}}" } + ] + }, + { + "id": 3, "type": "timeseries", "title": "Network RX per container", + "gridPos": { "h": 9, "w": 12, "x": 0, "y": 9 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { "defaults": { "unit": "Bps" }, "overrides": [] }, + "targets": [ + { "refId": "A", "expr": "sum by (name) (rate(container_network_receive_bytes_total{name=~\"codestar-.+\"}[5m]))", "legendFormat": "{{name}}" } + ] + }, + { + "id": 4, "type": "timeseries", "title": "Network TX per container", + "gridPos": { "h": 9, "w": 12, "x": 12, "y": 9 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { "defaults": { "unit": "Bps" }, "overrides": [] }, + "targets": [ + { "refId": "A", "expr": "sum by (name) (rate(container_network_transmit_bytes_total{name=~\"codestar-.+\"}[5m]))", "legendFormat": "{{name}}" } + ] + } + ] +} diff --git a/monitoring/grafana/dashboards/jvm-spring.json b/monitoring/grafana/dashboards/jvm-spring.json new file mode 100644 index 0000000..ccfbb0e --- /dev/null +++ b/monitoring/grafana/dashboards/jvm-spring.json @@ -0,0 +1,70 @@ +{ + "uid": "codestar-jvm", + "title": "Codestar — JVM / Spring Boot", + "tags": ["codestar", "backend"], + "timezone": "browser", + "schemaVersion": 39, + "version": 1, + "refresh": "30s", + "time": { "from": "now-6h", "to": "now" }, + "templating": { "list": [] }, + "panels": [ + { + "id": 1, "type": "timeseries", "title": "JVM heap used", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] }, + "targets": [ + { "refId": "A", "expr": "sum(jvm_memory_used_bytes{area=\"heap\"})", "legendFormat": "heap used" }, + { "refId": "B", "expr": "sum(jvm_memory_max_bytes{area=\"heap\"})", "legendFormat": "heap max" } + ] + }, + { + "id": 2, "type": "timeseries", "title": "GC pause rate", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }, + "targets": [ + { "refId": "A", "expr": "rate(jvm_gc_pause_seconds_sum[5m])", "legendFormat": "{{action}}" } + ] + }, + { + "id": 3, "type": "timeseries", "title": "HTTP request rate (req/s)", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { "defaults": { "unit": "reqps" }, "overrides": [] }, + "targets": [ + { "refId": "A", "expr": "sum by (status) (rate(http_server_requests_seconds_count[5m]))", "legendFormat": "{{status}}" } + ] + }, + { + "id": 4, "type": "timeseries", "title": "HTTP p95 latency", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }, + "targets": [ + { "refId": "A", "expr": "histogram_quantile(0.95, sum by (le) (rate(http_server_requests_seconds_bucket[5m])))", "legendFormat": "p95" } + ] + }, + { + "id": 5, "type": "stat", "title": "Live threads", + "gridPos": { "h": 6, "w": 8, "x": 0, "y": 16 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ { "refId": "A", "expr": "jvm_threads_live_threads" } ] + }, + { + "id": 6, "type": "stat", "title": "Process CPU", + "gridPos": { "h": 6, "w": 8, "x": 8, "y": 16 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { "defaults": { "unit": "percentunit" }, "overrides": [] }, + "targets": [ { "refId": "A", "expr": "process_cpu_usage" } ] + }, + { + "id": 7, "type": "stat", "title": "Uptime", + "gridPos": { "h": 6, "w": 8, "x": 16, "y": 16 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }, + "targets": [ { "refId": "A", "expr": "process_uptime_seconds" } ] + } + ] +} diff --git a/monitoring/grafana/dashboards/logs.json b/monitoring/grafana/dashboards/logs.json new file mode 100644 index 0000000..f5c685b --- /dev/null +++ b/monitoring/grafana/dashboards/logs.json @@ -0,0 +1,44 @@ +{ + "uid": "codestar-logs", + "title": "Codestar — Logs (Loki)", + "tags": ["codestar", "logs"], + "timezone": "browser", + "schemaVersion": 39, + "version": 1, + "refresh": "30s", + "time": { "from": "now-1h", "to": "now" }, + "templating": { + "list": [ + { + "name": "container", + "label": "Container", + "type": "query", + "datasource": { "type": "loki", "uid": "loki" }, + "query": { "label": "container", "stream": "{stack=\"codestar\"}", "type": 1 }, + "refresh": 2, + "includeAll": true, + "multi": true, + "current": { "text": "All", "value": "$__all" } + } + ] + }, + "panels": [ + { + "id": 1, "type": "timeseries", "title": "Log volume per container", + "gridPos": { "h": 7, "w": 24, "x": 0, "y": 0 }, + "datasource": { "type": "loki", "uid": "loki" }, + "targets": [ + { "refId": "A", "expr": "sum by (container) (count_over_time({stack=\"codestar\", container=~\"$container\"}[1m]))", "legendFormat": "{{container}}" } + ] + }, + { + "id": 2, "type": "logs", "title": "Logs", + "gridPos": { "h": 17, "w": 24, "x": 0, "y": 7 }, + "datasource": { "type": "loki", "uid": "loki" }, + "options": { "showTime": true, "wrapLogMessage": true, "sortOrder": "Descending", "enableLogDetails": true }, + "targets": [ + { "refId": "A", "expr": "{stack=\"codestar\", container=~\"$container\"}" } + ] + } + ] +} diff --git a/monitoring/grafana/dashboards/vps-system.json b/monitoring/grafana/dashboards/vps-system.json new file mode 100644 index 0000000..08c9512 --- /dev/null +++ b/monitoring/grafana/dashboards/vps-system.json @@ -0,0 +1,56 @@ +{ + "uid": "codestar-vps", + "title": "Codestar — VPS system", + "tags": ["codestar", "host"], + "timezone": "browser", + "schemaVersion": 39, + "version": 1, + "refresh": "30s", + "time": { "from": "now-6h", "to": "now" }, + "templating": { "list": [] }, + "panels": [ + { + "id": 1, "type": "timeseries", "title": "CPU usage (%)", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { "defaults": { "unit": "percent", "max": 100 }, "overrides": [] }, + "targets": [ + { "refId": "A", "expr": "100 - (avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)", "legendFormat": "cpu" } + ] + }, + { + "id": 2, "type": "timeseries", "title": "Memory used (%)", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { "defaults": { "unit": "percent", "max": 100 }, "overrides": [] }, + "targets": [ + { "refId": "A", "expr": "(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100", "legendFormat": "mem" } + ] + }, + { + "id": 3, "type": "timeseries", "title": "Disk free (root)", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] }, + "targets": [ + { "refId": "A", "expr": "node_filesystem_avail_bytes{mountpoint=\"/host\"}", "legendFormat": "free" } + ] + }, + { + "id": 4, "type": "timeseries", "title": "Load average (1m)", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "targets": [ { "refId": "A", "expr": "node_load1", "legendFormat": "load1" } ] + }, + { + "id": 5, "type": "timeseries", "title": "Network traffic", + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 16 }, + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { "defaults": { "unit": "Bps" }, "overrides": [] }, + "targets": [ + { "refId": "A", "expr": "sum(rate(node_network_receive_bytes_total{device!~\"lo|veth.*|docker.*|br.*\"}[5m]))", "legendFormat": "rx" }, + { "refId": "B", "expr": "sum(rate(node_network_transmit_bytes_total{device!~\"lo|veth.*|docker.*|br.*\"}[5m]))", "legendFormat": "tx" } + ] + } + ] +} diff --git a/monitoring/grafana/provisioning/dashboards/dashboards.yml b/monitoring/grafana/provisioning/dashboards/dashboards.yml new file mode 100644 index 0000000..1d470f0 --- /dev/null +++ b/monitoring/grafana/provisioning/dashboards/dashboards.yml @@ -0,0 +1,14 @@ +# Loads every dashboard JSON dropped in /var/lib/grafana/dashboards. +apiVersion: 1 + +providers: + - name: codestar + orgId: 1 + folder: Codestar + type: file + disableDeletion: false + updateIntervalSeconds: 30 + allowUiUpdates: true + options: + path: /var/lib/grafana/dashboards + foldersFromFilesStructure: false diff --git a/monitoring/grafana/provisioning/datasources/datasources.yml b/monitoring/grafana/provisioning/datasources/datasources.yml new file mode 100644 index 0000000..353425d --- /dev/null +++ b/monitoring/grafana/provisioning/datasources/datasources.yml @@ -0,0 +1,18 @@ +# Grafana datasources — provisioned at startup, no manual clicking. +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + uid: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: false + + - name: Loki + type: loki + uid: loki + access: proxy + url: http://loki:3100 + editable: false diff --git a/monitoring/loki/loki-config.yml b/monitoring/loki/loki-config.yml new file mode 100644 index 0000000..92f68ac --- /dev/null +++ b/monitoring/loki/loki-config.yml @@ -0,0 +1,48 @@ +# Loki — single-binary, filesystem storage. Sized for a small VPS. +# Retention decided: 7 days (168h). The compactor deletes older chunks. +auth_enabled: false + +server: + http_listen_port: 3100 + log_level: warn + +common: + instance_addr: 127.0.0.1 + path_prefix: /loki + storage: + filesystem: + chunks_directory: /loki/chunks + rules_directory: /loki/rules + replication_factor: 1 + ring: + kvstore: + store: inmemory + +schema_config: + configs: + - from: 2024-01-01 + store: tsdb + object_store: filesystem + schema: v13 + index: + prefix: index_ + period: 24h + +limits_config: + retention_period: 168h # 7 days + reject_old_samples: true + reject_old_samples_max_age: 168h + ingestion_rate_mb: 8 + ingestion_burst_size_mb: 16 + +compactor: + working_directory: /loki/compactor + retention_enabled: true # required for retention_period to take effect + delete_request_store: filesystem + +query_range: + results_cache: + cache: + embedded_cache: + enabled: true + max_size_mb: 100 diff --git a/monitoring/prometheus/prometheus.yml b/monitoring/prometheus/prometheus.yml new file mode 100644 index 0000000..384f852 --- /dev/null +++ b/monitoring/prometheus/prometheus.yml @@ -0,0 +1,33 @@ +# Prometheus scrape configuration. +# Targets are reached by their Docker container names over codestar-net. +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + # Prometheus itself. + - job_name: prometheus + static_configs: + - targets: ["localhost:9090"] + + # Spring Boot backend — Micrometer exposes Prometheus format here. + - job_name: backend + metrics_path: /actuator/prometheus + static_configs: + - targets: ["codestar-backend:8080"] + + # VPS host metrics (CPU / RAM / disk / network). + - job_name: node + static_configs: + - targets: ["codestar-node-exporter:9100"] + + # Per-container metrics. + - job_name: cadvisor + static_configs: + - targets: ["codestar-cadvisor:8080"] + + # Caddy metrics — the admin API is exposed on 2019 via the Caddyfile global block. + - job_name: caddy + metrics_path: /metrics + static_configs: + - targets: ["codestar-caddy:2019"] diff --git a/monitoring/promtail/promtail-config.yml b/monitoring/promtail/promtail-config.yml new file mode 100644 index 0000000..e08dc7b --- /dev/null +++ b/monitoring/promtail/promtail-config.yml @@ -0,0 +1,29 @@ +# Promtail — discovers running containers via the Docker socket and ships +# their stdout/stderr to Loki. Backend & Caddy already log JSON, so the +# raw line is the JSON object; Grafana parses it at query time. +server: + http_listen_port: 9080 + log_level: warn + +positions: + filename: /tmp/positions.yaml + +clients: + - url: http://loki:3100/loki/api/v1/push + +scrape_configs: + - job_name: docker + docker_sd_configs: + - host: unix:///var/run/docker.sock + refresh_interval: 15s + relabel_configs: + # container name (strip the leading "/"). + - source_labels: ["__meta_docker_container_name"] + regex: "/(.*)" + target_label: container + # compose service name when present. + - source_labels: ["__meta_docker_container_label_com_docker_compose_service"] + target_label: service + # Static label to find everything under the stack quickly. + - replacement: codestar + target_label: stack