diff --git a/.dockerignore b/.dockerignore index b429a4a..e2b9cbf 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,7 @@ **/bin/ **/obj/ +.env +.env.* .git/ .gitea/ .vs/ diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml index c258494..7298da5 100644 --- a/.gitea/workflows/ci-cd.yml +++ b/.gitea/workflows/ci-cd.yml @@ -1,17 +1,28 @@ name: CI/CD on: - push: { branches: [main] } - pull_request: { branches: [main] } + push: + branches: [main] + pull_request: + branches: [main] concurrency: group: hamkadr-cicd-${{ github.ref }} cancel-in-progress: true +# ───────────────────────────────────────────────────────────────────────────── +# Runner labels (act_runner): +# ubuntu-latest:docker://node:20-alpine ← CI jobs run in real Docker containers +# self-hosted:host ← deploy runs directly on the server +# All images/packages via Nexus at mirror.soroushasadi.com. +# Required Gitea secret: ENV_FILE (contents of .env) +# ───────────────────────────────────────────────────────────────────────────── + jobs: - # ---------------------------------------------------------------- CI - build: - name: "CI — dotnet build (Release)" + + # ── CI: compile-check (every push / PR) ────────────────────────────────────── + ci: + name: "CI · dotnet build" runs-on: ubuntu-latest container: image: mirror.soroushasadi.com/dotnet/sdk:10.0 @@ -44,23 +55,23 @@ jobs: - name: Restore run: dotnet restore src/JobsMedical.Web/JobsMedical.Web.csproj --configfile /tmp/nuget.ci.config - env: { DOTNET_CLI_TELEMETRY_OPTOUT: 1 } + env: + DOTNET_CLI_TELEMETRY_OPTOUT: 1 - name: Build run: dotnet build src/JobsMedical.Web/JobsMedical.Web.csproj --no-restore -c Release - # ---------------------------------------------------------------- Deploy + # ── CD: build image → deploy on the server (push to main only) ──────────────── deploy: - name: "Deploy — hamkadr (compose)" + name: "Deploy · hamkadr" runs-on: self-hosted - needs: [build] - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - timeout-minutes: 40 env: # act host runner starts with a minimal PATH — extend so docker/snap resolve. PATH: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin - DOCKER_BUILDKIT: 1 - COMPOSE_DOCKER_CLI_BUILD: 1 + needs: [ci] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + timeout-minutes: 30 + steps: - name: Checkout env: @@ -75,52 +86,77 @@ jobs: - name: Write .env run: printf '%s' "$ENV_FILE" > .env - env: { ENV_FILE: ${{ secrets.ENV_FILE }} } + env: + ENV_FILE: ${{ secrets.ENV_FILE }} + + - name: Tag current image for rollback (before rebuild) + run: | + if docker image inspect mirror.soroushasadi.com/hamkadr/api:latest >/dev/null 2>&1; then + docker tag mirror.soroushasadi.com/hamkadr/api:latest mirror.soroushasadi.com/hamkadr/api:rollback + echo "Tagged previous image → :rollback" + else + echo "No previous image — first deploy." + fi - name: Back up database (if running) run: | set -a; . ./.env; set +a - if docker ps -a --format '{{.Names}}' | grep -q '^hamkadr-db$'; then - mkdir -p /opt/hamkadr-backups - TS=$(date +%Y%m%d-%H%M%S) - echo "Backing up DB → /opt/hamkadr-backups/hamkadr-$TS.sql" - docker exec hamkadr-db pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" \ - > "/opt/hamkadr-backups/hamkadr-$TS.sql" || echo "WARN: backup skipped (db not ready yet)" + if docker ps -q --filter name='^hamkadr_db$' | grep -q .; then + BACKUP_DIR="/opt/hamkadr-backups"; mkdir -p "$BACKUP_DIR" + STAMP=$(date +%Y%m%d-%H%M%S) + docker exec hamkadr_db pg_dump -U "${POSTGRES_USER:-hamkadr}" "${POSTGRES_DB:-hamkadr}" \ + > "$BACKUP_DIR/hamkadr-$STAMP.sql" \ + && echo "✅ DB backed up → $BACKUP_DIR/hamkadr-$STAMP.sql" \ + || echo "⚠️ backup failed (non-fatal)" + ls -t "$BACKUP_DIR"/*.sql 2>/dev/null | tail -n +11 | xargs -r rm else - echo "No existing db container — first deploy, nothing to back up." + echo "ℹ️ hamkadr_db not running — first deploy, nothing to back up." fi - - name: Tag current image for rollback - run: | - if docker image inspect hamkadr-app:latest >/dev/null 2>&1; then - docker tag hamkadr-app:latest hamkadr-app:rollback - echo "Tagged hamkadr-app:rollback" - fi - - - name: Build app image - run: docker compose -f docker-compose.prod.yml build app + - name: Build image + run: docker compose build api + env: + DOCKER_BUILDKIT: 1 + COMPOSE_DOCKER_CLI_BUILD: 1 - name: Start database - run: docker compose -f docker-compose.prod.yml up -d --no-deps db + run: docker compose up -d --no-deps db - - name: Recreate app (stop + rm + up — reliable across docker versions) + - name: Deploy app (stop + rm + up — reliable across docker versions) run: | - docker stop hamkadr-app 2>/dev/null || true - docker rm hamkadr-app 2>/dev/null || true - docker compose -f docker-compose.prod.yml up -d --no-deps app + docker stop hamkadr_api 2>/dev/null || true + docker rm hamkadr_api 2>/dev/null || true + docker compose up -d --no-deps api - - name: Wait for app healthy + - name: Wait for healthy run: | - set -a; . ./.env; set +a - for i in $(seq 1 24); do - if curl -fsS "http://127.0.0.1:${APP_PORT}/healthz" >/dev/null 2>&1; then - echo "OK — hamkadr-app healthy on 127.0.0.1:${APP_PORT}"; exit 0 + echo "Waiting for hamkadr_api (up to 3 min)..." + for i in $(seq 1 36); do + HEALTH=$(docker inspect --format='{{.State.Health.Status}}' hamkadr_api 2>/dev/null || echo "missing") + STATE=$(docker inspect --format='{{.State.Status}}' hamkadr_api 2>/dev/null || echo "missing") + RESTARTS=$(docker inspect --format='{{.RestartCount}}' hamkadr_api 2>/dev/null || echo "0") + echo " [$i/36] state=$STATE health=$HEALTH restarts=$RESTARTS" + [ "$HEALTH" = "healthy" ] && echo "✅ hamkadr_api healthy" && exit 0 + if [ "$STATE" = "exited" ] || [ "$STATE" = "dead" ]; then + echo "❌ hamkadr_api crashed — logs:"; docker logs hamkadr_api 2>&1 | tail -120; exit 1 fi - echo " [$i/24] not ready yet…" + if [ "$RESTARTS" -gt 1 ]; then + echo "❌ hamkadr_api crash-loop — logs:"; docker logs hamkadr_api 2>&1 | tail -120; exit 1 + fi + [ "$i" = "36" ] && echo "❌ timeout" && docker logs hamkadr_api 2>&1 | tail -80 && exit 1 sleep 5 done - echo "TIMEOUT — dumping logs"; docker logs --tail=60 hamkadr-app; exit 1 - - name: Prune dangling images + - name: Show containers + if: always() + run: docker compose ps + + - name: Prune old hamkadr images if: success() - run: docker image prune -f + # Only untagged () hamkadr images — never touches other projects. + run: | + docker images --format '{{.Repository}}:{{.Tag}} {{.ID}}' \ + | grep '^mirror\.soroushasadi\.com/hamkadr/' \ + | grep '' \ + | awk '{print $2}' \ + | xargs -r docker rmi || true diff --git a/DEPLOY.md b/DEPLOY.md index cca40f5..a3fe777 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -7,10 +7,10 @@ TLS for `hamkadr.ir` and reverse-proxies to the app. ## Architecture & open ports ``` -Internet ──443/80──► nginx (host, existing) ──► 127.0.0.1:8090 ──► hamkadr-app (container :8080) +Internet ──443/80──► nginx (host, existing) ──► 127.0.0.1:8090 ──► hamkadr_api (container :8080) │ internal docker net ▼ - hamkadr-db (postgres, no host port) + hamkadr_db (postgres, no host port) ``` | Port | Open? | Purpose | @@ -30,7 +30,8 @@ serves git./mirror. — no firewall change needed.) |------|------| | `Dockerfile` | multi-stage build, images + NuGet via `mirror.soroushasadi.com` | | `nuget.docker.config` | NuGet → Nexus `nuget-group` | -| `docker-compose.prod.yml` | `app` (127.0.0.1:${APP_PORT}) + `db` (internal) + named volume | +| `docker-compose.yml` | production stack: `api` (127.0.0.1:${HOST_PORT}) + `db` (internal) + named volume | +| `docker-compose.dev.yml` | local-dev Postgres only (host 5433) for `dotnet run` | | `.gitea/workflows/ci-cd.yml` | build job + self-hosted deploy (backup → rollback tag → recreate → health-wait) | | `deploy/nginx-hamkadr.ir.conf` | nginx vhost for hamkadr.ir | @@ -50,48 +51,37 @@ and its user is in the `docker` group. (Already true if other soroush projects d ### 3. ENV_FILE secret Set at `https://git.soroushasadi.com/soroushdes/hamkadr/settings/secrets` → key **`ENV_FILE`**: +`docker-compose.yml` substitutes these into the `api`/`db` services (it derives +`ConnectionStrings__Default` and `Auth__AdminPhone` for you), so the secret is just: + ```dotenv -ASPNETCORE_ENVIRONMENT=Production -ASPNETCORE_URLS=http://+:8080 - # host port nginx proxies to (must match deploy/nginx-hamkadr.ir.conf) -APP_PORT=8090 +HOST_PORT=8090 -# Postgres (container) — generate a strong password: openssl rand -hex 24 +# Postgres — generate a strong password: openssl rand -hex 24 POSTGRES_DB=hamkadr POSTGRES_USER=hamkadr POSTGRES_PASSWORD=__CHANGE_ME__ -# EF Core connection string (host = compose service name "db") -ConnectionStrings__Default=Host=db;Port=5432;Database=hamkadr;Username=hamkadr;Password=__CHANGE_ME__ +# Platform admin phone (gets the Admin role on login) +ADMIN_PHONE=09XXXXXXXXX -# Platform admin (the phone that gets the Admin role on login) -Auth__AdminPhone=09XXXXXXXXX - -# Future: Kavenegar / SMS.ir keys for real OTP delivery - -# --- Channel scraping (optional; off by default) --- -# Enable the background worker and the sources you want, then their fetch runs on a timer. -# Ingestion__Enabled=true -# Ingestion__IntervalMinutes=30 -# Telegram (public channels via t.me/s — no token needed): -# Ingestion__Telegram__Enabled=true -# Ingestion__Telegram__Channels__0=shift_channel_username -# Ingestion__Telegram__Channels__1=another_channel -# Bale (bot must be a member of the channel; Telegram-style Bot API): -# Ingestion__Bale__Enabled=true -# Ingestion__Bale__BotToken=__BALE_BOT_TOKEN__ -# Divar (best-effort web-search): -# Ingestion__Divar__Enabled=true -# Ingestion__Divar__Queries__0=استخدام پزشک -# Ingestion__Divar__Queries__1=پرستار +# --- Channel scraping (optional; off by default) — toggles --- +# INGESTION_ENABLED=true +# INGESTION_INTERVAL_MINUTES=30 +# TELEGRAM_ENABLED=true +# TELEGRAM_BOT_TOKEN=__TELEGRAM_BOT_TOKEN__ +# BALE_ENABLED=true +# BALE_BOT_TOKEN=__BALE_BOT_TOKEN__ +# DIVAR_ENABLED=true ``` +> Channel **lists** (`Telegram.Channels`, `Divar.Queries`) live in `appsettings.json` (or add +> `Ingestion__Telegram__Channels__0=...` keys). The toggles above gate each source on/off. > The **AI audit layer** is configured at runtime in the admin panel (`/Admin/Settings`) — endpoint, -> model, API key, prompt/framework, and auto-approve — not via env. Default: AI off, mode = Manual, +> model, API key, prompt/framework, auto-approve — not via env. Default: AI off, mode = Manual, > so every ingested listing waits in the review queue until an admin publishes it. -> `POSTGRES_PASSWORD` and the password in `ConnectionStrings__Default` must be identical. -> `ASPNETCORE_ENVIRONMENT=Production` ⇒ only **reference data** (roles/cities/districts) is seeded — -> no demo facilities/shifts. Real employers add listings via the employer panel. +> `ASPNETCORE_ENVIRONMENT=Production` is set by the compose file ⇒ only **reference data** +> (roles/cities/districts) is seeded — no demo facilities/shifts. ### 4. nginx vhost + TLS ```bash @@ -111,15 +101,14 @@ on startup and seeds reference data; nginx already proxies hamkadr.ir to it. ## Operations - **Backups:** every deploy runs `pg_dump` → `/opt/hamkadr-backups/hamkadr-.sql` before touching containers. -- **Rollback:** the previous image is tagged `hamkadr-app:rollback` each deploy: +- **Rollback:** the previous image is tagged `mirror.soroushasadi.com/hamkadr/api:rollback` each deploy: ```bash - docker stop hamkadr-app && docker rm hamkadr-app - docker run -d --name hamkadr-app --env-file .env --network hamkadr_default \ - -p 127.0.0.1:8090:8080 hamkadr-app:rollback + docker stop hamkadr_api && docker rm hamkadr_api + API_TAG=rollback docker compose up -d --no-deps api ``` - **Rotate a secret:** edit `ENV_FILE` in Gitea, push any commit to redeploy. -- **Logs:** `docker logs -f hamkadr-app` -- **Restore a backup:** `cat /opt/hamkadr-backups/.sql | docker exec -i hamkadr-db psql -U hamkadr -d hamkadr` +- **Logs:** `docker logs -f hamkadr_api` +- **Restore a backup:** `cat /opt/hamkadr-backups/.sql | docker exec -i hamkadr_db psql -U hamkadr -d hamkadr` ## Safety (never do these) - ❌ `docker compose down -v` — deletes the database volume. diff --git a/Dockerfile b/Dockerfile index 305e40a..49629bd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,5 +12,12 @@ WORKDIR /app COPY --from=build /out ./ EXPOSE 8080 ENV ASPNETCORE_URLS=http://+:8080 \ + ASPNETCORE_ENVIRONMENT=Production \ DOTNET_CLI_TELEMETRY_OPTOUT=1 + +# Liveness probe (TCP connect to Kestrel) — lets the deploy job wait on +# `docker inspect Health.Status`. bash ships in the Debian-based aspnet image. +HEALTHCHECK --interval=15s --timeout=10s --start-period=30s --retries=3 \ + CMD bash -c 'echo > /dev/tcp/localhost/8080' || exit 1 + ENTRYPOINT ["dotnet", "JobsMedical.Web.dll"] diff --git a/deploy/nginx-hamkadr.ir.conf b/deploy/nginx-hamkadr.ir.conf index 7039514..c2740bc 100644 --- a/deploy/nginx-hamkadr.ir.conf +++ b/deploy/nginx-hamkadr.ir.conf @@ -5,14 +5,14 @@ # sudo nginx -t && sudo systemctl reload nginx # sudo certbot --nginx -d hamkadr.ir -d www.hamkadr.ir # adds the :443 server + HTTP→HTTPS redirect # -# APP_PORT below MUST match APP_PORT in the Gitea ENV_FILE secret (default 8090). +# The port below MUST match HOST_PORT in the Gitea ENV_FILE secret (default 8090). server { listen 80; listen [::]:80; server_name hamkadr.ir www.hamkadr.ir; - # The app binds 127.0.0.1:8090 (docker-compose.prod.yml) — never exposed publicly. + # The app binds 127.0.0.1:8090 (docker-compose.yml, service "api") — never exposed publicly. location / { proxy_pass http://127.0.0.1:8090; proxy_http_version 1.1; diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..5ee27b3 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,20 @@ +# Local development DB only. Devs run `docker compose -f docker-compose.dev.yml up -d` +# and `dotnet run` against it. Production uses docker-compose.yml (api + db). +name: jobsmedical-dev + +services: + db: + image: postgres:17-alpine + container_name: jobsmedical-db + restart: unless-stopped + environment: + POSTGRES_DB: jobsmedical + POSTGRES_USER: jobsmedical + POSTGRES_PASSWORD: jobsmedical_dev + ports: + - "5433:5432" # host 5433 to avoid clashing with a local Postgres on 5432 + volumes: + - jobsmedical-pgdata:/var/lib/postgresql/data + +volumes: + jobsmedical-pgdata: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml deleted file mode 100644 index f2b8cfb..0000000 --- a/docker-compose.prod.yml +++ /dev/null @@ -1,39 +0,0 @@ -# Production stack for hamkadr.ir — used by the Gitea deploy job (docker compose -f docker-compose.prod.yml). -# nginx (on the host) terminates TLS for hamkadr.ir and reverse-proxies to 127.0.0.1:${APP_PORT}. -name: hamkadr # pinned so redeploys reuse the same named volume (never creates orphaned data) - -services: - db: - image: mirror.soroushasadi.com/postgres:16-alpine - container_name: hamkadr-db - restart: unless-stopped - environment: - POSTGRES_DB: ${POSTGRES_DB} - POSTGRES_USER: ${POSTGRES_USER} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - volumes: - - hamkadr_db_data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] - interval: 5s - timeout: 5s - retries: 20 - # NOTE: no `ports:` — Postgres is reachable only by the app on the internal network. - - app: - build: - context: . - dockerfile: Dockerfile - image: hamkadr-app:latest - container_name: hamkadr-app - restart: unless-stopped - env_file: .env - depends_on: - db: - condition: service_healthy - ports: - - "127.0.0.1:${APP_PORT}:8080" # localhost-only; nginx proxies hamkadr.ir → here - -volumes: - hamkadr_db_data: - name: hamkadr_db_data diff --git a/docker-compose.yml b/docker-compose.yml index 79ebbd9..db08d83 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,16 +1,56 @@ +# Production compose for hamkadr.ir — the Gitea deploy job uses THIS file directly +# (docker compose build api / up -d --no-deps api). Local dev DB → docker-compose.dev.yml. +# nginx (host) terminates TLS for hamkadr.ir and reverse-proxies to 127.0.0.1:${HOST_PORT}. +name: hamkadr # locked so redeploys reuse the same named volume (no orphaned data) + services: + + # ── .NET 10 Razor Pages app ────────────────────────────────────────────────── + api: + image: mirror.soroushasadi.com/hamkadr/api:${API_TAG:-latest} + build: + context: . + dockerfile: Dockerfile + container_name: hamkadr_api + restart: unless-stopped + depends_on: + db: + condition: service_healthy + ports: + - "127.0.0.1:${HOST_PORT:-8090}:8080" # localhost-only; nginx proxies hamkadr.ir → here + environment: + ASPNETCORE_ENVIRONMENT: "Production" + ASPNETCORE_URLS: "http://+:8080" + ConnectionStrings__Default: "Host=db;Port=5432;Database=${POSTGRES_DB:-hamkadr};Username=${POSTGRES_USER:-hamkadr};Password=${POSTGRES_PASSWORD}" + Auth__AdminPhone: "${ADMIN_PHONE:-}" + # Channel scraping (optional; enable + configure via ENV_FILE) + Ingestion__Enabled: "${INGESTION_ENABLED:-false}" + Ingestion__IntervalMinutes: "${INGESTION_INTERVAL_MINUTES:-30}" + Ingestion__Telegram__Enabled: "${TELEGRAM_ENABLED:-false}" + Ingestion__Telegram__BotToken: "${TELEGRAM_BOT_TOKEN:-}" + Ingestion__Bale__Enabled: "${BALE_ENABLED:-false}" + Ingestion__Bale__BotToken: "${BALE_BOT_TOKEN:-}" + Ingestion__Divar__Enabled: "${DIVAR_ENABLED:-false}" + # healthcheck is defined in the Dockerfile (bash /dev/tcp probe) so the deploy + # job's `docker inspect Health.Status` wait works. + + # ── PostgreSQL (internal only — never published) ───────────────────────────── db: - image: postgres:17-alpine - container_name: jobsmedical-db + image: mirror.soroushasadi.com/postgres:16-alpine + container_name: hamkadr_db restart: unless-stopped environment: - POSTGRES_DB: jobsmedical - POSTGRES_USER: jobsmedical - POSTGRES_PASSWORD: jobsmedical_dev - ports: - - "5433:5432" # host 5433 to avoid clashing with a local Postgres on 5432 + POSTGRES_DB: ${POSTGRES_DB:-hamkadr} + POSTGRES_USER: ${POSTGRES_USER:-hamkadr} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} volumes: - - jobsmedical-pgdata:/var/lib/postgresql/data + - hamkadr_db_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-hamkadr} -d ${POSTGRES_DB:-hamkadr}"] + interval: 5s + timeout: 5s + retries: 20 volumes: - jobsmedical-pgdata: + hamkadr_db_data: + name: hamkadr_db_data