From 0c8d593d40e700166e262af7d82993312c606524 Mon Sep 17 00:00:00 2001 From: epistemophiliac Date: Tue, 16 Jun 2026 17:52:02 -0400 Subject: [PATCH] Add production ERPNext Coolify stack with CI gates Single compose file for Coolify: MariaDB, Redis, idempotent site creation, migrations on redeploy, SERVICE_URL_FRONTEND_8080 routing, and Forgejo Actions readiness validation vendored from production-ci-readiness skill. --- .github/workflows/production-readiness.yml | 39 ++++ .gitignore | 3 + Makefile | 11 + README.md | 58 +++++ docker-compose.yml | 236 +++++++++++++++++++++ docs/COOLIFY_DEPLOY.md | 102 +++++++++ example.env | 32 +++ scripts/ci/ci-readiness.sh | 127 +++++++++++ scripts/ci/validate-docker-compose.sh | 109 ++++++++++ 9 files changed, 717 insertions(+) create mode 100644 .github/workflows/production-readiness.yml create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 docs/COOLIFY_DEPLOY.md create mode 100644 example.env create mode 100755 scripts/ci/ci-readiness.sh create mode 100755 scripts/ci/validate-docker-compose.sh diff --git a/.github/workflows/production-readiness.yml b/.github/workflows/production-readiness.yml new file mode 100644 index 0000000..fd43a1d --- /dev/null +++ b/.github/workflows/production-readiness.yml @@ -0,0 +1,39 @@ +name: Production Readiness + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: production-erpnext-${{ github.ref }} + cancel-in-progress: true + +jobs: + readiness: + name: Coolify compose readiness + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Production readiness scan + run: | + chmod +x scripts/ci/*.sh + bash scripts/ci/ci-readiness.sh . + + - name: Validate docker-compose (Coolify rules) + run: bash scripts/ci/validate-docker-compose.sh . + + compose-smoke: + name: docker compose config + runs-on: ubuntu-latest + needs: readiness + steps: + - uses: actions/checkout@v4 + + - name: Compose config (strip Coolify-only keys) + run: | + sed '/exclude_from_hc:/d' docker-compose.yml > docker-compose.validate.yml + docker compose -f docker-compose.validate.yml config -q + echo "compose config OK" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f832522 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.env +*.local +.DS_Store diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6bf65f7 --- /dev/null +++ b/Makefile @@ -0,0 +1,11 @@ +.PHONY: ci validate compose-config + +ci: + bash scripts/ci/ci-readiness.sh . + bash scripts/ci/validate-docker-compose.sh . + +validate: + bash scripts/ci/validate-docker-compose.sh . + +compose-config: + sed '/exclude_from_hc:/d' docker-compose.yml | docker compose -f - config diff --git a/README.md b/README.md new file mode 100644 index 0000000..12a7fa7 --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# Production ERPNext on Coolify + +Validated Docker Compose stack for [ERPNext](https://erpnext.com) on [Coolify](https://coolify.io), derived from [frappe/frappe_docker](https://github.com/frappe/frappe_docker). + +**Repository:** https://git.aexoradao.com/epistemophiliac/production-erpnext + +## Quick start (Coolify) + +1. **New Resource** → **Docker Compose** +2. **Git repository:** `https://git.aexoradao.com/epistemophiliac/production-erpnext` +3. **Compose file:** `docker-compose.yml` +4. Set environment variables from [`example.env`](example.env) (at minimum `DB_PASSWORD`, `SITE_NAME`, `ADMIN_PASSWORD`) +5. Assign your domain to service **`frontend`**, port **`8080`** +6. Deploy — first boot creates the site and installs ERPNext (~5–15 minutes) + +Login: user `Administrator`, password = `ADMIN_PASSWORD`. + +## What this stack includes + +| Service | Role | +|---------|------| +| `db` | MariaDB 11.8 | +| `redis-cache` / `redis-queue` | Cache and job queue | +| `configurator` | One-shot bench config | +| `create-site` | Idempotent site + ERPNext install | +| `migrator` | `bench migrate` on redeploy | +| `backend` | Gunicorn API | +| `frontend` | Nginx (port **8080**) | +| `websocket` | Socket.IO realtime | +| `queue-short` / `queue-long` / `scheduler` | Background workers | + +## CI + +Forgejo Actions runs on every push/PR to `main`: + +- `scripts/ci/ci-readiness.sh` — secrets, docs, compose checks +- `scripts/ci/validate-docker-compose.sh` — Coolify compose rules + `docker compose config` + +Run locally: + +```bash +make ci +``` + +## Requirements + +- Coolify server with **4 GB+ RAM** (8 GB recommended) +- Domain DNS pointing to your Coolify proxy +- `SITE_NAME` and `FRAPPE_SITE_NAME_HEADER` must match the Coolify domain + +## Documentation + +- [Coolify deploy guide](docs/COOLIFY_DEPLOY.md) +- [Upstream frappe_docker docs](https://frappe.github.io/frappe_docker/) + +## License + +Compose and docs: MIT. ERPNext/Frappe images: see upstream licenses. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2875063 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,236 @@ +# ERPNext production stack for Coolify. +# Based on https://github.com/frappe/frappe_docker (compose.yaml + mariadb + redis). +# Coolify: assign your domain to service `frontend` on port 8080. +# No ports: — routing uses SERVICE_URL_FRONTEND_8080. + +x-customizable-image: &customizable_image + image: ${CUSTOM_IMAGE:-frappe/erpnext}:${CUSTOM_TAG:-${ERPNEXT_VERSION:-v16.22.0}} + pull_policy: ${PULL_POLICY:-always} + restart: ${RESTART_POLICY:-unless-stopped} + +x-depends-on-configurator: &depends_on_configurator + depends_on: + configurator: + condition: service_completed_successfully + +x-backend-defaults: &backend_defaults + <<: [*depends_on_configurator, *customizable_image] + platform: linux/amd64 + volumes: + - sites:/home/frappe/frappe-bench/sites + +services: + db: + image: mariadb:11.8 + restart: unless-stopped + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + - --skip-character-set-client-handshake + - --skip-innodb-read-only-compressed + environment: + - 'MYSQL_ROOT_PASSWORD=${DB_PASSWORD:-changeme}' + - 'MARIADB_AUTO_UPGRADE=1' + healthcheck: + test: ['CMD', 'healthcheck.sh', '--connect', '--innodb_initialized'] + start_period: 10s + interval: 5s + timeout: 5s + retries: 10 + volumes: + - db-data:/var/lib/mysql + + redis-cache: + image: redis:8.6-alpine + restart: unless-stopped + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 10s + timeout: 5s + retries: 5 + + redis-queue: + image: redis:8.6-alpine + restart: unless-stopped + volumes: + - redis-queue-data:/data + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 10s + timeout: 5s + retries: 5 + + configurator: + <<: *backend_defaults + exclude_from_hc: true + restart: 'no' + entrypoint: ['bash', '-c'] + command: + - > + ls -1 apps > sites/apps.txt; + bench set-config -g db_host $$DB_HOST; + bench set-config -gp db_port $$DB_PORT; + bench set-config -g redis_cache "redis://$$REDIS_CACHE"; + bench set-config -g redis_queue "redis://$$REDIS_QUEUE"; + bench set-config -g redis_socketio "redis://$$REDIS_QUEUE"; + bench set-config -gp socketio_port $$SOCKETIO_PORT; + bench set-config -g chromium_path /usr/bin/chromium-headless-shell; + environment: + - 'DB_HOST=db' + - 'DB_PORT=3306' + - 'REDIS_CACHE=redis-cache:6379' + - 'REDIS_QUEUE=redis-queue:6379' + - 'SOCKETIO_PORT=9000' + depends_on: + db: + condition: service_healthy + redis-cache: + condition: service_healthy + redis-queue: + condition: service_healthy + + create-site: + <<: *customizable_image + exclude_from_hc: true + restart: 'no' + platform: linux/amd64 + entrypoint: ['bash', '-c'] + command: + - > + B=/usr/local/bin/bench; + SITE=$$SITE_NAME; + wait-for-it -t 120 db:3306; + wait-for-it -t 120 redis-cache:6379; + wait-for-it -t 120 redis-queue:6379; + if [ -d "sites/$$SITE" ]; then echo "[create-site] exists"; $$B use "$$SITE"; else echo "[create-site] creating"; $$B new-site "$$SITE" --mariadb-user-host-login-scope='%' --admin-password "$$ADMIN_PASSWORD" --db-root-password "$$DB_PASSWORD" --install-app erpnext --set-default; fi + environment: + - 'SITE_NAME=${SITE_NAME:-erp.example.com}' + - 'ADMIN_PASSWORD=${ADMIN_PASSWORD:-changeme}' + - 'DB_PASSWORD=${DB_PASSWORD:-changeme}' + volumes: + - sites:/home/frappe/frappe-bench/sites + depends_on: + configurator: + condition: service_completed_successfully + db: + condition: service_healthy + + migrator: + <<: *backend_defaults + exclude_from_hc: true + restart: 'no' + entrypoint: ['bash', '-c'] + command: + - > + if [ "$$MIGRATE_SITES" != "true" ]; then echo "[migrator] disabled"; exit 0; fi; + if [ -z "$$(find sites -mindepth 2 -maxdepth 2 -name site_config.json 2>/dev/null)" ]; then echo "[migrator] no sites"; exit 0; fi; + echo "[migrator] migrating all sites"; + bench --site all migrate; + environment: + - 'MIGRATE_SITES=${MIGRATE_SITES:-true}' + depends_on: + create-site: + condition: service_completed_successfully + + backend: + <<: *backend_defaults + environment: + - 'GUNICORN_THREADS=${GUNICORN_THREADS:-4}' + - 'GUNICORN_WORKERS=${GUNICORN_WORKERS:-2}' + - 'GUNICORN_TIMEOUT=${GUNICORN_TIMEOUT:-120}' + depends_on: + configurator: + condition: service_completed_successfully + create-site: + condition: service_completed_successfully + migrator: + condition: service_completed_successfully + healthcheck: + test: ['CMD-SHELL', 'curl -sf http://localhost:8000/api/method/ping || exit 1'] + interval: 15s + timeout: 10s + retries: 10 + start_period: 120s + + websocket: + <<: [*depends_on_configurator, *customizable_image] + platform: linux/amd64 + command: + - node + - /home/frappe/frappe-bench/apps/frappe/socketio.js + volumes: + - sites:/home/frappe/frappe-bench/sites + depends_on: + create-site: + condition: service_completed_successfully + + frontend: + <<: *customizable_image + platform: linux/amd64 + command: + - nginx-entrypoint.sh + environment: + - SERVICE_URL_FRONTEND_8080 + - 'BACKEND=backend:8000' + - 'SOCKETIO=websocket:9000' + - 'FRAPPE_SITE_NAME_HEADER=${FRAPPE_SITE_NAME_HEADER:-${SITE_NAME:-erp.example.com}}' + - 'UPSTREAM_REAL_IP_ADDRESS=${UPSTREAM_REAL_IP_ADDRESS:-127.0.0.1}' + - 'UPSTREAM_REAL_IP_HEADER=${UPSTREAM_REAL_IP_HEADER:-X-Forwarded-For}' + - 'UPSTREAM_REAL_IP_RECURSIVE=${UPSTREAM_REAL_IP_RECURSIVE:-off}' + - 'PROXY_READ_TIMEOUT=${PROXY_READ_TIMEOUT:-120}' + - 'CLIENT_MAX_BODY_SIZE=${CLIENT_MAX_BODY_SIZE:-50m}' + volumes: + - sites:/home/frappe/frappe-bench/sites + depends_on: + backend: + condition: service_healthy + websocket: + condition: service_started + healthcheck: + test: ['CMD-SHELL', 'curl -sf http://localhost:8080/ || exit 1'] + interval: 15s + timeout: 10s + retries: 15 + start_period: 90s + + queue-short: + <<: *backend_defaults + command: + - bench + - worker + - --queue + - short,default + depends_on: + create-site: + condition: service_completed_successfully + migrator: + condition: service_completed_successfully + + queue-long: + <<: *backend_defaults + command: + - bench + - worker + - --queue + - long,default,short + depends_on: + create-site: + condition: service_completed_successfully + migrator: + condition: service_completed_successfully + + scheduler: + <<: *backend_defaults + command: + - bench + - schedule + depends_on: + create-site: + condition: service_completed_successfully + migrator: + condition: service_completed_successfully + +volumes: + sites: + db-data: + redis-queue-data: diff --git a/docs/COOLIFY_DEPLOY.md b/docs/COOLIFY_DEPLOY.md new file mode 100644 index 0000000..1c0f992 --- /dev/null +++ b/docs/COOLIFY_DEPLOY.md @@ -0,0 +1,102 @@ +# Coolify deployment — Production ERPNext + +## Prerequisites + +- Coolify v4+ with Docker Compose support +- Server: **minimum 4 GB RAM**, **8 GB+** for production workloads +- Public domain (e.g. `erp.yourdomain.com`) + +## 1. Create the Coolify service + +| Setting | Value | +|---------|--------| +| Type | Docker Compose | +| Repository | `https://git.aexoradao.com/epistemophiliac/production-erpnext` | +| Branch | `main` | +| Compose file | `docker-compose.yml` | + +## 2. Environment variables + +Set these in **Coolify → Service → Environment Variables** before first deploy: + +| Variable | Required | Example | Notes | +|----------|----------|---------|-------| +| `ERPNEXT_VERSION` | yes | `v16.22.0` | Pin image tag | +| `DB_PASSWORD` | yes | strong secret | MariaDB root | +| `SITE_NAME` | yes | `erp.yourdomain.com` | Must match domain | +| `ADMIN_PASSWORD` | yes | strong secret | Frappe login | +| `FRAPPE_SITE_NAME_HEADER` | yes | same as `SITE_NAME` | Single-site routing | +| `MIGRATE_SITES` | no | `true` | Run migrate on redeploy | + +> **Coolify env cache:** Changing defaults in `docker-compose.yml` does **not** update values already stored in Coolify. Edit them in the UI after changes. + +## 3. Domain routing + +1. Open the deployed service in Coolify +2. Add domain: `erp.yourdomain.com` +3. Attach domain to service **`frontend`** +4. Internal port: **`8080`** (Frappe nginx — not 80) + +The compose file sets `SERVICE_URL_FRONTEND_8080` so Coolify routes HTTPS to nginx correctly. + +## 4. First deploy timeline + +```text +db (healthy) → redis → configurator (exit 0) + → create-site (new-site + install-app erpnext, ~5–15 min) + → migrator → backend / workers / frontend +``` + +Watch logs: + +- `create-site` — site creation progress +- `backend` — gunicorn ready +- `frontend` — nginx on 8080 + +## 5. Post-deploy verification + +From Coolify terminal on `frontend`: + +```bash +curl -sI http://localhost:8080/ +``` + +From your machine: + +```bash +curl -sI https://erp.yourdomain.com/ +``` + +Login at `https://erp.yourdomain.com` — user `Administrator`. + +## 6. Upgrades + +1. Bump `ERPNEXT_VERSION` in Coolify env vars +2. Redeploy — `migrator` runs `bench --site all migrate` +3. Confirm `migrator` logs show success + +## Troubleshooting + +| Symptom | Fix | +|---------|-----| +| Coolify 404 | Domain on wrong service — must be `frontend:8080` | +| Site not found | `SITE_NAME` ≠ domain; fix `FRAPPE_SITE_NAME_HEADER` in UI | +| Stack unhealthy | Healthcheck port must be **8080** on frontend | +| create-site fails on redeploy | Should be idempotent — check `sites/$SITE_NAME` exists | +| Env change ignored | Update variable in Coolify UI, not only in git | + +## What we intentionally omit + +- No Traefik / nginx-proxy / Let's Encrypt in compose — Coolify handles TLS +- No `ports:` — Coolify proxy only +- No `pwd.yml` demo stack + +## CI / production gate + +Every merge to `main` must pass `.github/workflows/production-readiness.yml` before Coolify auto-deploy (if wired). + +Local check: + +```bash +make ci +``` diff --git a/example.env b/example.env new file mode 100644 index 0000000..ad9217e --- /dev/null +++ b/example.env @@ -0,0 +1,32 @@ +# Copy to Coolify Environment Variables (Service > Environment). +# Upstream reference: https://github.com/frappe/frappe_docker/blob/main/docs/02-setup/04-env-variables.md + +# Image tag — pin for reproducible deploys +ERPNEXT_VERSION=v16.22.0 + +# MariaDB root password (required — change before production) +DB_PASSWORD=changeme + +# Frappe site name — MUST match your Coolify domain +SITE_NAME=erp.example.com + +# Frappe Administrator password +ADMIN_PASSWORD=changeme + +# Nginx site header — for single-site Coolify, same as SITE_NAME +FRAPPE_SITE_NAME_HEADER=erp.example.com + +# Run bench migrate on every deploy (set false to skip) +MIGRATE_SITES=true + +# Gunicorn tuning (optional) +GUNICORN_THREADS=4 +GUNICORN_WORKERS=2 +GUNICORN_TIMEOUT=120 + +# Proxy / upload limits (optional) +PROXY_READ_TIMEOUT=120 +CLIENT_MAX_BODY_SIZE=50m +UPSTREAM_REAL_IP_ADDRESS=127.0.0.1 +UPSTREAM_REAL_IP_HEADER=X-Forwarded-For +UPSTREAM_REAL_IP_RECURSIVE=off diff --git a/scripts/ci/ci-readiness.sh b/scripts/ci/ci-readiness.sh new file mode 100755 index 0000000..c1e04e4 --- /dev/null +++ b/scripts/ci/ci-readiness.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# Production readiness scanner — run from repo root or pass path as $1 +# SPDX-License-Identifier: Apache-2.0 +set -euo pipefail + +REPO="${1:-.}" +REPO="$(cd "$REPO" && pwd)" +STRICT="${STRICT:-0}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +ERR=0 +WARN=0 +INFO=0 + +err() { echo "ERROR $*"; ERR=$((ERR + 1)); } +warn() { echo "WARN $*"; WARN=$((WARN + 1)); } +info() { echo "INFO $*"; } +pass() { echo "OK $*"; } + +echo "=== Production readiness: $REPO ===" +echo "strict=$STRICT" +echo + +# --- Secrets in tracked files --- +if command -v git >/dev/null 2>&1 && git -C "$REPO" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + if git -C "$REPO" ls-files | grep -qE '^\.env$|credentials\.json|\.pem$'; then + err "[SEC-01] Tracked sensitive files (.env, credentials, pem)" + else + pass "[SEC-01] No obvious secret filenames tracked" + fi + while IFS= read -r f; do + [ -f "$REPO/$f" ] || continue + if grep -qE 'BEGIN (RSA |EC )?PRIVATE KEY|api[_-]?key\s*=\s*["\x27][a-zA-Z0-9]{20,}' "$REPO/$f" 2>/dev/null; then + err "[SEC-01] Possible secret in tracked file: $f" + fi + done < <(git -C "$REPO" ls-files '*.yml' '*.yaml' '*.json' '*.md' '*.sh' '*.js' '*.go' '*.env*' 2>/dev/null | head -500) +else + warn "[SEC-01] Not a git repo — skip secret scan" +fi + +# --- node_modules in git --- +if command -v git >/dev/null 2>&1 && git -C "$REPO" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + NM_COUNT=$(git -C "$REPO" ls-files '**/node_modules/**' 2>/dev/null | wc -l | tr -d ' ') + if [ "${NM_COUNT:-0}" -gt 0 ]; then + err "[DC-06] node_modules tracked ($NM_COUNT files) — git rm -r --cached and .gitignore" + else + pass "[DC-06] node_modules not tracked" + fi +fi + +# --- Node Dockerfiles: lockfile + npm ci --- +while IFS= read -r df; do + [ -f "$df" ] || continue + dir="$(dirname "$df")" + if grep -qE 'npm install' "$df" && ! grep -qE 'npm ci' "$df"; then + warn "[DC-07] $df uses npm install without npm ci" + fi + if grep -qE 'npm ci|npm install' "$df"; then + if [ ! -f "$dir/package-lock.json" ] && [ ! -f "$dir/yarn.lock" ] && [ ! -f "$dir/pnpm-lock.yaml" ]; then + warn "[DC-07] $df has no lockfile in $dir" + else + pass "[DC-07] $df has lockfile" + fi + fi +done < <(find "$REPO" -name Dockerfile \ + -not -path '*/node_modules/*' \ + -not -path '*/fabric-samples-upstream/*' \ + -not -path '*/.git/*' \ + 2>/dev/null) + +# --- Gateway patterns (if present) --- +GW="$REPO/gateway/server.js" +if [ -f "$GW" ]; then + if grep -q 'withChainLock' "$GW" && grep -q 'evaluateChaincodeRaw' "$GW"; then + if grep -A2 'evaluateChaincodeRaw' "$GW" | grep -q 'withChainLock'; then + warn "[GW-02] gateway evaluates may be serialized behind withChainLock" + else + pass "[GW-02] evaluates not behind global chain lock" + fi + fi + if grep -qE "app\.get\(['\"]/health" "$GW"; then + pass "[GW-01] /health route present" + else + warn "[GW-01] no /health in gateway/server.js" + fi +fi + +# --- Deploy docs --- +for doc in README.md docs/COOLIFY_DEPLOY.md docs/GATEWAY.md AGENTS.md; do + if [ -f "$REPO/$doc" ]; then + pass "[HY-01] found $doc" + break + fi +done + +# --- Compose (delegate) --- +if [ -f "$REPO/docker-compose.yml" ] || [ -f "$REPO/docker-compose.yaml" ]; then + echo + echo "--- docker-compose validation ---" + COMPOSE_ERR=0 + bash "$SCRIPT_DIR/validate-docker-compose.sh" "$REPO" || COMPOSE_ERR=$? + if [ "$COMPOSE_ERR" -ne 0 ]; then + ERR=$((ERR + COMPOSE_ERR)) + fi +fi + +# --- Go tests hint --- +if [ -f "$REPO/Makefile" ] && grep -q 'test' "$REPO/Makefile"; then + info "[HY-03] Makefile has test target — run in CI" +fi +if [ -d "$REPO/chaincode" ] || find "$REPO" -name go.mod -print -quit 2>/dev/null | grep -q .; then + info "Consider: cd chaincode && go test ./..." +fi + +echo +echo "=== Summary ===" +echo "errors=$ERR warnings=$WARN" +if [ "$ERR" -gt 0 ]; then + echo "VERDICT: FAIL" + exit 1 +fi +if [ "$STRICT" = "1" ] && [ "$WARN" -gt 0 ]; then + echo "VERDICT: FAIL (strict mode, warnings treated as errors)" + exit 1 +fi +echo "VERDICT: PASS" +exit 0 diff --git a/scripts/ci/validate-docker-compose.sh b/scripts/ci/validate-docker-compose.sh new file mode 100755 index 0000000..65b5617 --- /dev/null +++ b/scripts/ci/validate-docker-compose.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# Validate docker-compose for production/Coolify deploy safety +# SPDX-License-Identifier: Apache-2.0 +set -euo pipefail + +REPO="${1:-.}" +REPO="$(cd "$REPO" && pwd)" +COOLIFY_STRICT="${COOLIFY_STRICT:-0}" + +ERR=0 +WARN=0 + +err() { echo "ERROR $*"; ERR=$((ERR + 1)); } +warn() { echo "WARN $*"; WARN=$((WARN + 1)); } +info() { echo "INFO $*"; } +pass() { echo "OK $*"; } + +COMPOSE_FILE="" +for f in docker-compose.yml docker-compose.yaml compose.yml; do + if [ -f "$REPO/$f" ]; then + COMPOSE_FILE="$REPO/$f" + break + fi +done + +if [ -z "$COMPOSE_FILE" ]; then + echo "INFO No docker-compose file — skip" + exit 0 +fi + +echo "=== validate-docker-compose: $COMPOSE_FILE ===" + +# Coolify-only keys (exclude_from_hc) are stripped before docker compose config +COMPOSE_VALIDATE_FILE="$COMPOSE_FILE" +if grep -q 'exclude_from_hc:' "$COMPOSE_FILE"; then + COMPOSE_VALIDATE_FILE="$(mktemp)" + sed '/exclude_from_hc:/d' "$COMPOSE_FILE" > "$COMPOSE_VALIDATE_FILE" + info "[DC-00] stripped exclude_from_hc for docker compose config" +fi + +# docker compose config +if command -v docker >/dev/null 2>&1; then + if docker compose -f "$COMPOSE_VALIDATE_FILE" config -q 2>/dev/null; then + pass "[DC-01] docker compose config OK" + else + err "[DC-01] docker compose config failed" + docker compose -f "$COMPOSE_VALIDATE_FILE" config 2>&1 | tail -20 || true + fi +else + warn "[DC-01] docker not available — syntax not fully validated" +fi + +[ "$COMPOSE_VALIDATE_FILE" != "$COMPOSE_FILE" ] && rm -f "$COMPOSE_VALIDATE_FILE" + +# Coolify: no host ports on HTTP services +if grep -E '^\s+ports:' "$COMPOSE_FILE" >/dev/null 2>&1; then + err "[DC-02] ports: found — Coolify uses SERVICE_URL_* instead" +else + pass "[DC-02] no host ports (Coolify-safe)" +fi + +# SERVICE_URL on frontend with port 8080 (Frappe nginx default) +if grep -q 'SERVICE_URL_FRONTEND_8080' "$COMPOSE_FILE"; then + pass "[DC-03] SERVICE_URL_FRONTEND_8080 present" +else + err "[DC-03] missing SERVICE_URL_FRONTEND_8080 on frontend" +fi + +# Bind mounts ./scripts on long-running services (heuristic) +if grep -E '^\s+-\s+['\''"]?\./scripts' "$COMPOSE_FILE" >/dev/null 2>&1; then + if grep -B30 './scripts' "$COMPOSE_FILE" | grep -qE 'restart:\s+(unless-stopped|always)'; then + if [ "$COOLIFY_STRICT" = "1" ]; then + err "[DC-04] ./scripts bind mount on restarting service — Coolify may miss files" + else + warn "[DC-04] ./scripts bind mount — prefer inline compose for Coolify daemons" + fi + else + info "[DC-04] ./scripts mount on non-restarting service (may be OK)" + fi +fi + +# SERVICE_URL on compose (Coolify magic vars) +if grep -qE 'SERVICE_URL_[A-Z0-9_]+' "$COMPOSE_FILE"; then + if grep -qE 'networks:' "$COMPOSE_FILE" && ! grep -qE '^\s+default:' "$COMPOSE_FILE"; then + warn "[DC-05] SERVICE_URL_* with custom networks — risk Traefik 504; see coolify-docker-compose skill" + fi +fi + +# fabric-price-oracle / gateway port hints +if grep -q 'fabric-gateway' "$COMPOSE_FILE"; then + if grep -A80 'fabric-gateway:' "$COMPOSE_FILE" | grep -qE "['\"]?3000:3000|GATEWAY_PORT"; then + pass "[DC-08] fabric-gateway publishes port 3000" + else + warn "[DC-08] fabric-gateway may not publish host port 3000" + fi +fi + +# Retired oracle stub +if grep -q 'fabric-oracle:' "$COMPOSE_FILE"; then + if grep -A15 'fabric-oracle:' "$COMPOSE_FILE" | grep -qE 'profiles:|legacy|Retired|exit 0'; then + pass "[DC-09] fabric-oracle retired/profiled" + else + warn "[DC-09] fabric-oracle still active — should be legacy stub" + fi +fi + +echo "compose errors=$ERR warnings=$WARN" +[ "$ERR" -eq 0 ] || exit 1 +exit 0