From 256e39ccffa1f79e31cc670933bba34cbf4e52d6 Mon Sep 17 00:00:00 2001 From: ImanThiyanga Date: Thu, 17 Sep 2026 11:25:33 +0530 Subject: [PATCH] Revert "Merge pull request 'Dev' (#58) from Dev into production" This reverts commit 821e96e011706021123721ba8f122003beb24f91, reversing changes made to 0ff39e9e58767e547bae31667c2f4d6aba8c25f2. --- .gitattributes | 10 - .gitea/workflows/ci-cd.yml | 419 ------------------ .gitignore | 4 - .../NotificationConfigTestSupport.cs | 2 - Backend/ERPCore.sln | 14 - .../ERPCore/Infra/Gl/GeneralLedgerClient.cs | 5 - .../ERPCore/Infra/Persistence/DataSeeder.cs | 27 +- Backend/ERPCore/Program.cs | 54 +-- Backend/ERPCore/appsettings.Development.json | 14 + .../appsettings.Development.json.example | 21 - Backend/ERPCore/appsettings.Production.json | 11 + Backend/ERPCore/appsettings.json | 11 +- Backend/PROGRESS.md | 28 -- .../m4c_content_units.cpython-313.pyc | Bin 0 -> 13557 bytes .../__pycache__/smoke_common.cpython-313.pyc | Bin 0 -> 15787 bytes Backend/smoke/smoke_common.py | 8 +- Frontend/PROGRESS.md | 7 - Frontend/erp-system/.dockerignore | 10 - Frontend/erp-system/Dockerfile | 38 -- .../purchase-invoices/new/page.tsx | 20 +- .../app/dashboard/sales/day-end/page.tsx | 2 +- .../settings/invoice-template/page.tsx | 4 +- .../erp-system/app/login/forgot/otp/page.tsx | 4 +- Frontend/erp-system/app/login/forgot/page.tsx | 2 +- .../app/login/forgot/reset/page.tsx | 24 +- Frontend/erp-system/eslint.config.mjs | 8 - Frontend/erp-system/next.config.ts | 7 - README.md | 16 +- deploy/apache/erp.hexdive.com.conf | 33 -- deploy/env/erpcore.env.example | 34 -- deploy/on-hold/README.md | 34 -- .../on-hold/apache/test-erp.hexdive.com.conf | 35 -- deploy/on-hold/deploy.two-env.yml | 205 --------- deploy/on-hold/scripts/build-backend.sh | 54 --- deploy/on-hold/scripts/build-frontend.sh | 63 --- deploy/on-hold/scripts/deploy-backend.sh | 91 ---- deploy/on-hold/scripts/deploy-frontend.sh | 59 --- deploy/on-hold/scripts/detect.sh | 60 --- deploy/on-hold/scripts/e2e.sh | 35 -- deploy/on-hold/scripts/prune.sh | 59 --- deploy/on-hold/scripts/release.sh | 59 --- deploy/on-hold/systemd/erpcore@.service | 40 -- deploy/scripts/ci-backend.sh | 33 -- deploy/scripts/ci-detect.sh | 26 -- deploy/scripts/common.sh | 131 ------ deploy/scripts/migration-scan.sh | 53 --- deploy/scripts/wait-healthy.sh | 37 -- deploy/systemd/erpcore.service | 38 -- docs/00-CORE.md | 10 +- docs/01-DOC-GUIDE.md | 3 +- docs/02-SECURITY.md | 2 +- docs/40-DEPLOYMENT.md | 347 --------------- 52 files changed, 87 insertions(+), 2224 deletions(-) delete mode 100644 .gitattributes delete mode 100644 .gitea/workflows/ci-cd.yml create mode 100644 Backend/ERPCore/appsettings.Development.json delete mode 100644 Backend/ERPCore/appsettings.Development.json.example create mode 100644 Backend/ERPCore/appsettings.Production.json create mode 100644 Backend/smoke/__pycache__/m4c_content_units.cpython-313.pyc create mode 100644 Backend/smoke/__pycache__/smoke_common.cpython-313.pyc delete mode 100644 Frontend/erp-system/.dockerignore delete mode 100644 Frontend/erp-system/Dockerfile delete mode 100644 deploy/apache/erp.hexdive.com.conf delete mode 100644 deploy/env/erpcore.env.example delete mode 100644 deploy/on-hold/README.md delete mode 100644 deploy/on-hold/apache/test-erp.hexdive.com.conf delete mode 100644 deploy/on-hold/deploy.two-env.yml delete mode 100644 deploy/on-hold/scripts/build-backend.sh delete mode 100644 deploy/on-hold/scripts/build-frontend.sh delete mode 100644 deploy/on-hold/scripts/deploy-backend.sh delete mode 100644 deploy/on-hold/scripts/deploy-frontend.sh delete mode 100644 deploy/on-hold/scripts/detect.sh delete mode 100644 deploy/on-hold/scripts/e2e.sh delete mode 100644 deploy/on-hold/scripts/prune.sh delete mode 100644 deploy/on-hold/scripts/release.sh delete mode 100644 deploy/on-hold/systemd/erpcore@.service delete mode 100644 deploy/scripts/ci-backend.sh delete mode 100644 deploy/scripts/ci-detect.sh delete mode 100644 deploy/scripts/common.sh delete mode 100644 deploy/scripts/migration-scan.sh delete mode 100644 deploy/scripts/wait-healthy.sh delete mode 100644 deploy/systemd/erpcore.service delete mode 100644 docs/40-DEPLOYMENT.md diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index b7dc900..0000000 --- a/.gitattributes +++ /dev/null @@ -1,10 +0,0 @@ -# Deployment assets execute on the Linux runner / VPS. Force LF no matter which OS commits them: -# a CRLF script fails with "/usr/bin/env: 'bash\r': No such file or directory". -*.sh text eol=lf -*.service text eol=lf -*.conf text eol=lf -*.yml text eol=lf -*.yaml text eol=lf -Dockerfile text eol=lf -.dockerignore text eol=lf -*.env.example text eol=lf diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml deleted file mode 100644 index 95d6354..0000000 --- a/.gitea/workflows/ci-cd.yml +++ /dev/null @@ -1,419 +0,0 @@ -# CI/CD — ERP Core. One workflow for pull-request checks and the production deploy -# (https://erp.hexdive.com). Runbook: docs/40-DEPLOYMENT.md, "Active". -# -# Which jobs run for which event: -# PR opened / updated into Dev or production → checks: detect → backend / frontend / e2e-typecheck / -# scripts (only the sides the PR touches) → ci-ok -# PR merged into production → build-and-deploy (checks are skipped) -# PR merged into Dev, or closed unmerged → nothing (every job skipped) -# Run workflow (manual) → build-and-deploy with the deploy_backend / deploy_frontend boxes -# -# Checks: `ci-ok` is the single required status check for branch protection. It passes when every -# check passed or was skipped, and rejects PRs into production that don't come from Dev or hotfix/*. -# -# Deploy: same shape as the Fitness workflows (publish on the host → stop → copy → fix permissions → -# start → verify), plus: only the side whose folder changed in the PR is deployed, the database is -# backed up when the PR changes migrations, and the verify steps FAIL the job (no automatic rollback). -# Detection only sees the PR being merged: after a failed deploy, fix it and use "Run workflow". -# The test environment is on hold (deploy/on-hold/). -# -# Security model: the dotnet/npm/tsc/eslint check work runs in capped containers without secrets, but -# the deploy/scripts/*.sh steps come from the PR checkout and run on the host runner as root, some with -# ERPSECRET in their environment. Acceptable only while the repo is private and every PR author is a -# trusted team member. -name: CI/CD - ERP Core - -on: - pull_request: - branches: - - Dev - - production - types: - - opened - - synchronize - - reopened - - closed - workflow_dispatch: - inputs: - deploy_backend: - description: "Deploy the backend (after editing /etc/erpcore/erpcore.env, or after a failed deploy)" - type: boolean - default: false - deploy_frontend: - description: "Deploy the frontend" - type: boolean - default: false - -# One group for the whole file, never cancelled: a deploy (or its migration) must not be interrupted -# by a PR push. Superseded check runs therefore queue instead of being cancelled; with the runner at -# capacity 1 they would run one after another anyway. -concurrency: - group: erp-core-cicd - cancel-in-progress: false - -jobs: - # ════════════════════════════ Pull-request checks ════════════════════════════ - detect: - name: detect - if: github.event_name == 'pull_request' && github.event.action != 'closed' - runs-on: linux - outputs: - backend: ${{ steps.detect.outputs.backend }} - frontend: ${{ steps.detect.outputs.frontend }} - e2e: ${{ steps.detect.outputs.e2e }} - scripts: ${{ steps.detect.outputs.scripts }} - steps: - - name: Checkout PR head - env: - ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }} - SHA: ${{ github.event.pull_request.head.sha }} - REF: refs/pull/${{ github.event.pull_request.number }}/head - run: | - basic="$(printf 'oauth2:%s' "$ERP_GIT_TOKEN" | base64 | tr -d '\n')" - url="https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git" - git init -q . - git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 1 "$url" "$SHA" \ - || git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 50 "$url" "$REF" - git checkout -qf "$SHA" && git clean -ffdxq - git log -1 --format='checked out %h %s' - - name: Detect changed sides - id: detect - env: - ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }} - BASE_REF: ${{ github.base_ref }} - run: bash deploy/scripts/ci-detect.sh - - backend: - name: backend - needs: detect - if: needs.detect.outputs.backend == 'true' - runs-on: linux - steps: - - name: Checkout PR head - env: - ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }} - SHA: ${{ github.event.pull_request.head.sha }} - REF: refs/pull/${{ github.event.pull_request.number }}/head - run: | - basic="$(printf 'oauth2:%s' "$ERP_GIT_TOKEN" | base64 | tr -d '\n')" - url="https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git" - git init -q . - git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 1 "$url" "$SHA" \ - || git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 50 "$url" "$REF" - git checkout -qf "$SHA" && git clean -ffdxq - - name: Build, test, pending model changes - run: bash deploy/scripts/ci-backend.sh - - name: Destructive migration scan - env: - ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }} - BASE_REF: ${{ github.base_ref }} - PR_TITLE: ${{ github.event.pull_request.title }} - run: bash deploy/scripts/migration-scan.sh - - frontend: - name: frontend - needs: detect - if: needs.detect.outputs.frontend == 'true' - runs-on: linux - steps: - - name: Checkout PR head - env: - ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }} - SHA: ${{ github.event.pull_request.head.sha }} - REF: refs/pull/${{ github.event.pull_request.number }}/head - run: | - basic="$(printf 'oauth2:%s' "$ERP_GIT_TOKEN" | base64 | tr -d '\n')" - url="https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git" - git init -q . - git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 1 "$url" "$SHA" \ - || git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 50 "$url" "$REF" - git checkout -qf "$SHA" && git clean -ffdxq - # Checks only (no image): build-and-deploy builds its own image with `docker build` after the merge. - - name: Type-check, lint, build - run: | - z=""; if command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled; then z=":z"; fi - docker run --rm --cpus=2 --memory=2500m \ - -e CI=true -e NEXT_TELEMETRY_DISABLED=1 -e NODE_OPTIONS=--max-old-space-size=2048 \ - -v "$PWD/Frontend/erp-system:/app$z" -v erp-npm-cache:/root/.npm -w /app node:24-alpine \ - sh -ec 'npm ci --no-audit --no-fund && npx tsc --noEmit && npx eslint . && npm run build' - - e2e-typecheck: - name: e2e-typecheck - needs: detect - if: needs.detect.outputs.e2e == 'true' - runs-on: linux - steps: - - name: Checkout PR head - env: - ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }} - SHA: ${{ github.event.pull_request.head.sha }} - REF: refs/pull/${{ github.event.pull_request.number }}/head - run: | - basic="$(printf 'oauth2:%s' "$ERP_GIT_TOKEN" | base64 | tr -d '\n')" - url="https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git" - git init -q . - git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 1 "$url" "$SHA" \ - || git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 50 "$url" "$REF" - git checkout -qf "$SHA" && git clean -ffdxq - - name: Type-check Playwright specs - run: | - z=""; if command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled; then z=":z"; fi - docker run --rm --cpus=1 --memory=768m \ - -v "$PWD/Testing/e2e:/e2e$z" -v erp-npm-cache:/root/.npm -w /e2e node:24-alpine \ - sh -ec 'npm ci --no-audit --no-fund && npx tsc --noEmit' - - scripts: - name: scripts - needs: detect - if: needs.detect.outputs.scripts == 'true' - runs-on: linux - steps: - - name: Checkout PR head - env: - ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }} - SHA: ${{ github.event.pull_request.head.sha }} - REF: refs/pull/${{ github.event.pull_request.number }}/head - run: | - basic="$(printf 'oauth2:%s' "$ERP_GIT_TOKEN" | base64 | tr -d '\n')" - url="https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git" - git init -q . - git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 1 "$url" "$SHA" \ - || git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 50 "$url" "$REF" - git checkout -qf "$SHA" && git clean -ffdxq - - name: bash -n + shellcheck - run: | - for f in deploy/scripts/*.sh; do bash -n "$f"; done - z=""; if command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled; then z=":z"; fi - files=""; for f in deploy/scripts/*.sh; do files="$files /mnt/${f#deploy/}"; done - # shellcheck disable=SC2086 - docker run --rm -v "$PWD/deploy:/mnt$z" koalaman/shellcheck:stable \ - --severity=warning --external-sources --source-path=SCRIPTDIR $files - - ci-ok: - name: ci-ok - needs: [detect, backend, frontend, e2e-typecheck, scripts] - if: always() && github.event_name == 'pull_request' && github.event.action != 'closed' - runs-on: linux - steps: - - name: All required checks passed or were skipped - env: - RESULTS: >- - detect=${{ needs.detect.result }} - backend=${{ needs.backend.result }} - frontend=${{ needs.frontend.result }} - e2e-typecheck=${{ needs.e2e-typecheck.result }} - scripts=${{ needs.scripts.result }} - BASE_REF: ${{ github.base_ref }} - HEAD_REF: ${{ github.head_ref }} - run: | - failed=0 - for r in $RESULTS; do - case "${r#*=}" in - success|skipped) echo "ok $r" ;; - *) echo "FAILED $r"; failed=1 ;; - esac - done - if [ "$BASE_REF" = production ]; then - case "$HEAD_REF" in - Dev|hotfix/*) ;; - *) echo "PRs into production must come from Dev or hotfix/* (this one is from '$HEAD_REF')"; failed=1 ;; - esac - fi - exit "$failed" - - # ════════════════════════════ Production deploy ════════════════════════════ - # No `needs: ci-ok`: that job belongs to the PR-open events and is skipped on a merge, so the - # deploy would never run. The pre-merge gate is branch protection requiring ci-ok. - build-and-deploy: - name: Build & Deploy - runs-on: linux - # base.ref matters: without it a PR merged into Dev would deploy production. - if: >- - github.event_name == 'workflow_dispatch' || - (github.event.pull_request.merged == true && - github.event.pull_request.base.ref == 'production') - env: - PUBLISH_DIR: /home/erp/core/publish - BUILD_DIR: /tmp/erp-deploy - SERVICE: erpcore.service - SERVICE_USER: erp - BACKEND_PORT: "5220" - ENV_FILE: /etc/erpcore/erpcore.env - DB_NAME: ERPCore - BACKUP_DIR: /var/backups/erpcore - WEB_IMAGE: erp-web:latest - WEB_CONTAINER: erp-web - WEB_PORT: "3020" - - steps: - - name: Checkout code - env: - ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }} - MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} - BASE_SHA: ${{ github.event.pull_request.base.sha }} - run: | - basic="$(printf 'oauth2:%s' "$ERP_GIT_TOKEN" | base64 | tr -d '\n')" - url="https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git" - fetch() { git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 1 "$url" "$1"; } - git init -q . - # The exact merge commit, not the branch tip (a second merge may already have landed). - if [ -n "$MERGE_SHA" ] && fetch "$MERGE_SHA"; then :; else fetch production; fi - git checkout -qf FETCH_HEAD && git clean -ffdxq - # production as it was before this PR — the detect step compares against it. - if [ -n "$BASE_SHA" ]; then fetch "$BASE_SHA" || echo "could not fetch base $BASE_SHA — both sides will deploy"; fi - git log -1 --format='Deploying %h %s' - - - name: Detect changes - id: changes - env: - EVENT: ${{ github.event_name }} - BASE_SHA: ${{ github.event.pull_request.base.sha }} - INPUT_BACKEND: ${{ inputs.deploy_backend }} - INPUT_FRONTEND: ${{ inputs.deploy_frontend }} - run: | - # A folder's git tree hash changes only when something inside it changed, so comparing - # production-before-the-PR with the merge result works for merge, squash and rebase merges. - changed() { - if [ -z "$BASE_SHA" ] || ! git cat-file -e "$BASE_SHA^{commit}" 2>/dev/null; then echo true; return; fi - now="$(git rev-parse -q --verify "HEAD:$1" || true)" - before="$(git rev-parse -q --verify "$BASE_SHA:$1" || true)" - if [ "$now" != "$before" ]; then echo true; else echo false; fi - } - if [ "$EVENT" = workflow_dispatch ]; then - backend="${INPUT_BACKEND:-false}" - frontend="${INPUT_FRONTEND:-false}" - migrations="$backend" # unknown on a manual run → take the backup - else - backend="$(changed Backend/ERPCore)" - frontend="$(changed Frontend/erp-system)" - migrations="$(changed Backend/ERPCore/Migrations)" - fi - { - echo "backend=$backend" - echo "frontend=$frontend" - echo "migrations=$migrations" - } >> "$GITHUB_OUTPUT" - echo "backend=$backend frontend=$frontend migrations=$migrations" - if [ "$backend" != true ] && [ "$frontend" != true ]; then echo "Nothing to deploy."; fi - - # ── Backend ────────────────────────────────────────────────────────────────────────────── - - name: Restore - if: steps.changes.outputs.backend == 'true' - run: dotnet restore Backend/ERPCore/ERPCore.csproj -r linux-x64 - - - name: Publish - if: steps.changes.outputs.backend == 'true' - run: | - rm -rf "$BUILD_DIR" - mkdir -p "$BUILD_DIR" - dotnet publish Backend/ERPCore/ERPCore.csproj \ - -c Release \ - -r linux-x64 \ - --self-contained true \ - --no-restore \ - -o "$BUILD_DIR" - - - name: Backup database - if: steps.changes.outputs.backend == 'true' && steps.changes.outputs.migrations == 'true' - run: | - mkdir -p "$BACKUP_DIR" - pg_dump="$(command -v pg_dump || echo /usr/pgsql-16/bin/pg_dump)" - file="$BACKUP_DIR/$DB_NAME-$(date -u +%Y%m%dT%H%M%SZ).dump" - runuser -u postgres -- "$pg_dump" -Fc "$DB_NAME" > "$file.partial" - mv "$file.partial" "$file" - echo "Backup: $file" - find "$BACKUP_DIR" -maxdepth 1 -name '*.dump' -mtime +30 -delete - - - name: Guard data paths - if: steps.changes.outputs.backend == 'true' - run: | - # "Copy to deploy folder" empties the publish folder, so uploads and logs must live elsewhere. - [ -r "$ENV_FILE" ] || { echo "❌ $ENV_FILE not found"; exit 1; } - publish="$(realpath -m "$PUBLISH_DIR")" - for key in FileStorage__RootPath LogFile__Path; do - value="$(sed -n "s/^$key=//p" "$ENV_FILE" | tail -n 1 | tr -d '"')" - [ -n "$value" ] || { echo "❌ $key is not set in $ENV_FILE"; exit 1; } - case "$value" in - /*) ;; - *) echo "❌ $key=$value is relative — it would resolve inside $PUBLISH_DIR"; exit 1 ;; - esac - case "$(realpath -m "$value")/" in - "$publish"/*) echo "❌ $key=$value is inside $PUBLISH_DIR, which every deploy wipes"; exit 1 ;; - esac - echo "ok $key=$value" - done - - - name: Stop service - if: steps.changes.outputs.backend == 'true' - run: systemctl stop "$SERVICE" || true - - - name: Copy to deploy folder - if: steps.changes.outputs.backend == 'true' - run: | - mkdir -p "$PUBLISH_DIR" - rm -rf "${PUBLISH_DIR:?}"/* - cp -r "$BUILD_DIR"/. "$PUBLISH_DIR"/ - - - name: Fix ownership and permissions - if: steps.changes.outputs.backend == 'true' - run: | - chown -R "$SERVICE_USER:$SERVICE_USER" "$PUBLISH_DIR" - find "$PUBLISH_DIR" -type d -exec chmod 755 {} + - find "$PUBLISH_DIR" -type f -exec chmod 644 {} + - chmod +x "$PUBLISH_DIR/ERPCore" - - - name: Start service - if: steps.changes.outputs.backend == 'true' - run: | - systemctl reset-failed "$SERVICE" 2>/dev/null || true - systemctl start "$SERVICE" || echo "systemctl start returned an error — the verify step shows why" - # Baseline for the crash check in the verify step. - echo "RESTARTS_AT_START=$(systemctl show -p NRestarts --value "$SERVICE")" >> "$GITHUB_ENV" - - - name: Verify backend - if: steps.changes.outputs.backend == 'true' - env: - HEALTH_TIMEOUT: "300" # migrations run at startup; a crash still fails within seconds - run: bash deploy/scripts/wait-healthy.sh - - # ── Frontend (after the backend: a failed backend step stops the job here) ───────────── - - name: Build frontend image - if: steps.changes.outputs.frontend == 'true' - run: docker build -t "$WEB_IMAGE" Frontend/erp-system - - - name: Stop & remove existing container - if: steps.changes.outputs.frontend == 'true' - run: | - docker stop "$WEB_CONTAINER" 2>/dev/null || true - docker rm "$WEB_CONTAINER" 2>/dev/null || true - - - name: Start container - if: steps.changes.outputs.frontend == 'true' - run: | - docker run -d \ - --name "$WEB_CONTAINER" \ - -p "127.0.0.1:$WEB_PORT:3000" \ - --memory=512m \ - --restart unless-stopped \ - "$WEB_IMAGE" - - - name: Verify frontend - if: steps.changes.outputs.frontend == 'true' - run: | - for _ in $(seq 1 15); do - if curl -fsS -o /dev/null "http://127.0.0.1:$WEB_PORT/login"; then - echo "✅ Frontend serving on 127.0.0.1:$WEB_PORT" - exit 0 - fi - sleep 2 - done - echo "❌ Frontend did not serve /login within 30s" - docker ps -a --filter "name=$WEB_CONTAINER" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" - docker logs --tail 50 "$WEB_CONTAINER" || true - exit 1 - - - name: Remove old images - if: steps.changes.outputs.frontend == 'true' - run: | - docker image prune -f - docker builder prune -f --filter until=168h diff --git a/.gitignore b/.gitignore index 101cad2..d5bfc9d 100644 --- a/.gitignore +++ b/.gitignore @@ -36,8 +36,4 @@ Testing/e2e/test-results/ Testing/e2e/.auth/ Testing/e2e/blob-report/ -# ── Python (Backend/smoke) ──────────────────────────────────────────── -__pycache__/ -*.pyc - diff --git a/Backend/ERPCore.Tests/TestSupport/NotificationConfigTestSupport.cs b/Backend/ERPCore.Tests/TestSupport/NotificationConfigTestSupport.cs index cdbccf9..55128d3 100644 --- a/Backend/ERPCore.Tests/TestSupport/NotificationConfigTestSupport.cs +++ b/Backend/ERPCore.Tests/TestSupport/NotificationConfigTestSupport.cs @@ -11,8 +11,6 @@ internal sealed class TestCurrentUser : ICurrentUser public string UserId => "system"; public int AuditUserId => User.SystemUserId; public bool IsAuthenticated => false; - public string? RoleCode => null; - public bool IsAdmin => false; } internal sealed class ThrowingUnitOfWork : ERPCore.Infra.UoW.IUnitOfWork diff --git a/Backend/ERPCore.sln b/Backend/ERPCore.sln index 01c3425..c779b85 100644 --- a/Backend/ERPCore.sln +++ b/Backend/ERPCore.sln @@ -5,8 +5,6 @@ VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ERPCore", "ERPCore\ERPCore.csproj", "{B38203ED-80EA-41C6-8B7D-B664451299E3}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ERPCore.Tests", "ERPCore.Tests\ERPCore.Tests.csproj", "{E451472F-550F-40A0-AE82-E24ECD6C647A}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -29,18 +27,6 @@ Global {B38203ED-80EA-41C6-8B7D-B664451299E3}.Release|x64.Build.0 = Release|Any CPU {B38203ED-80EA-41C6-8B7D-B664451299E3}.Release|x86.ActiveCfg = Release|Any CPU {B38203ED-80EA-41C6-8B7D-B664451299E3}.Release|x86.Build.0 = Release|Any CPU - {E451472F-550F-40A0-AE82-E24ECD6C647A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E451472F-550F-40A0-AE82-E24ECD6C647A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E451472F-550F-40A0-AE82-E24ECD6C647A}.Debug|x64.ActiveCfg = Debug|Any CPU - {E451472F-550F-40A0-AE82-E24ECD6C647A}.Debug|x64.Build.0 = Debug|Any CPU - {E451472F-550F-40A0-AE82-E24ECD6C647A}.Debug|x86.ActiveCfg = Debug|Any CPU - {E451472F-550F-40A0-AE82-E24ECD6C647A}.Debug|x86.Build.0 = Debug|Any CPU - {E451472F-550F-40A0-AE82-E24ECD6C647A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E451472F-550F-40A0-AE82-E24ECD6C647A}.Release|Any CPU.Build.0 = Release|Any CPU - {E451472F-550F-40A0-AE82-E24ECD6C647A}.Release|x64.ActiveCfg = Release|Any CPU - {E451472F-550F-40A0-AE82-E24ECD6C647A}.Release|x64.Build.0 = Release|Any CPU - {E451472F-550F-40A0-AE82-E24ECD6C647A}.Release|x86.ActiveCfg = Release|Any CPU - {E451472F-550F-40A0-AE82-E24ECD6C647A}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Backend/ERPCore/Infra/Gl/GeneralLedgerClient.cs b/Backend/ERPCore/Infra/Gl/GeneralLedgerClient.cs index dbc036a..170879f 100644 --- a/Backend/ERPCore/Infra/Gl/GeneralLedgerClient.cs +++ b/Backend/ERPCore/Infra/Gl/GeneralLedgerClient.cs @@ -18,11 +18,6 @@ public sealed class GeneralLedgerClient(HttpClient http, IConfiguration configur public async Task SendAsync( HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct) { - // The base appsettings.json ships the key blank; name it here rather than forward GL's 401 - // for an empty X-Api-Key (deployed environments already refuse to start without it). - if (string.IsNullOrWhiteSpace(_apiKey)) - throw new DomainException(ErrorCodes.GlServiceUnavailable, "GeneralLedgerService:ApiKey is not configured.", 503); - var relativeUri = path.TrimStart('/') + queryString; using var request = new HttpRequestMessage(method, relativeUri); request.Headers.TryAddWithoutValidation("X-Api-Key", _apiKey); diff --git a/Backend/ERPCore/Infra/Persistence/DataSeeder.cs b/Backend/ERPCore/Infra/Persistence/DataSeeder.cs index 934ff3f..eea8688 100644 --- a/Backend/ERPCore/Infra/Persistence/DataSeeder.cs +++ b/Backend/ERPCore/Infra/Persistence/DataSeeder.cs @@ -39,29 +39,17 @@ public static class DataSeeder ("PRD-REWORK-LOSS", "Production Rework Loss", ReasonContext.Production), ]; - /// - /// Also seed demo rows — sample warehouses/UOMs/category/items, an on-hand stock layer, - /// customers, bundle and draft sales documents (Seed:DemoData). On for Development - /// and the test deployment (the E2E suite relies on MAIN/SHOP, PCS/BOX, General Goods); - /// never for release, which gets reference data only (docs/40-DEPLOYMENT.md). - /// - public static async Task SeedAsync(ErpDbContext db, bool includeDemoData, CancellationToken ct = default) + public static async Task SeedAsync(ErpDbContext db, CancellationToken ct = default) { - // Reference data — every environment. var dirty = await SeedReasonCodesAsync(db, ct); dirty |= await SeedItemTypesAsync(db, ct); // dirty |= await SeedCompanyProfileAsync(db, ct); dirty |= await SeedProductConfigAsync(db, ct); dirty |= await SeedBranchConfigAsync(db, ct); dirty |= await SeedNotificationConfigAsync(db, ct); - dirty |= await SeedSalesSequencesAsync(db, ct); - - if (includeDemoData) - { - dirty |= await SeedSalesMastersAsync(db, ct); - dirty |= await SeedSalesStockAsync(db, ct); - dirty |= await SeedSalesAsync(db, ct); - } + dirty |= await SeedSalesMastersAsync(db, ct); + dirty |= await SeedSalesStockAsync(db, ct); + dirty |= await SeedSalesAsync(db, ct); if (dirty) await db.SaveChangesAsync(ct); } @@ -356,9 +344,9 @@ public static class DataSeeder } /// - /// Seeds the demo sales data used for UI/backend development: a couple of customer - /// rows and a few draft invoice/slip samples when the required master data already - /// exists. (Current-year document counters are reference data, seeded in SeedAsync.) + /// Seeds the minimum sales bootstrap data needed for UI/backend development: + /// a couple of customer rows, current-year document counters, and a few draft + /// invoice/slip samples when the required master data already exists. /// This intentionally never clears or rewrites any existing rows. /// private static async Task SeedSalesAsync(ErpDbContext db, CancellationToken ct) @@ -366,6 +354,7 @@ public static class DataSeeder var dirty = false; dirty |= await SeedSalesCustomersAsync(db, ct); + dirty |= await SeedSalesSequencesAsync(db, ct); dirty |= await SeedSampleSalesDocsAsync(db, ct); try diff --git a/Backend/ERPCore/Program.cs b/Backend/ERPCore/Program.cs index 0b00392..3b3f451 100644 --- a/Backend/ERPCore/Program.cs +++ b/Backend/ERPCore/Program.cs @@ -15,7 +15,6 @@ using ERPCore.Services.Production; using ERPCore.Services.Stock; using ERPCore.System.Errors; using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.HttpOverrides; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.OpenApi; @@ -24,32 +23,10 @@ using Serilog; var builder = WebApplication.CreateBuilder(args); -// Fail fast on missing deployment config. Outside Development these arrive as environment -// variables (docs/40-DEPLOYMENT.md §5); a blank value would otherwise surface later as an -// unrelated crash (e.g. `new Uri("")` for AuthHex:BaseUrl) instead of naming the key. -if (!builder.Environment.IsDevelopment()) -{ - string[] requiredKeys = - [ - "ConnectionStrings:DefaultConnection", - "AuthHex:BaseUrl", - "GeneralLedgerService:BaseUrl", - "GeneralLedgerService:ApiKey", - "Auth:RsaPublicKeyXml", - ]; - var missing = requiredKeys.Where(k => string.IsNullOrWhiteSpace(builder.Configuration[k])).ToArray(); - if (missing.Length > 0) - throw new InvalidOperationException( - $"Missing required configuration for environment '{builder.Environment.EnvironmentName}': " + - $"{string.Join(", ", missing)}. Set them as environment variables (':' becomes '__', " + - "e.g. ConnectionStrings__DefaultConnection)."); -} - -// Serilog (file sink) — reads levels from configuration, plus a rolling daily file. The path is -// configurable so deployed instances log outside their release folder (LogFile:Path). +// Serilog (file sink) — reads levels from configuration, plus a rolling daily file. builder.Host.UseSerilog((ctx, cfg) => cfg .ReadFrom.Configuration(ctx.Configuration) - .WriteTo.File(ctx.Configuration["LogFile:Path"] ?? "logs/erpcore-.log", rollingInterval: RollingInterval.Day)); + .WriteTo.File("logs/erpcore-.log", rollingInterval: RollingInterval.Day)); // Controllers + JSON: serialize enums as their string names (docs/11 §8, camelCase). builder.Services.AddControllers() @@ -72,10 +49,8 @@ builder.Services.AddErpJwtAuth(builder.Configuration); // AuthController proxy → AuthHex (docs/11 §2.0) builder.Services.AddHttpClient(c => { - // Blank counts as missing: the base appsettings.json ships these keys empty on purpose. - var baseUrl = builder.Configuration["AuthHex:BaseUrl"]; - if (string.IsNullOrWhiteSpace(baseUrl)) - throw new InvalidOperationException("AuthHex:BaseUrl is not configured (set it in appsettings.Development.json locally)."); + var baseUrl = builder.Configuration["AuthHex:BaseUrl"] + ?? throw new InvalidOperationException("AuthHex:BaseUrl is not configured."); c.BaseAddress = new Uri(baseUrl); }); builder.Services.AddScoped(); @@ -85,9 +60,8 @@ builder.Services.AddScoped(); // General Ledger service proxy → external GL microservice (docs/12-GENERAL-LEDGER-INTEGRATION.md) builder.Services.AddHttpClient(c => { - var baseUrl = builder.Configuration["GeneralLedgerService:BaseUrl"]; - if (string.IsNullOrWhiteSpace(baseUrl)) - throw new InvalidOperationException("GeneralLedgerService:BaseUrl is not configured (set it in appsettings.Development.json locally)."); + var baseUrl = builder.Configuration["GeneralLedgerService:BaseUrl"] + ?? throw new InvalidOperationException("GeneralLedgerService:BaseUrl is not configured."); c.BaseAddress = new Uri(baseUrl); }); builder.Services.AddScoped(); @@ -231,10 +205,7 @@ using (var scope = app.Services.CreateScope()) await db.Database.MigrateAsync(); try { - // Demo rows (sample catalog, stock, customers, sales docs) default on only in Development; - // the test deployment opts in with Seed__DemoData=true, release never does. - var includeDemoData = app.Configuration.GetValue("Seed:DemoData", app.Environment.IsDevelopment()); - await DataSeeder.SeedAsync(db, includeDemoData); + await DataSeeder.SeedAsync(db); } catch (Exception ex) { @@ -242,18 +213,9 @@ using (var scope = app.Services.CreateScope()) } } -// Behind the co-located Apache reverse proxy (docs/40-DEPLOYMENT.md): take the client IP and -// scheme from X-Forwarded-*. Only loopback proxies are trusted by default, which is exactly it. -app.UseForwardedHeaders(new ForwardedHeadersOptions -{ - ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto -}); - app.UseSerilogRequestLogging(); -// Off in Production only. The test deployment (Staging) keeps it; it is still not public there -// because Apache proxies only /api/ — reach it through an SSH tunnel to the backend port. -if (!app.Environment.IsProduction()) +if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); diff --git a/Backend/ERPCore/appsettings.Development.json b/Backend/ERPCore/appsettings.Development.json new file mode 100644 index 0000000..16c2ab2 --- /dev/null +++ b/Backend/ERPCore/appsettings.Development.json @@ -0,0 +1,14 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "ConnectionStrings": { + "DefaultConnection": "Host=127.0.0.1;Port=5433;Database=ERPCoreTest;Username=postgres;Password=post@hexdive" + }, + "AuthHex": { + "BaseUrl": "http://localhost:5011" + } +} diff --git a/Backend/ERPCore/appsettings.Development.json.example b/Backend/ERPCore/appsettings.Development.json.example deleted file mode 100644 index 8796545..0000000 --- a/Backend/ERPCore/appsettings.Development.json.example +++ /dev/null @@ -1,21 +0,0 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "ConnectionStrings": { - "DefaultConnection": "Host=127.0.0.1;Port=5432;Database=ERPCore;Username=postgres;Password=CHANGE_ME" - }, - "AuthHex": { - "BaseUrl": "http://localhost:5011" - }, - "GeneralLedgerService": { - "BaseUrl": "https://localhost:7024/api/v1/", - "ApiKey": "ask-the-team-for-the-dev-key" - }, - "Seed": { - "DemoData": true - } -} diff --git a/Backend/ERPCore/appsettings.Production.json b/Backend/ERPCore/appsettings.Production.json new file mode 100644 index 0000000..f2762ae --- /dev/null +++ b/Backend/ERPCore/appsettings.Production.json @@ -0,0 +1,11 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "" + }, + "Jwt": { + "SigningKey": "" + }, + "AuthHex": { + "BaseUrl": "" + } +} diff --git a/Backend/ERPCore/appsettings.json b/Backend/ERPCore/appsettings.json index 6a604fb..ed05dc3 100644 --- a/Backend/ERPCore/appsettings.json +++ b/Backend/ERPCore/appsettings.json @@ -6,7 +6,7 @@ } }, "ConnectionStrings": { - "DefaultConnection": "" + "DefaultConnection": "Host=localhost;Port=5432;Database=ERPCore;Username=root;Password=root" }, "Auth": { "Issuer": "AuthHex", @@ -16,18 +16,15 @@ "RequiredRoleCode": "" }, "AuthHex": { - "BaseUrl": "" + "BaseUrl": "http://localhost:5602" }, "FileStorage": { "RootPath": "App_Data/hr-documents", "MaxSizeBytes": 10485760 }, - "LogFile": { - "Path": "logs/erpcore-.log" - }, "GeneralLedgerService": { - "BaseUrl": "", - "ApiKey": "" + "BaseUrl": "https://localhost:7024/api/v1/", + "ApiKey": "1A5FE0E389C029B1FBCD2A94650CEBF0656083BF06FF48E5E11987040ABA262D" }, "Grn": { "GlInventoryAccountCode": "1100", diff --git a/Backend/PROGRESS.md b/Backend/PROGRESS.md index af2cbe2..b80f704 100644 --- a/Backend/PROGRESS.md +++ b/Backend/PROGRESS.md @@ -4,34 +4,6 @@ Legend: `[ ]` not started · `[~]` in progress · `[x]` done Spec: `docs/10-BACKEND-PHASE1.md` (model + rules) · `docs/11-BACKEND-PHASE1.md` (API) Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** as the code. When ticking `[x]`, append a short note + any deviation. -## Deployment readiness + CI/CD (2026-09-14) — `docs/40-DEPLOYMENT.md` - -- [x] **Secrets out of git.** `appsettings.Development.json` untracked (template: `appsettings.Development.json.example`); `appsettings.Production.json` deleted (its `Jwt:SigningKey` was unused); base `appsettings.json` now blanks the connection string, `AuthHex:BaseUrl` and `GeneralLedgerService:BaseUrl/ApiKey`; `Backend/smoke/smoke_common.py` has no default password (`ERP_SMOKE_PASSWORD`). Values committed earlier still need rotating (`docs/02-SECURITY.md` AR-06). -- [x] **Fail-fast config check** (`Program.cs`): outside Development, startup throws one `InvalidOperationException` naming every blank required key. Verified: `ASPNETCORE_ENVIRONMENT=Production` with no env vars → lists the four blank keys and exits. In Development (where that check is skipped) the `AuthHex`/`GeneralLedgerService` HTTP-client registrations now treat a **blank** `BaseUrl` as missing — they only caught null, so the newly blank base values would have surfaced as an unnamed `UriFormatException` — and `GeneralLedgerClient` returns `GL_SERVICE_UNAVAILABLE` naming `GeneralLedgerService:ApiKey` instead of forwarding GL's 401 for an empty `X-Api-Key`. -- [x] **`LogFile:Path`** drives the Serilog file sink (default unchanged); **Swagger** is now off only in Production (test/Staging keeps it; Apache does not proxy it); **`UseForwardedHeaders`** for X-Forwarded-For/Proto from the co-located Apache. -- [x] **Seeder split** — `DataSeeder.SeedAsync(db, includeDemoData)`: reference data (reason codes, item types, product/branch/notification config, sales document counters) always; demo data (MAIN/SHOP warehouses, PCS/BOX UOMs, General Goods, items, stock layers, customers, bundles, sample docs) only with `Seed:DemoData` — default on in Development, `Seed__DemoData=true` on test, never on release. **Deviation from the plan:** no `Seed` key in base `appsettings.json`, because it would override the Development default. -- [x] **`ERPCore.Tests` added to `ERPCore.sln`.** It was outside the solution and had **stopped compiling** (`TestCurrentUser` lacked `ICurrentUser.RoleCode`/`IsAdmin`, added in `f664961`) — fixed, so CI can run it. -- [x] `dotnet ef migrations has-pending-model-changes` verified with a blank connection string (what CI has) → "No changes have been made to the model since the last migration." -- Pipeline assets: `.gitea/workflows/{ci,deploy}.yml`, `deploy/scripts/*.sh`, `deploy/{systemd,apache,env}/`. `detect.sh` exercised locally against a scratch state dir (first deploy / no-op / frontend-only / forced backend / rollback / non-deployable branch — all as designed); `migration-scan.sh`'s awk ignores `Down()` and flags real `Up()` drops (e.g. `20260831060800_AddPurchaseInvoices`); shellcheck clean at `--severity=warning`. **Not yet exercised:** anything needing Docker or the VPS (container builds, systemd, Apache, the Gitea runner). -- [x] **2026-09-15 — switched to a production-only, Fitness-style deployment** (only `erp.hexdive.com` exists; `docs/40-DEPLOYMENT.md` "Active"). - - A PR merged into `production` runs one job: - - detect which side changed (tree hashes, `base.sha` vs merge commit); - - host `dotnet publish`; - - `pg_dump` when migrations changed (30-day retention); - - guard that uploads and logs are outside the publish folder; - - stop `erpcore.service`, wipe and copy `/home/erp/core/publish`, `chown erp`, start; - - `deploy/scripts/wait-healthy.sh`, which fails fast on a crash and allows up to 300 s for migrations. - - No auto-rollback. - - The two-environment pipeline, test Apache vhost, `erpcore@.service` and the hash/release/E2E/prune scripts moved to `deploy/on-hold/` (README explains restoring); the `test` branch in `common.sh` is commented out. - - Verified locally: both workflows are valid YAML; shellcheck is clean on the active scripts and on every inline `run:` block; the detect step gives the right flags on real merges (`8835ba9` frontend-only, `ee9fa40` backend+migrations, `8555702` neither, `eb36e0a` both), on a manual run and when the base commit is unavailable (both sides); the guard step rejects paths inside the publish folder (including the folder itself and the log path) and accepts `..`-escapes, sibling prefixes and quoted values. - - **Not exercised:** the real Gitea run, `erpcore.service` under `/home` (SELinux), `pg_dump` via `runuser`. -- [x] **2026-09-15 — `ci.yml` + `deploy.yml` merged into one `.gitea/workflows/ci-cd.yml`.** - - Check jobs run on PR open / update / reopen into `Dev` or `production`; `Build & Deploy` runs only when a PR is merged into `production` (the job checks `pull_request.base.ref` itself, so a merge into `Dev` deploys nothing) or on a manual run. - - One concurrency group for the file, never cancelled: a PR push can't interrupt a deploy, but superseded check runs now queue. - - The deploy's env vars moved from workflow level to the deploy job, so they don't leak into the check jobs. - - Verified locally: valid YAML, and every job `if` evaluated against six event shapes (PR opened into Dev, updated into production, merged into Dev, merged into production, closed unmerged, manual run) gives the intended jobs. - - Once branch protection is set up, the required check appears as `CI/CD - ERP Core / ci-ok`. - ## Per-variant content size (2026-08-11) — follow-up to the UOM re-model below The UOM re-model put `ContentQty`/`ContentUnit` on `Item` correctly, but the item **create page diff --git a/Backend/smoke/__pycache__/m4c_content_units.cpython-313.pyc b/Backend/smoke/__pycache__/m4c_content_units.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..eba30981ad557c5bacb632f74ae313d776c2c7b5 GIT binary patch literal 13557 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 HU|jwSHsI{x literal 0 HcmV?d00001 diff --git a/Backend/smoke/__pycache__/smoke_common.cpython-313.pyc b/Backend/smoke/__pycache__/smoke_common.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3e7c7a8be9d75f4e1219dd98ca9b78aebe7699fa GIT binary patch literal 15787 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 J(OMq){{wfLKa>Cf literal 0 HcmV?d00001 diff --git a/Backend/smoke/smoke_common.py b/Backend/smoke/smoke_common.py index 1bfcf4c..f8e5175 100644 --- a/Backend/smoke/smoke_common.py +++ b/Backend/smoke/smoke_common.py @@ -38,8 +38,7 @@ DEFAULTS = { "api": "http://localhost:5224/api/v1", "auth": "http://localhost:5602", "user": "admin@gmail.com", - # Deliberately no default: credentials never live in the repo (docs/40-DEPLOYMENT.md §5). - "password": None, + "password": "Naveen@99", } @@ -47,10 +46,7 @@ def parse_args(description: str) -> argparse.Namespace: p = argparse.ArgumentParser(description=description) for key, default in DEFAULTS.items(): p.add_argument(f"--{key}", default=os.environ.get(f"ERP_SMOKE_{key.upper()}", default)) - args = p.parse_args() - if not args.password: - p.error("no password given: set ERP_SMOKE_PASSWORD or pass --password") - return args + return p.parse_args() class Response: diff --git a/Frontend/PROGRESS.md b/Frontend/PROGRESS.md index 6221ac3..627db79 100644 --- a/Frontend/PROGRESS.md +++ b/Frontend/PROGRESS.md @@ -345,10 +345,3 @@ Spec: `docs/21-FRONTEND-PHASE2.md` (flows/screens) · contract: `docs/30-BACKEND 5. **Inline "add variant category":** a "+" icon button next to the checkboxes opens an inline name field that calls `variantCategoriesApi.create()` directly from `/new`, appends the result to the in-memory list, and auto-checks it — so a brand-new dimension (e.g. "Material") can be added without leaving the Item form, and it also then appears on `/dashboard/products/variants`. 6. **Color gets a real color picker:** for whichever checked category is literally named "Color" (case-insensitive), the free-text input is replaced with a native `` swatch picker *plus* a required "Color name" text field — picking red alone isn't enough, a name is mandatory too. The pair is encoded as a single string `"|"` in `valuesByCategory` (helpers `encodeColorValue`/`decodeColorValue`/`partLabel` in `new/page.tsx`) so the existing generic value-list plumbing didn't need a parallel data shape; every place that displays or SKU-generates from a color value decodes it back to just the name (the hex only ever drives the swatch dot next to chips and table cells) — so SKUs read `HAR-CRI` (from "Crimson"), never `HAR-EF4`. - Verified: `tsc --noEmit` clean after every step (same pre-existing `login/page.tsx` error only, confirmed unchanged throughout). Each change was driven end-to-end through a headless Playwright session against the dev server and screenshotted — field removal, subcategory always-visible + disabled state, checkbox show/hide of category builders, cartesian flat table with 2+ active categories, inline category creation followed by its builder appearing immediately, and the color picker + name → chip swatch → table swatch → final SKU/item name chain — with `console --errors` clean at every step and at least one full create-and-redirect-to-Items-list confirmed per major change. - -## Deployment readiness (2026-09-14) — `docs/40-DEPLOYMENT.md` -- [x] **Standalone build + runtime image.** `next.config.ts` → `output: "standalone"`; runtime-only `Dockerfile` (`node:24-alpine`, non-root, copies `.next/standalone` + `.next/static` + `public`) and `.dockerignore` (keeps `.next`). The `/api` rewrite is resolved at build time and only matters locally — Apache sends `/api/` straight to ERPCore in deployed environments, so one image serves both test and release. **Deviation from the plan:** Node 24 instead of 20 (Node 20 is past end-of-life). Verified: `npm run build` produced `.next/standalone/server.js`; started on :3999 → `GET /login` 200, `GET /dashboard` without a session cookie → 307 to `/login`. -- [x] **Lint can gate CI.** `react-hooks/set-state-in-effect` temporarily `"warn"` (React Compiler rule from eslint-config-next 16, ~80 existing hits). Fixed the other 12 errors: 7 unescaped apostrophes (`sales/day-end`, `settings/invoice-template`, `login/forgot`, `login/forgot/otp`, `login/forgot/reset`); `PasswordRequirement` hoisted to module scope in `login/forgot/reset` (a component created during render remounts on every keystroke); the `visibleGrns` memo moved above `toggleAll` in `procurement/purchase-invoices/new` (it was read before its declaration, so the compiler could not preserve the memo). Now `eslint .` → 0 errors / 114 warnings; `tsc --noEmit` clean. -- [ ] Clear the `set-state-in-effect` backlog, then set the rule back to `"error"`. -- [x] **2026-09-15 — production-only deploy (`docs/40-DEPLOYMENT.md` "Active").** Supersedes the runtime-only image above: `Dockerfile` is now multi-stage (deps → builder → runner on `node:24-alpine`, the Client_New_Fe pattern), so `next build` runs inside `docker build` on the server, and `.dockerignore` excludes `.next` and `node_modules`. Still `output: "standalone"` and no build args. The deploy job replaces the `erp-web` container on `127.0.0.1:3020` only when `Frontend/erp-system` changed and fails the job if `/login` doesn't answer within 30 s. PR checks now run `tsc` + `eslint` + `next build` in a capped container without producing an image. The multi-stage `docker build` has not been run yet (no Docker locally). -- [x] **2026-09-15 — one workflow file.** `.gitea/workflows/ci.yml` and `deploy.yml` merged into `.gitea/workflows/ci-cd.yml`: the frontend PR check (`tsc` + `eslint` + `next build`) runs while a PR is open, and the `Build & Deploy` job (`docker build` → replace `erp-web` → `/login` check) runs only when a PR is merged into `production` or on a manual run. No change to the Dockerfile or the app. diff --git a/Frontend/erp-system/.dockerignore b/Frontend/erp-system/.dockerignore deleted file mode 100644 index 68c3d5a..0000000 --- a/Frontend/erp-system/.dockerignore +++ /dev/null @@ -1,10 +0,0 @@ -# `next build` runs inside the image (see Dockerfile), so local build output and dependencies must -# not be sent as build context: they are large, and a Windows node_modules would break the Linux build. -node_modules -.next -.git -.env* -*.tsbuildinfo -npm-debug.log* -Dockerfile -.dockerignore diff --git a/Frontend/erp-system/Dockerfile b/Frontend/erp-system/Dockerfile deleted file mode 100644 index 5faee5d..0000000 --- a/Frontend/erp-system/Dockerfile +++ /dev/null @@ -1,38 +0,0 @@ -# ERP frontend image — built on the server by .gitea/workflows/ci-cd.yml (`docker build`), using the -# same pattern as Client_New_Fe. There are no build args: the app only calls same-origin /api/v1 and -# Apache routes /api/ straight to ERPCore (docs/40-DEPLOYMENT.md, "Active"). - -FROM node:24-alpine AS deps -WORKDIR /app -RUN apk add --no-cache libc6-compat -COPY package.json package-lock.json ./ -RUN npm ci --no-audit --no-fund - -FROM node:24-alpine AS builder -WORKDIR /app -COPY --from=deps /app/node_modules ./node_modules -COPY . . -# Shared 8 GB VPS: keep the build's heap bounded (docker build has no --memory under BuildKit). -ENV NEXT_TELEMETRY_DISABLED=1 \ - NODE_ENV=production \ - NODE_OPTIONS=--max-old-space-size=2048 -RUN npm run build - -FROM node:24-alpine AS runner -WORKDIR /app -ENV NODE_ENV=production \ - NEXT_TELEMETRY_DISABLED=1 \ - PORT=3000 \ - HOSTNAME=0.0.0.0 - -RUN addgroup --system --gid 1001 nodejs \ - && adduser --system --uid 1001 nextjs - -COPY --from=builder --chown=nextjs:nodejs /app/public ./public -COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ -COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static - -USER nextjs -EXPOSE 3000 - -CMD ["node", "server.js"] diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-invoices/new/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-invoices/new/page.tsx index 82d27a3..7ae4efb 100644 --- a/Frontend/erp-system/app/dashboard/procurement/purchase-invoices/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-invoices/new/page.tsx @@ -112,17 +112,6 @@ function NewPurchaseInvoiceContent() { [vendors] ) - // Deep-linked from a GRN detail page's "Create Purchase Invoice" prompt — that GRN is the - // whole reason this page was opened, so show only it rather than the vendor's full eligible - // list (which could pull in unrelated GRNs the user never meant to consolidate here). - // Declared before toggleAll, which reads it: referencing it ahead of its declaration stopped - // the React Compiler from preserving this memo (react-hooks/preserve-manual-memoization). - const deepLinkedGrnId = grnIdFromQuery ? Number(grnIdFromQuery) : null - const visibleGrns = useMemo( - () => (deepLinkedGrnId === null ? (eligibleGrns ?? []) : (eligibleGrns ?? []).filter((g) => g.grnId === deepLinkedGrnId)), - [eligibleGrns, deepLinkedGrnId] - ) - function toggleGrn(grnId: number, checked: boolean) { setSelectedGrnIds((prev) => { const next = new Set(prev) @@ -136,6 +125,15 @@ function NewPurchaseInvoiceContent() { setSelectedGrnIds(checked ? new Set(visibleGrns.map((g) => g.grnId)) : new Set()) } + // Deep-linked from a GRN detail page's "Create Purchase Invoice" prompt — that GRN is the + // whole reason this page was opened, so show only it rather than the vendor's full eligible + // list (which could pull in unrelated GRNs the user never meant to consolidate here). + const deepLinkedGrnId = grnIdFromQuery ? Number(grnIdFromQuery) : null + const visibleGrns = useMemo( + () => (deepLinkedGrnId === null ? (eligibleGrns ?? []) : (eligibleGrns ?? []).filter((g) => g.grnId === deepLinkedGrnId)), + [eligibleGrns, deepLinkedGrnId] + ) + const selectedGrns = useMemo( () => (eligibleGrns ?? []).filter((g) => selectedGrnIds.has(g.grnId)), [eligibleGrns, selectedGrnIds] diff --git a/Frontend/erp-system/app/dashboard/sales/day-end/page.tsx b/Frontend/erp-system/app/dashboard/sales/day-end/page.tsx index f4c1d95..5e4dc0b 100644 --- a/Frontend/erp-system/app/dashboard/sales/day-end/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/day-end/page.tsx @@ -88,7 +88,7 @@ export default function SalesDayEndPage() {

Day End

- Close a cashier's business day — locks their posted sales slips and bundle sales, and posts one consolidated journal entry to the ledger. + Close a cashier's business day — locks their posted sales slips and bundle sales, and posts one consolidated journal entry to the ledger.

diff --git a/Frontend/erp-system/app/dashboard/settings/invoice-template/page.tsx b/Frontend/erp-system/app/dashboard/settings/invoice-template/page.tsx index 61cc014..9ae997f 100644 --- a/Frontend/erp-system/app/dashboard/settings/invoice-template/page.tsx +++ b/Frontend/erp-system/app/dashboard/settings/invoice-template/page.tsx @@ -149,14 +149,14 @@ export default function InvoiceTemplatePage() {
Show bank details - Prints the company's bank name, account and SWIFT code on invoices. + Prints the company's bank name, account and SWIFT code on invoices.
Show tax registration no. - Prints the company's tax registration number in the invoice header. + Prints the company's tax registration number in the invoice header.
diff --git a/Frontend/erp-system/app/login/forgot/otp/page.tsx b/Frontend/erp-system/app/login/forgot/otp/page.tsx index 4fc0365..0d22975 100644 --- a/Frontend/erp-system/app/login/forgot/otp/page.tsx +++ b/Frontend/erp-system/app/login/forgot/otp/page.tsx @@ -32,7 +32,7 @@ export default function ForgotOtpPage() {

Verify your email

-

We've sent a verification code to your email address. Enter it below to continue.

+

We've sent a verification code to your email address. Enter it below to continue.

@@ -48,7 +48,7 @@ export default function ForgotOtpPage() { }} className="text-sm font-medium text-foreground hover:text-foreground/80 transition-colors" > - Didn't receive the code? Resend + Didn't receive the code? Resend ) : (

Resend code available in {Math.max(0, secondsLeft)}s

diff --git a/Frontend/erp-system/app/login/forgot/page.tsx b/Frontend/erp-system/app/login/forgot/page.tsx index 902233c..e78493b 100644 --- a/Frontend/erp-system/app/login/forgot/page.tsx +++ b/Frontend/erp-system/app/login/forgot/page.tsx @@ -22,7 +22,7 @@ export default function ForgotEmailPage() {

Reset your password

-

Enter the email address associated with your account and we'll send you a one-time code to verify your identity.

+

Enter the email address associated with your account and we'll send you a one-time code to verify your identity.

diff --git a/Frontend/erp-system/app/login/forgot/reset/page.tsx b/Frontend/erp-system/app/login/forgot/reset/page.tsx index 63bb03d..856958a 100644 --- a/Frontend/erp-system/app/login/forgot/reset/page.tsx +++ b/Frontend/erp-system/app/login/forgot/reset/page.tsx @@ -13,19 +13,6 @@ import { } from "@/components/ui/dialog" import { Check, Eye, EyeOff } from "lucide-react" -// Declared at module scope: a component defined inside the page body is a new type on every -// render, so React would remount (and reset) each requirement row on each keystroke. -function PasswordRequirement({ met, label }: { met: boolean; label: string }) { - return ( -
-
- {met ? : ○} -
- {label} -
- ) -} - export default function ForgotResetPage() { const router = useRouter() const [password, setPassword] = useState("") @@ -41,6 +28,15 @@ export default function ForgotResetPage() { const passwordsMatch = password === confirm && password.length > 0 const allValid = meetsLength && hasUpper && hasNumber && hasSpecial && passwordsMatch + const PasswordRequirement = ({ met, label }: { met: boolean; label: string }) => ( +
+
+ {met ? : ○} +
+ {label} +
+ ) + return (
@@ -101,7 +97,7 @@ export default function ForgotResetPage() {
{!passwordsMatch && confirm.length > 0 && ( -

Passwords don't match.

+

Passwords don't match.

)}
diff --git a/Frontend/erp-system/eslint.config.mjs b/Frontend/erp-system/eslint.config.mjs index 85885c5..05e726d 100644 --- a/Frontend/erp-system/eslint.config.mjs +++ b/Frontend/erp-system/eslint.config.mjs @@ -5,14 +5,6 @@ import nextTs from "eslint-config-next/typescript"; const eslintConfig = defineConfig([ ...nextVitals, ...nextTs, - { - rules: { - // Temporarily a warning: ~80 pre-existing violations from the React Compiler rules that - // eslint-config-next 16 enabled. Everything else stays an error so CI can gate on lint - // now; flip this back to "error" once the backlog is cleared (docs/40-DEPLOYMENT.md §7). - "react-hooks/set-state-in-effect": "warn", - }, - }, // Override default ignores of eslint-config-next. globalIgnores([ // Default ignores of eslint-config-next: diff --git a/Frontend/erp-system/next.config.ts b/Frontend/erp-system/next.config.ts index 3df14c2..3bdd152 100644 --- a/Frontend/erp-system/next.config.ts +++ b/Frontend/erp-system/next.config.ts @@ -8,16 +8,9 @@ import path from "path" // // Defaults to the backend's HTTP port: the HTTPS port uses a self-signed dev cert that // this proxy would reject. -// -// Rewrites are resolved at `next build`, so this is effectively a local-dev setting. In the -// deployed environments Apache sends /api/ straight to ERPCore before Next ever sees it, which -// keeps one built image valid for both test and release (docs/40-DEPLOYMENT.md). const BACKEND_ORIGIN = process.env.BACKEND_ORIGIN ?? "http://localhost:5224" const nextConfig: NextConfig = { - // Self-contained server bundle (.next/standalone) for the runtime-only Docker image - // (Dockerfile in this folder). Has no effect on `next dev`. - output: "standalone", turbopack: { root: path.join(__dirname), }, diff --git a/README.md b/README.md index 20cd2b9..b9d2a2b 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,6 @@ erp-monorepo/ | `docs/10-BACKEND-PHASE1.md` | Backend spec: SRS + ER model + architecture | | `docs/11-BACKEND-PHASE1.md` | Backend API reference (complete req/res) | | `docs/20-FRONTEND.md` | Frontend user-flows + rules + validation posture | -| `docs/40-DEPLOYMENT.md` | CI/CD pipelines, test/release environments, server runbook | | `Backend/PROGRESS.md`, `Frontend/PROGRESS.md` | Change checklists (git-shared) | ## Prerequisites @@ -36,8 +35,7 @@ erp-monorepo/ # create the database in your local Postgres first, e.g.: # createdb -U postgres ERPCore (or: CREATE DATABASE "ERPCore"; in psql) cd Backend/ERPCore -# local settings are git-ignored: copy the template, then fill in your Postgres login and the GL key -cp appsettings.Development.json.example appsettings.Development.json +# set Username/Password in appsettings.Development.json to your local Postgres login dotnet ef database update dotnet run ``` @@ -48,19 +46,15 @@ Full setup (packages, folder scaffold, Program.cs wiring) is in `docs/00-CORE.md ## Run the frontend ```bash -cd Frontend/erp-system -cp .env.local.example .env.local # BACKEND_ORIGIN=http://localhost:5224 (the Next proxy target) +cd Frontend +cp .env.local.example .env.local # set NEXT_PUBLIC_API_BASE_URL=https://localhost: npm install npm run dev ``` ## Configuration & secrets -- `appsettings.json` is committed and holds **no secrets**; environment-specific values are blank there. -- Local: `appsettings.Development.json` (git-ignored, from the `.example`). Deployed (production): environment variables in `/etc/erpcore/erpcore.env` on the server — outside Development the API refuses to start when a required key is missing. -- Details and the full key list: `docs/40-DEPLOYMENT.md §5`. Credentials committed in the past must be rotated (`docs/02-SECURITY.md` AR-06). - -## Deployment -One workflow, `.gitea/workflows/ci-cd.yml`, does both jobs. Pull requests get checks (only the changed side is checked), and merging a PR into `production` deploys **production** (`erp.hexdive.com`) Fitness-style: publish on the server → stop → copy → start → verify, and only for the backend/frontend whose folder changed. A test environment is designed but on hold (`deploy/on-hold/`). See `docs/40-DEPLOYMENT.md`. +- Three appsettings files: base (`appsettings.json`, placeholders only), `Development` (localhost), `Production` (env vars). +- **Local dev only** currently. Before any shared/staging/production use, move secrets to User Secrets (dev) and environment variables (prod), keep `appsettings.Development.json` / `appsettings.Production.json` out of git, and rotate any credential ever committed. ## Tech stack **Backend:** .NET 10, ASP.NET Core Web API, EF Core 10, PostgreSQL (Npgsql), JWT bearer auth, Serilog, Swashbuckle/Swagger, HealthChecks. Layering: Controller → Service → Repository → UnitOfWork, DTOs at the controller boundary, FIFO in a domain service. diff --git a/deploy/apache/erp.hexdive.com.conf b/deploy/apache/erp.hexdive.com.conf deleted file mode 100644 index 0e2deb5..0000000 --- a/deploy/apache/erp.hexdive.com.conf +++ /dev/null @@ -1,33 +0,0 @@ -# ERP-Core RELEASE environment (deployed from the production branch) — docs/40-DEPLOYMENT.md §4. -# -# Install: cp deploy/apache/erp.hexdive.com.conf /etc/httpd/conf.d/ -# apachectl configtest && systemctl reload httpd -# certbot --apache -d erp.hexdive.com -# certbot copies this vhost to :443 with the certificate and turns :80 into a redirect to https. -# The session cookies are Secure + SameSite=Strict, so login only works over HTTPS. -# Needs mod_proxy, mod_proxy_http, mod_headers, mod_ssl and SELinux httpd_can_network_connect=on. - - - ServerName erp.hexdive.com - - ProxyRequests Off - ProxyPreserveHost On - ProxyTimeout 120 - RequestHeader set X-Forwarded-Proto expr=%{REQUEST_SCHEME} - - Header always set Strict-Transport-Security "max-age=31536000" "expr=%{HTTPS} == 'on'" - Header always set X-Content-Type-Options "nosniff" - - # HR documents are capped at 10 MB by the API (FileStorage:MaxSizeBytes); allow a little overhead. - LimitRequestBody 12582912 - - # Order matters — the first matching ProxyPass wins, so /api/ must come before /. - # /health and /swagger are deliberately NOT proxied (they fall through to Next and 404). - ProxyPass /api/ http://127.0.0.1:5220/api/ - ProxyPassReverse /api/ http://127.0.0.1:5220/api/ - ProxyPass / http://127.0.0.1:3020/ - ProxyPassReverse / http://127.0.0.1:3020/ - - ErrorLog /var/log/httpd/erp.hexdive.com-error.log - CustomLog /var/log/httpd/erp.hexdive.com-access.log combined - diff --git a/deploy/env/erpcore.env.example b/deploy/env/erpcore.env.example deleted file mode 100644 index 1e39b2d..0000000 --- a/deploy/env/erpcore.env.example +++ /dev/null @@ -1,34 +0,0 @@ -# /etc/erpcore/erpcore.env — settings + secrets for erpcore.service (production, erp.hexdive.com). -# -# Create once (docs/40-DEPLOYMENT.md, "Active" A.2): -# install -m 640 -o root -g erp deploy/env/erpcore.env.example /etc/erpcore/erpcore.env -# then replace every CHANGE_ME / . Never commit real values. -# -# Format rules (systemd EnvironmentFile, not a shell): -# • comments only on their own line — a trailing "# ..." becomes part of the value -# • ':' in a .NET config key becomes '__' -# • wrap a value in double quotes if it contains spaces, quotes or backslashes -# After editing: Actions → "CI/CD - ERP Core" → Run workflow with deploy_backend. -# (The test environment is on hold — see deploy/on-hold/README.md.) - -ASPNETCORE_ENVIRONMENT=Production -# Must match deploy/apache/erp.hexdive.com.conf and BACKEND_PORT in .gitea/workflows/ci-cd.yml -ASPNETCORE_URLS=http://127.0.0.1:5220 - -ConnectionStrings__DefaultConnection=Host=127.0.0.1;Port=5432;Database=ERPCore;Username=erpcore;Password=CHANGE_ME - -AuthHex__BaseUrl=http://127.0.0.1: -# Door policy — set the real AuthHex user type / role codes. -Auth__RequiredUserTypeCode= -Auth__RequiredRoleCode= - -GeneralLedgerService__BaseUrl=http://127.0.0.1:/api/v1/ -GeneralLedgerService__ApiKey=CHANGE_ME - -# MUST be absolute and OUTSIDE /home/erp/core/publish — every backend deploy empties that folder. -# The workflow's "Guard data paths" step refuses to deploy otherwise. -FileStorage__RootPath=/var/lib/erpcore/hr-documents -LogFile__Path=/var/log/erpcore/erpcore-.log - -# Production never seeds demo data. -Seed__DemoData=false diff --git a/deploy/on-hold/README.md b/deploy/on-hold/README.md deleted file mode 100644 index 12dc819..0000000 --- a/deploy/on-hold/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# On hold — test environment + two-environment pipeline - -Only `erp.hexdive.com` exists today, so since 2026-09-15 production is deployed Fitness-style by the -`Build & Deploy` job in `.gitea/workflows/ci-cd.yml`, the one workflow that also runs the PR checks -(docs/40-DEPLOYMENT.md, "Active"). Everything in this folder is the -**test + release** design built on 2026-09-14, kept intact but **inactive**: Gitea only runs workflows -from `.gitea/workflows/`, and nothing active calls these scripts. - -| File | What it did | -|---|---| -| `deploy.two-env.yml` | The two-environment workflow: push to `Dev` → test, push to `production` → release; tree-hash change detection against the last successful deploy; nightly Playwright against test; Gitea release per production deploy | -| `scripts/detect.sh` | Decided which side to deploy by comparing tree hashes with `/opt/erpcore//.deployed` | -| `scripts/build-backend.sh`, `scripts/build-frontend.sh` | Capped container builds into versioned `/opt/erpcore/releases/backend/` and `erp-web:`, reused across environments | -| `scripts/deploy-backend.sh`, `scripts/deploy-frontend.sh` | Backup → symlink switch → health check → automatic rollback to the previous release | -| `scripts/release.sh` | Gitea release + `release-YYYY.MM.DD-N` tag with rollback hashes | -| `scripts/e2e.sh` | Playwright suite against test | -| `scripts/prune.sh` | Weekly cleanup of releases, images, backups, reports | -| `apache/test-erp.hexdive.com.conf` | Apache vhost for the test environment | -| `systemd/erpcore@.service` | Unit template for `erpcore@test` / `erpcore@release` | - -The full design is documented under **"On hold: two-environment design"** in `docs/40-DEPLOYMENT.md`. - -## Bringing it back -1. Create `test-erp.hexdive.com` (DNS + certificate) and do the server bootstrap in that doc section. -2. Move `scripts/*.sh` back to `deploy/scripts/` (they source `common.sh` from their own folder), `apache/` and `systemd/` files back - to `deploy/apache/` and `deploy/systemd/`, and `deploy.two-env.yml` to `.gitea/workflows/deploy.yml`. - Then **delete the `build-and-deploy` job from `.gitea/workflows/ci-cd.yml`** (or two workflows will deploy production), remove - `closed` from its `pull_request` types and the `workflow_dispatch` block, and give it back a per-PR concurrency group - (`ci-${{ github.event.pull_request.number }}`, `cancel-in-progress: true`) — at that point it is the old `ci.yml`. - `deploy.two-env.yml` triggers on `push`, so it cannot share one file with the PR checks the way the production-only job does. -3. In `deploy/scripts/common.sh`, uncomment the `test)` and `release)` branches of `load_env` and the `Dev) echo test` mapping. -4. In the checks' `frontend` job, point it back at `build-frontend.sh` with `WITH_CHECKS=true` if you want PR-built images reused by the deploy. -5. Decide how to migrate production from `/home/erp/core/publish` + `erpcore.service` to the versioned - `/opt/erpcore/release/backend/current` layout + `erpcore@release` (or adjust the scripts to keep the production layout). diff --git a/deploy/on-hold/apache/test-erp.hexdive.com.conf b/deploy/on-hold/apache/test-erp.hexdive.com.conf deleted file mode 100644 index 25442a2..0000000 --- a/deploy/on-hold/apache/test-erp.hexdive.com.conf +++ /dev/null @@ -1,35 +0,0 @@ -# ERP-Core TEST environment (deployed from the Dev branch) — docs/40-DEPLOYMENT.md §4. -# -# Install: cp deploy/apache/test-erp.hexdive.com.conf /etc/httpd/conf.d/ -# apachectl configtest && systemctl reload httpd -# certbot --apache -d test-erp.hexdive.com -# certbot copies this vhost to :443 with the certificate and turns :80 into a redirect to https. -# The session cookies are Secure + SameSite=Strict, so login only works over HTTPS. -# Needs mod_proxy, mod_proxy_http, mod_headers, mod_ssl and SELinux httpd_can_network_connect=on. - - - ServerName test-erp.hexdive.com - - ProxyRequests Off - ProxyPreserveHost On - ProxyTimeout 120 - RequestHeader set X-Forwarded-Proto expr=%{REQUEST_SCHEME} - - Header always set Strict-Transport-Security "max-age=31536000" "expr=%{HTTPS} == 'on'" - Header always set X-Content-Type-Options "nosniff" - # Test data must never end up in search results. - Header always set X-Robots-Tag "noindex, nofollow" - - # HR documents are capped at 10 MB by the API (FileStorage:MaxSizeBytes); allow a little overhead. - LimitRequestBody 12582912 - - # Order matters — the first matching ProxyPass wins, so /api/ must come before /. - # /health and /swagger are deliberately NOT proxied (they fall through to Next and 404). - ProxyPass /api/ http://127.0.0.1:5230/api/ - ProxyPassReverse /api/ http://127.0.0.1:5230/api/ - ProxyPass / http://127.0.0.1:3030/ - ProxyPassReverse / http://127.0.0.1:3030/ - - ErrorLog /var/log/httpd/test-erp.hexdive.com-error.log - CustomLog /var/log/httpd/test-erp.hexdive.com-access.log combined - diff --git a/deploy/on-hold/deploy.two-env.yml b/deploy/on-hold/deploy.two-env.yml deleted file mode 100644 index 25150f8..0000000 --- a/deploy/on-hold/deploy.two-env.yml +++ /dev/null @@ -1,205 +0,0 @@ -# Deployments — one workflow for both environments (docs/40-DEPLOYMENT.md §6): -# push to Dev → test (https://test-erp.hexdive.com) -# push to production → release (https://erp.hexdive.com) -# Only the side(s) whose folder changed since the last successful deploy are built and deployed, -# backend (and its migrations) always before frontend. Nightly/manual Playwright runs hit test only. -name: Deploy - -on: - push: - branches: [Dev, production] - schedule: - - cron: "30 20 * * *" # 02:00 Asia/Colombo. Gitea reads schedules from the default branch (production). - workflow_dispatch: - inputs: - force_backend: - description: "Redeploy the backend even if unchanged (e.g. after editing /etc/erpcore/.env)" - type: boolean - default: false - force_frontend: - description: "Redeploy the frontend even if unchanged" - type: boolean - default: false - backend_hash: - description: "Rollback: backend tree hash from a previous Gitea release (leave empty normally)" - type: string - default: "" - frontend_hash: - description: "Rollback: frontend tree hash from a previous Gitea release (leave empty normally)" - type: string - default: "" - run_e2e: - description: "Run the Playwright suite against test after this run" - type: boolean - default: false - -concurrency: - group: deploy-${{ github.ref_name }} - cancel-in-progress: false # never interrupt a deploy (or a migration) half-way - -jobs: - detect: - name: detect - if: github.event_name != 'schedule' - runs-on: linux - outputs: - env: ${{ steps.detect.outputs.env }} - backend: ${{ steps.detect.outputs.backend }} - frontend: ${{ steps.detect.outputs.frontend }} - backend_hash: ${{ steps.detect.outputs.backend_hash }} - frontend_hash: ${{ steps.detect.outputs.frontend_hash }} - backend_rollback: ${{ steps.detect.outputs.backend_rollback }} - frontend_rollback: ${{ steps.detect.outputs.frontend_rollback }} - migrations_hash: ${{ steps.detect.outputs.migrations_hash }} - migrations_changed: ${{ steps.detect.outputs.migrations_changed }} - record_release: ${{ steps.detect.outputs.record_release }} - steps: - - name: Checkout pushed commit - env: - ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }} - SHA: ${{ github.sha }} - REF: ${{ github.ref_name }} - run: | - basic="$(printf 'oauth2:%s' "$ERP_GIT_TOKEN" | base64 | tr -d '\n')" - url="https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git" - git init -q . - git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 1 "$url" "$SHA" \ - || git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 50 "$url" "$REF" - git checkout -qf "$SHA" && git clean -ffdxq - git log -1 --format='checked out %h %s' - - name: Decide what to deploy - id: detect - env: - REF_NAME: ${{ github.ref_name }} - FORCE_BACKEND: ${{ inputs.force_backend }} - FORCE_FRONTEND: ${{ inputs.force_frontend }} - BACKEND_HASH_INPUT: ${{ inputs.backend_hash }} - FRONTEND_HASH_INPUT: ${{ inputs.frontend_hash }} - run: bash deploy/scripts/detect.sh - - backend: - name: backend - needs: detect - if: needs.detect.outputs.backend == 'true' - runs-on: linux - outputs: - reused: ${{ steps.build.outputs.reused }} - steps: - - name: Checkout pushed commit - env: - ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }} - SHA: ${{ github.sha }} - REF: ${{ github.ref_name }} - run: | - basic="$(printf 'oauth2:%s' "$ERP_GIT_TOKEN" | base64 | tr -d '\n')" - url="https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git" - git init -q . - git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 1 "$url" "$SHA" \ - || git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 50 "$url" "$REF" - git checkout -qf "$SHA" && git clean -ffdxq - - name: Build (or reuse) release - id: build - env: - BACKEND_HASH: ${{ needs.detect.outputs.backend_hash }} - run: bash deploy/scripts/build-backend.sh - - name: Backup, switch, health check - env: - ERP_ENV: ${{ needs.detect.outputs.env }} - BACKEND_HASH: ${{ needs.detect.outputs.backend_hash }} - IS_ROLLBACK: ${{ needs.detect.outputs.backend_rollback }} - MIGRATIONS_HASH: ${{ needs.detect.outputs.migrations_hash }} - MIGRATIONS_CHANGED: ${{ needs.detect.outputs.migrations_changed }} - run: bash deploy/scripts/deploy-backend.sh - - frontend: - name: frontend - needs: [detect, backend] - # Runs after the backend succeeded or was skipped — a new UI must never meet an old API. - if: >- - always() && needs.detect.result == 'success' && needs.detect.outputs.frontend == 'true' - && needs.backend.result != 'failure' && needs.backend.result != 'cancelled' - runs-on: linux - outputs: - reused: ${{ steps.build.outputs.reused }} - steps: - - name: Checkout pushed commit - env: - ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }} - SHA: ${{ github.sha }} - REF: ${{ github.ref_name }} - run: | - basic="$(printf 'oauth2:%s' "$ERP_GIT_TOKEN" | base64 | tr -d '\n')" - url="https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git" - git init -q . - git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 1 "$url" "$SHA" \ - || git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 50 "$url" "$REF" - git checkout -qf "$SHA" && git clean -ffdxq - - name: Build (or reuse) image - id: build - env: - FRONTEND_HASH: ${{ needs.detect.outputs.frontend_hash }} - run: bash deploy/scripts/build-frontend.sh - - name: Swap container, HTTP check - env: - ERP_ENV: ${{ needs.detect.outputs.env }} - FRONTEND_HASH: ${{ needs.detect.outputs.frontend_hash }} - IS_ROLLBACK: ${{ needs.detect.outputs.frontend_rollback }} - run: bash deploy/scripts/deploy-frontend.sh - - release: - name: release - needs: [detect, backend, frontend] - if: >- - always() && needs.detect.outputs.record_release == 'true' - && needs.backend.result != 'failure' && needs.backend.result != 'cancelled' - && needs.frontend.result != 'failure' && needs.frontend.result != 'cancelled' - runs-on: linux - steps: - - name: Checkout pushed commit - env: - ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }} - SHA: ${{ github.sha }} - REF: ${{ github.ref_name }} - run: | - basic="$(printf 'oauth2:%s' "$ERP_GIT_TOKEN" | base64 | tr -d '\n')" - url="https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git" - git init -q . - git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 1 "$url" "$SHA" \ - || git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 50 "$url" "$REF" - git checkout -qf "$SHA" && git clean -ffdxq - - name: Create Gitea release - env: - ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }} - BACKEND_HASH: ${{ needs.detect.outputs.backend_hash }} - FRONTEND_HASH: ${{ needs.detect.outputs.frontend_hash }} - MIGRATIONS_HASH: ${{ needs.detect.outputs.migrations_hash }} - MIGRATIONS_CHANGED: ${{ needs.detect.outputs.migrations_changed }} - BACKEND_RESULT: ${{ needs.backend.result }} - BACKEND_REUSED: ${{ needs.backend.outputs.reused }} - FRONTEND_RESULT: ${{ needs.frontend.result }} - FRONTEND_REUSED: ${{ needs.frontend.outputs.reused }} - run: bash deploy/scripts/release.sh - - e2e: - name: e2e - needs: [backend, frontend] - if: >- - always() && (github.event_name == 'schedule' || format('{0}', inputs.run_e2e) == 'true') - && needs.backend.result != 'failure' && needs.frontend.result != 'failure' - runs-on: linux - steps: - # Specs come from Dev: the test environment runs Dev, even when a schedule fires on production. - - name: Checkout Dev - env: - ERP_GIT_TOKEN: ${{ secrets.ERPSECRET }} - run: | - basic="$(printf 'oauth2:%s' "$ERP_GIT_TOKEN" | base64 | tr -d '\n')" - git init -q . - git -c "http.extraHeader=Authorization: Basic $basic" fetch -q --depth 1 \ - "https://gitea.hexdive.com/New_REP_SYSTEM/ERP-core.git" Dev - git checkout -qf FETCH_HEAD && git clean -ffdxq - - name: Playwright against test - env: - E2E_ADMIN_EMAIL: ${{ secrets.E2E_ADMIN_EMAIL }} - E2E_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD }} - run: bash deploy/scripts/e2e.sh diff --git a/deploy/on-hold/scripts/build-backend.sh b/deploy/on-hold/scripts/build-backend.sh deleted file mode 100644 index 7ff953a..0000000 --- a/deploy/on-hold/scripts/build-backend.sh +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env bash -# deploy.yml › backend — build (or reuse) the self-contained release for BACKEND_HASH into -# /opt/erpcore/releases/backend/. Releases are shared by test and release, so a -# Dev → production merge that carries the same Backend/ERPCore tree skips the build entirely. -# shellcheck source=common.sh -source "$(dirname "${BASH_SOURCE[0]}")/common.sh" - -: "${BACKEND_HASH:?BACKEND_HASH is required}" -target="$RELEASES_DIR/$BACKEND_HASH" - -reuse_if_built() { - if [[ -x "$target/ERPCore" ]]; then - log "backend $BACKEND_HASH already built → reused ($target)" - set_output reused true - exit 0 - fi -} - -reuse_if_built -with_build_lock -reuse_if_built # another job may have built it while we waited for the lock - -# Only the exact tree can be built. A rollback hash whose release was pruned cannot be recreated -# from a different checkout — fail instead of deploying something else under that name. -[[ "$(tree_hash "$BACKEND_PATH")" == "$BACKEND_HASH" ]] || - fail "no release for $BACKEND_HASH, and this checkout's $BACKEND_PATH is a different tree (pruned rollback target?)" - -mkdir -p "$RELEASES_DIR" -tmp="$target.tmp" -rm -rf "$tmp" -mkdir -p "$tmp" -z="$(selinux_mount_suffix)" - -log "building backend $BACKEND_HASH (capped: 2 CPU / 1500 MB)" -docker run --rm --cpus=2 --cpu-shares=512 --memory=1500m \ - -e DOTNET_CLI_TELEMETRY_OPTOUT=1 -e DOTNET_NOLOGO=1 \ - -v "$PWD/Backend:/src$z" \ - -v "$tmp:/out$z" \ - -v erp-nuget:/root/.nuget/packages \ - -w /src "$DOTNET_SDK_IMAGE" \ - dotnet publish ERPCore/ERPCore.csproj -c Release -r linux-x64 --self-contained true -o /out - -[[ -x "$tmp/ERPCore" ]] || fail "publish finished but $tmp/ERPCore is missing" - -# Root-owned and read-only for the service user: the app never writes into its own release -# (uploads and logs live under /var/lib/erpcore and /var/log/erpcore). -chown -R root:root "$tmp" -chmod -R u=rwX,go=rX "$tmp" -chmod 755 "$tmp/ERPCore" -mv "$tmp" "$target" -if command -v restorecon >/dev/null 2>&1; then restorecon -R "$target"; fi - -log "backend release ready: $target" -set_output reused false diff --git a/deploy/on-hold/scripts/build-frontend.sh b/deploy/on-hold/scripts/build-frontend.sh deleted file mode 100644 index d63c824..0000000 --- a/deploy/on-hold/scripts/build-frontend.sh +++ /dev/null @@ -1,63 +0,0 @@ -#!/usr/bin/env bash -# ci.yml › frontend and deploy.yml › frontend — build (or reuse) image erp-web:. -# -# `npm ci` + `next build` run in a capped node container; the image itself is runtime-only -# (Frontend/erp-system/Dockerfile). The PR check builds the same tag with WITH_CHECKS=true -# (type-check + lint first), so the deploy after a merge normally just reuses that image. -# shellcheck source=common.sh -source "$(dirname "${BASH_SOURCE[0]}")/common.sh" - -: "${FRONTEND_HASH:?FRONTEND_HASH is required}" -image="$WEB_IMAGE_REPO:$FRONTEND_HASH" -with_checks="${WITH_CHECKS:-false}" -app="$PWD/$FRONTEND_PATH" -checks="npx tsc --noEmit && npx eslint ." - -image_exists() { docker image inspect "$image" >/dev/null 2>&1; } -image_checked() { [[ "$(docker image inspect -f '{{ index .Config.Labels "erp.checked" }}' "$image" 2>/dev/null)" == true ]]; } - -# run_node — run steps in the frontend folder inside a capped node container. -run_node() { - local z - z="$(selinux_mount_suffix)" - docker run --rm --cpus=2 --cpu-shares=512 --memory=2500m \ - -e CI=true -e NEXT_TELEMETRY_DISABLED=1 -e NODE_OPTIONS=--max-old-space-size=2048 \ - -v "$app:/app$z" \ - -v erp-npm-cache:/root/.npm \ - -v erp-next-cache:/app/.next/cache \ - -w /app "$NODE_IMAGE" sh -ec "$1" -} - -with_build_lock - -if image_exists; then - if [[ "$with_checks" == true ]] && ! image_checked; then - # Same content was built earlier by a deploy (no checks) — the PR still has to pass them. - log "$image exists but was built without checks → running type-check + lint only" - run_node "npm ci --no-audit --no-fund && $checks" - fi - log "$image already built → reused" - set_output reused true - exit 0 -fi - -[[ "$(tree_hash "$FRONTEND_PATH")" == "$FRONTEND_HASH" ]] || - fail "no image for $FRONTEND_HASH, and this checkout's $FRONTEND_PATH is a different tree (pruned rollback target?)" - -steps="npm ci --no-audit --no-fund" -if [[ "$with_checks" == true ]]; then steps="$steps && $checks"; fi -steps="$steps && npm run build" - -log "building frontend $FRONTEND_HASH (checks=$with_checks, capped: 2 CPU / 2500 MB)" -run_node "$steps" -[[ -f "$app/.next/standalone/server.js" ]] || - fail "next build did not produce .next/standalone/server.js (is output: \"standalone\" set in next.config.ts?)" - -docker build \ - --label "erp.frontend-tree=$FRONTEND_HASH" \ - --label "erp.source-sha=${GITHUB_SHA:-local}" \ - --label "erp.checked=$with_checks" \ - -t "$image" "$app" - -log "frontend image ready: $image" -set_output reused false diff --git a/deploy/on-hold/scripts/deploy-backend.sh b/deploy/on-hold/scripts/deploy-backend.sh deleted file mode 100644 index 7faedba..0000000 --- a/deploy/on-hold/scripts/deploy-backend.sh +++ /dev/null @@ -1,91 +0,0 @@ -#!/usr/bin/env bash -# deploy.yml › backend — switch an environment to BACKEND_HASH: -# pg_dump (policy) → stop → repoint `current` → start → wait for /health -# On failure the previous release is restored and the job fails (red). -# The DATABASE is never rolled back — hence add-only migrations (docs/40-DEPLOYMENT.md §8). -# shellcheck source=common.sh -source "$(dirname "${BASH_SOURCE[0]}")/common.sh" - -: "${ERP_ENV:?ERP_ENV is required}" "${BACKEND_HASH:?BACKEND_HASH is required}" -load_env "$ERP_ENV" -is_rollback="${IS_ROLLBACK:-false}" -migrations_changed="${MIGRATIONS_CHANGED:-false}" - -target="$RELEASES_DIR/$BACKEND_HASH" -[[ -x "$target/ERPCore" ]] || fail "backend release not found: $target" - -backup_database() { - local pg_dump file - pg_dump="$(command -v pg_dump || echo /usr/pgsql-16/bin/pg_dump)" - [[ -x "$pg_dump" ]] || fail "pg_dump not found (install postgresql16 client tools or fix PATH)" - mkdir -p "$BACKUP_DIR" - file="$BACKUP_DIR/$ERP_ENV-$(date -u +%Y%m%dT%H%M%SZ)-${BACKEND_HASH:0:12}.dump" - log "backing up database $DB_NAME → $file" - runuser -u postgres -- "$pg_dump" -Fc "$DB_NAME" >"$file.partial" - mv "$file.partial" "$file" -} - -# wait_healthy — succeeds once /health answers 200. EF migrations + seeding run BEFORE the app -# starts listening, so a slow start is normal. Only a crash (unit failed / auto-restarted) or the -# generous per-environment ceiling counts as failure: never cut a live migration on a tight timer. -wait_healthy() { - local deadline=$((SECONDS + HEALTH_TIMEOUT)) restarts_at_start state restarts - restarts_at_start="$(systemctl show -p NRestarts --value "$SERVICE")" - while ((SECONDS < deadline)); do - if curl -fsS --max-time 5 -o /dev/null "http://127.0.0.1:$BACKEND_PORT/health" 2>/dev/null; then - return 0 - fi - state="$(systemctl show -p ActiveState --value "$SERVICE")" - restarts="$(systemctl show -p NRestarts --value "$SERVICE")" - if [[ "$state" == failed || "$restarts" != "$restarts_at_start" ]]; then - warn "$SERVICE crashed during startup (state=$state, restarts $restarts_at_start → $restarts)" - return 1 - fi - sleep 3 - done - warn "$SERVICE was not healthy within ${HEALTH_TIMEOUT}s" - return 1 -} - -switch_to() { - systemctl stop "$SERVICE" || true - mkdir -p "$(dirname "$CURRENT_LINK")" - ln -sfn "$1" "$CURRENT_LINK" - systemctl reset-failed "$SERVICE" 2>/dev/null || true - systemctl start "$SERVICE" || warn "systemctl start $SERVICE returned an error" -} - -if [[ "$is_rollback" != true ]] && [[ "$BACKUP_POLICY" == always || "$migrations_changed" == true ]]; then - backup_database -else - log "no database backup (policy=$BACKUP_POLICY, migrations changed=$migrations_changed, rollback=$is_rollback)" -fi - -previous="$(readlink "$CURRENT_LINK" 2>/dev/null || true)" -log "deploying backend $BACKEND_HASH to $ERP_ENV (previous: ${previous:-none})" -switch_to "$target" - -if wait_healthy; then - write_state backend "$BACKEND_HASH" - if [[ "$is_rollback" != true && -n "${MIGRATIONS_HASH:-}" ]]; then write_state migrations "$MIGRATIONS_HASH"; fi - if [[ "$is_rollback" == true ]]; then result=rollback; else result=deployed; fi - record_history backend "$BACKEND_HASH" "$result" - log "✅ backend $BACKEND_HASH is healthy on $ERP_ENV" - exit 0 -fi - -journalctl -u "$SERVICE" -n 80 --no-pager || true -record_history backend "$BACKEND_HASH" failed -if [[ -n "$previous" && "$previous" != "$target" && -x "$previous/ERPCore" ]]; then - warn "restoring previous release $previous" - switch_to "$previous" - if wait_healthy; then - log "previous release is serving again" - else - warn "the previous release is not healthy either — investigate now" - fi -else - warn "no previous release to restore; stopping $SERVICE" - systemctl stop "$SERVICE" || true -fi -fail "backend deploy of $BACKEND_HASH to $ERP_ENV failed" diff --git a/deploy/on-hold/scripts/deploy-frontend.sh b/deploy/on-hold/scripts/deploy-frontend.sh deleted file mode 100644 index 8d8b4b8..0000000 --- a/deploy/on-hold/scripts/deploy-frontend.sh +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env bash -# deploy.yml › frontend — replace the environment's web container with erp-web: -# and wait for it to serve /login. On failure the previously deployed image is started again -# and the job fails. -# shellcheck source=common.sh -source "$(dirname "${BASH_SOURCE[0]}")/common.sh" - -: "${ERP_ENV:?ERP_ENV is required}" "${FRONTEND_HASH:?FRONTEND_HASH is required}" -load_env "$ERP_ENV" -is_rollback="${IS_ROLLBACK:-false}" - -docker image inspect "$WEB_IMAGE_REPO:$FRONTEND_HASH" >/dev/null 2>&1 || - fail "image $WEB_IMAGE_REPO:$FRONTEND_HASH not found (pruned?)" - -start_web() { - docker rm -f "$WEB_CONTAINER" >/dev/null 2>&1 || true - docker run -d --name "$WEB_CONTAINER" \ - -p "127.0.0.1:$WEB_PORT:3000" \ - --memory=512m --cpus=1 \ - --restart unless-stopped \ - --label "erp.env=$ERP_ENV" \ - "$WEB_IMAGE_REPO:$1" >/dev/null -} - -wait_web() { - local deadline=$((SECONDS + 30)) - while ((SECONDS < deadline)); do - if curl -fsS --max-time 5 -o /dev/null "http://127.0.0.1:$WEB_PORT/login" 2>/dev/null; then return 0; fi - if [[ "$(docker inspect -f '{{.State.Running}}' "$WEB_CONTAINER" 2>/dev/null)" != true ]]; then - warn "$WEB_CONTAINER is not running" - return 1 - fi - sleep 2 - done - warn "$WEB_CONTAINER did not serve /login within 30s" - return 1 -} - -previous="$(read_state frontend)" -log "deploying frontend $FRONTEND_HASH to $ERP_ENV (previous: ${previous:-none})" -start_web "$FRONTEND_HASH" - -if wait_web; then - write_state frontend "$FRONTEND_HASH" - if [[ "$is_rollback" == true ]]; then result=rollback; else result=deployed; fi - record_history frontend "$FRONTEND_HASH" "$result" - log "✅ frontend $FRONTEND_HASH is serving on $ERP_ENV" - exit 0 -fi - -docker logs --tail 80 "$WEB_CONTAINER" || true -record_history frontend "$FRONTEND_HASH" failed -if [[ -n "$previous" && "$previous" != "$FRONTEND_HASH" ]] && - docker image inspect "$WEB_IMAGE_REPO:$previous" >/dev/null 2>&1; then - warn "restoring previous frontend $previous" - start_web "$previous" - wait_web || warn "the previous frontend is not serving either — investigate now" -fi -fail "frontend deploy of $FRONTEND_HASH to $ERP_ENV failed" diff --git a/deploy/on-hold/scripts/detect.sh b/deploy/on-hold/scripts/detect.sh deleted file mode 100644 index 5cdab3b..0000000 --- a/deploy/on-hold/scripts/detect.sh +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env bash -# deploy.yml › detect — decide what this push deploys. -# -# A side deploys when its folder's tree hash differs from the hash last deployed SUCCESSFULLY to -# this environment (stored on the server in /opt/erpcore//.deployed). So: -# • docs / PROGRESS.md / smoke-script pushes deploy nothing, -# • a failed deploy is retried by the next run (its stored hash never moved), -# • force-pushes, multi-commit pushes and merges need no special handling. -# Manual runs can force a side (config change) or pin an older hash (rollback). -# shellcheck source=common.sh -source "$(dirname "${BASH_SOURCE[0]}")/common.sh" - -: "${REF_NAME:?REF_NAME (the pushed branch) is required}" -env_name="$(env_for_branch "$REF_NAME")" || - fail "branch '$REF_NAME' is not deployable (Dev → test, production → release)" -load_env "$env_name" - -be_hash="$(tree_hash "$BACKEND_PATH")" -fe_hash="$(tree_hash "$FRONTEND_PATH")" -mig_hash="$(tree_hash "$MIGRATIONS_PATH")" -[[ -n "$be_hash" && -n "$fe_hash" ]] || fail "could not compute tree hashes — is this an ERP-core checkout?" - -be_rollback=false -fe_rollback=false -if [[ -n "${BACKEND_HASH_INPUT:-}" ]]; then be_hash="$BACKEND_HASH_INPUT" && be_rollback=true; fi -if [[ -n "${FRONTEND_HASH_INPUT:-}" ]]; then fe_hash="$FRONTEND_HASH_INPUT" && fe_rollback=true; fi - -be_deployed="$(read_state backend)" -fe_deployed="$(read_state frontend)" -be_changed=false -fe_changed=false -if [[ "$be_hash" != "$be_deployed" ]]; then be_changed=true; fi -if [[ "$fe_hash" != "$fe_deployed" ]]; then fe_changed=true; fi - -deploy_backend=$be_changed -deploy_frontend=$fe_changed -if [[ "${FORCE_BACKEND:-}" == true ]]; then deploy_backend=true; fi -if [[ "${FORCE_FRONTEND:-}" == true ]]; then deploy_frontend=true; fi - -migrations_changed=false -if ! $be_rollback && [[ "$mig_hash" != "$(read_state migrations)" ]]; then migrations_changed=true; fi - -# A Gitea release records real content changes on release only — not forced restarts or -# rollbacks (those are in .deployed/history.log on the server). -record_release=false -if [[ "$env_name" == release ]] && ! $be_rollback && ! $fe_rollback && { $be_changed || $fe_changed; }; then - record_release=true -fi - -log "environment $env_name — deployed now: backend ${be_deployed:-none}, frontend ${fe_deployed:-none}" -set_output env "$env_name" -set_output backend "$deploy_backend" -set_output frontend "$deploy_frontend" -set_output backend_hash "$be_hash" -set_output frontend_hash "$fe_hash" -set_output backend_rollback "$be_rollback" -set_output frontend_rollback "$fe_rollback" -set_output migrations_hash "$mig_hash" -set_output migrations_changed "$migrations_changed" -set_output record_release "$record_release" diff --git a/deploy/on-hold/scripts/e2e.sh b/deploy/on-hold/scripts/e2e.sh deleted file mode 100644 index 12c4af9..0000000 --- a/deploy/on-hold/scripts/e2e.sh +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env bash -# deploy.yml › e2e — Playwright suite against the TEST environment. Never release: the suite -# creates vendors, items and POs on every run. -# shellcheck source=common.sh -source "$(dirname "${BASH_SOURCE[0]}")/common.sh" - -: "${E2E_ADMIN_EMAIL:?secrets.E2E_ADMIN_EMAIL is required}" "${E2E_ADMIN_PASSWORD:?secrets.E2E_ADMIN_PASSWORD is required}" -load_env test -base_url="${E2E_BASE_URL:-$PUBLIC_URL}" - -# The container's browsers must match the locked @playwright/test version exactly. -pw_version="$(grep -A1 '"node_modules/@playwright/test"' "$E2E_PATH/package-lock.json" | - sed -n 's/.*"version": "\([^"]*\)".*/\1/p')" -[[ -n "$pw_version" ]] || fail "could not read the locked @playwright/test version" -image="mcr.microsoft.com/playwright:v$pw_version-noble" -z="$(selinux_mount_suffix)" - -with_build_lock # Chromium is the second-heaviest workload on the box — never overlap a build - -log "E2E against $base_url with $image (capped: 2 CPU / 1500 MB)" -status=0 -docker run --rm --cpus=2 --cpu-shares=512 --memory=1500m --ipc=host \ - -e CI=true -e E2E_BASE_URL="$base_url" -e E2E_API_URL="$base_url" \ - -e E2E_ADMIN_EMAIL -e E2E_ADMIN_PASSWORD \ - -v "$PWD/$E2E_PATH:/e2e$z" -w /e2e "$image" \ - bash -ec 'npm ci --no-audit --no-fund && npx playwright test' || status=$? - -if [[ -d "$E2E_PATH/playwright-report" ]]; then - report_dir="$E2E_REPORTS_DIR/$(date -u +%Y%m%dT%H%M%SZ)-run${GITHUB_RUN_NUMBER:-manual}" - mkdir -p "$report_dir" - cp -r "$E2E_PATH/playwright-report" "$report_dir/" - if [[ -d "$E2E_PATH/test-results" ]]; then cp -r "$E2E_PATH/test-results" "$report_dir/"; fi - log "report saved on the server: $report_dir/playwright-report/index.html" -fi -exit "$status" diff --git a/deploy/on-hold/scripts/prune.sh b/deploy/on-hold/scripts/prune.sh deleted file mode 100644 index 70c0e4a..0000000 --- a/deploy/on-hold/scripts/prune.sh +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env bash -# Weekly housekeeping — root cron on the VPS (docs/40-DEPLOYMENT.md §4): -# 0 4 * * 0 /opt/erpcore/prune.sh >> /var/log/erpcore/prune.log 2>&1 -# Keeps the newest KEEP backend releases and frontend images (anything currently deployed in -# either environment is always kept), prunes build cache, old database backups and E2E reports. -# shellcheck source=common.sh -source "$(dirname "${BASH_SOURCE[0]}")/common.sh" - -KEEP="${KEEP:-5}" -with_build_lock # never delete a release or image that a running job is producing - -live=() -for e in test release; do - load_env "$e" - live+=("$(read_state backend)" "$(read_state frontend)") - if [[ -L "$CURRENT_LINK" ]]; then live+=("$(basename "$(readlink "$CURRENT_LINK")")"); fi -done -is_live() { - local h - for h in "${live[@]}"; do - if [[ -n "$h" && "$h" == "$1" ]]; then return 0; fi - done - return 1 -} - -if [[ -d "$RELEASES_DIR" ]]; then - n=0 - while IFS= read -r dir; do - name="$(basename "$dir")" - if [[ "$name" == *.tmp ]]; then - log "removing abandoned build $name" - rm -rf "$dir" - continue - fi - n=$((n + 1)) - if ((n > KEEP)) && ! is_live "$name"; then - log "removing backend release $name" - rm -rf "$dir" - fi - done < <(find "$RELEASES_DIR" -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\n' | sort -rn | cut -d' ' -f2-) -fi - -n=0 -while IFS= read -r tag; do - if [[ -z "$tag" || "$tag" == "" ]]; then continue; fi - n=$((n + 1)) - if ((n > KEEP)) && ! is_live "$tag"; then - log "removing image $WEB_IMAGE_REPO:$tag" - docker rmi "$WEB_IMAGE_REPO:$tag" >/dev/null || warn "could not remove $WEB_IMAGE_REPO:$tag" - fi -done < <(docker image ls "$WEB_IMAGE_REPO" --format '{{.Tag}}') # newest first - -docker image prune -f >/dev/null -docker builder prune -f --filter until=168h >/dev/null - -find "$BACKUP_DIR" -maxdepth 1 -name 'test-*.dump' -mtime +7 -delete 2>/dev/null || true -find "$BACKUP_DIR" -maxdepth 1 -name 'release-*.dump' -mtime +30 -delete 2>/dev/null || true -find "$E2E_REPORTS_DIR" -mindepth 1 -maxdepth 1 -type d -mtime +14 -exec rm -rf {} + 2>/dev/null || true -log "prune complete" diff --git a/deploy/on-hold/scripts/release.sh b/deploy/on-hold/scripts/release.sh deleted file mode 100644 index d8b2fb3..0000000 --- a/deploy/on-hold/scripts/release.sh +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env bash -# deploy.yml › release — record a successful release deploy as a Gitea release + tag -# (release-YYYY.MM.DD-N on the deployed commit), including the tree hashes to roll back to. -# shellcheck source=common.sh -source "$(dirname "${BASH_SOURCE[0]}")/common.sh" - -: "${GITHUB_SHA:?}" "${ERP_GIT_TOKEN:?}" "${BACKEND_HASH:?}" "${FRONTEND_HASH:?}" -command -v python3 >/dev/null 2>&1 || fail "python3 is required to build the release JSON" -load_env release - -# side_result — human-readable outcome for the notes table. -side_result() { - case "$1" in - success) if [[ "$2" == true ]]; then echo "deployed (build reused)"; else echo "deployed (built)"; fi ;; - skipped) echo "unchanged" ;; - *) echo "$1" ;; - esac -} - -today="$(TZ=Asia/Colombo date +%Y.%m.%d)" -mapfile -t tags < <(git_remote ls-remote --tags --refs "$REPO_GIT_URL" 'release-*' | - awk '{ sub("refs/tags/", "", $2); print $2 }' | sort -V) -previous="${tags[${#tags[@]}-1]:-}" -same_day=0 -for t in "${tags[@]}"; do [[ "$t" == "release-$today-"* ]] && same_day=$((same_day + 1)); done -tag="release-$today-$((same_day + 1))" - -# Pull requests since the previous release: fetch only the history after that tag. -if [[ -n "$previous" ]]; then - git_remote fetch -q --shallow-exclude="$previous" "$REPO_GIT_URL" "+refs/heads/production:refs/remotes/origin/production" || - git_remote fetch -q --deepen=200 "$REPO_GIT_URL" "+refs/heads/production:refs/remotes/origin/production" || true -else - git_remote fetch -q --deepen=200 "$REPO_GIT_URL" "+refs/heads/production:refs/remotes/origin/production" || true -fi -prs="$(git log --merges --format='- %s' "$GITHUB_SHA" 2>/dev/null | grep -i ' into Dev' || true)" - -body="Deployed to **release** ($PUBLIC_URL) from \`${GITHUB_SHA:0:12}\`. - -| Side | Result | Tree hash — rollback input | -|---|---|---| -| Backend | $(side_result "${BACKEND_RESULT:-}" "${BACKEND_REUSED:-}") | \`$BACKEND_HASH\` | -| Frontend | $(side_result "${FRONTEND_RESULT:-}" "${FRONTEND_REUSED:-}") | \`$FRONTEND_HASH\` | -| Migrations | $([[ "${MIGRATIONS_CHANGED:-}" == true ]] && echo "changed (backup taken)" || echo "unchanged") | \`${MIGRATIONS_HASH:-}\` | - -### Pull requests since ${previous:-the beginning} -${prs:-_(none found)_} - -Rollback: Actions → **Deploy** → Run workflow on \`production\` with \`backend_hash\` / \`frontend_hash\` from a previous release (docs/40-DEPLOYMENT.md §9)." - -payload="$(TAG="$tag" SHA="$GITHUB_SHA" BODY="$body" python3 -c ' -import json, os -print(json.dumps({"tag_name": os.environ["TAG"], "target_commitish": os.environ["SHA"], - "name": os.environ["TAG"], "body": os.environ["BODY"]}))')" - -log "creating Gitea release $tag" -curl -sS --fail-with-body -o /dev/null -X POST \ - -H "Authorization: token $ERP_GIT_TOKEN" -H "Content-Type: application/json" \ - --data "$payload" "$GITEA_URL/api/v1/repos/$REPO_SLUG/releases" -log "✅ $GITEA_URL/$REPO_SLUG/releases/tag/$tag" diff --git a/deploy/on-hold/systemd/erpcore@.service b/deploy/on-hold/systemd/erpcore@.service deleted file mode 100644 index 466bf26..0000000 --- a/deploy/on-hold/systemd/erpcore@.service +++ /dev/null @@ -1,40 +0,0 @@ -# ERP-Core API — one template for both environments: erpcore@test and erpcore@release. -# -# Install once (docs/40-DEPLOYMENT.md §4): -# cp deploy/systemd/erpcore@.service /etc/systemd/system/ -# systemctl daemon-reload && systemctl enable erpcore@test erpcore@release -# The pipeline never edits this file: a deploy only repoints /opt/erpcore//backend/current -# and restarts the unit (deploy/scripts/deploy-backend.sh). - -[Unit] -Description=ERP-Core API (%i) -After=network-online.target postgresql-16.service -Wants=network-online.target -# Stop hammering the database if a release crash-loops; the deploy script rolls back anyway. -StartLimitIntervalSec=300 -StartLimitBurst=5 - -[Service] -Type=simple -User=erpcore -Group=erpcore -WorkingDirectory=/opt/erpcore/%i/backend/current -ExecStart=/opt/erpcore/%i/backend/current/ERPCore -# Secrets + per-environment settings (template: deploy/env/erpcore.env.example). -EnvironmentFile=/etc/erpcore/%i.env -Environment=DOTNET_CLI_TELEMETRY_OPTOUT=1 -Restart=on-failure -RestartSec=5 -TimeoutStopSec=30 - -# Shared 8 GB VPS: a runaway instance must not take Gitea/Postgres down with it. -MemoryMax=768M - -# Hardening. The app writes only to /var/lib/erpcore/%i and /var/log/erpcore/%i (owned by erpcore). -NoNewPrivileges=true -PrivateTmp=true -ProtectSystem=full -ProtectHome=true - -[Install] -WantedBy=multi-user.target diff --git a/deploy/scripts/ci-backend.sh b/deploy/scripts/ci-backend.sh deleted file mode 100644 index c77ecde..0000000 --- a/deploy/scripts/ci-backend.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env bash -# ci-cd.yml › backend — build, unit tests and the "entity changed but no migration" check. -# The dotnet work runs inside a capped SDK container. ASPNETCORE_ENVIRONMENT=Development is set -# explicitly for the EF step: `dotnet ef` boots Program.cs, and outside Development the startup -# config check would (correctly) refuse to run without the deployment secrets CI doesn't have. -# shellcheck source=common.sh -source "$(dirname "${BASH_SOURCE[0]}")/common.sh" - -z="$(selinux_mount_suffix)" -with_build_lock - -log "backend checks (capped: 2 CPU / 1500 MB)" -# The inner script is single-quoted on purpose: it expands inside the container, not here. -# shellcheck disable=SC2016 -docker run --rm --cpus=2 --cpu-shares=512 --memory=1500m \ - -e DOTNET_CLI_TELEMETRY_OPTOUT=1 -e DOTNET_NOLOGO=1 -e EF_VERSION="$DOTNET_EF_VERSION" \ - -e ASPNETCORE_ENVIRONMENT=Development \ - -v "$PWD/Backend:/src$z" \ - -v erp-nuget:/root/.nuget/packages \ - -v erp-dotnet-tools:/root/.dotnet/tools \ - -w /src "$DOTNET_SDK_IMAGE" bash -ec ' - dotnet restore ERPCore.sln - dotnet build ERPCore.sln -c Release --no-restore - dotnet test ERPCore.Tests/ERPCore.Tests.csproj -c Release --no-build - - # Program.cs suppresses PendingModelChangesWarning at runtime, so this is the only guard - # against an entity/configuration change merged without its migration. --no-build is safe - # here: the assembly was built from this exact source two commands ago. - dotnet tool update --global dotnet-ef --version "$EF_VERSION" >/dev/null - export PATH="$PATH:/root/.dotnet/tools" - dotnet ef migrations has-pending-model-changes \ - --project ERPCore/ERPCore.csproj --configuration Release --no-build - ' diff --git a/deploy/scripts/ci-detect.sh b/deploy/scripts/ci-detect.sh deleted file mode 100644 index 0d3e6ee..0000000 --- a/deploy/scripts/ci-detect.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env bash -# ci-cd.yml › detect — which checks does this pull request need? -# Compares the PR head's folder tree hashes with the target branch's. Tree hashes are content -# addressed, so this needs no merge-base and works on depth-1 clones. -# shellcheck source=common.sh -source "$(dirname "${BASH_SOURCE[0]}")/common.sh" - -: "${BASE_REF:?BASE_REF (the PR target branch) is required}" -base="refs/remotes/origin/$BASE_REF" -git_remote fetch -q --depth 1 "$REPO_GIT_URL" "+refs/heads/$BASE_REF:$base" - -# changed — succeeds if any path differs between the target branch and HEAD. -changed() { - local p - for p in "$@"; do - if [[ "$(tree_hash "$p")" != "$(tree_hash "$p" "$base")" ]]; then return 0; fi - done - return 1 -} -flag() { if changed "$@"; then echo true; else echo false; fi; } - -set_output backend "$(flag "$BACKEND_PATH" "$BACKEND_TESTS_PATH" Backend/ERPCore.sln)" -set_output frontend "$(flag "$FRONTEND_PATH")" -set_output e2e "$(flag "$E2E_PATH")" -set_output scripts "$(flag deploy .gitea)" -set_output frontend_hash "$(tree_hash "$FRONTEND_PATH")" diff --git a/deploy/scripts/common.sh b/deploy/scripts/common.sh deleted file mode 100644 index 0aa0d14..0000000 --- a/deploy/scripts/common.sh +++ /dev/null @@ -1,131 +0,0 @@ -#!/usr/bin/env bash -# Shared settings + helpers for the ERP-Core CI/CD scripts. Sourced by the other scripts, never -# run directly. Runbook: docs/40-DEPLOYMENT.md (values here must match the server bootstrap, §4). -# shellcheck disable=SC2034 # variables are consumed by the scripts that source this file -set -euo pipefail - -# ── Repository (folders whose git tree hash decides what is checked / built / deployed) ───── -BACKEND_PATH="Backend/ERPCore" -BACKEND_TESTS_PATH="Backend/ERPCore.Tests" -MIGRATIONS_PATH="Backend/ERPCore/Migrations" -FRONTEND_PATH="Frontend/erp-system" -E2E_PATH="Testing/e2e" -GITEA_URL="${GITEA_URL:-https://gitea.hexdive.com}" -REPO_SLUG="New_REP_SYSTEM/ERP-core" -REPO_GIT_URL="$GITEA_URL/$REPO_SLUG.git" - -# ── Server layout of the ON-HOLD two-environment design (deploy/on-hold/). Active production does -# not use these paths — see .gitea/workflows/ci-cd.yml (erpcore.service, /home/erp/core/publish). -# ERP_ROOT still hosts the build lock taken by ci-backend.sh. ───────────────────────────────── -ERP_ROOT="${ERP_ROOT:-/opt/erpcore}" -RELEASES_DIR="$ERP_ROOT/releases/backend" -BUILD_LOCK="$ERP_ROOT/.build.lock" -BACKUP_DIR="${ERP_BACKUP_DIR:-/var/backups/erpcore}" -E2E_REPORTS_DIR="${ERP_E2E_REPORTS_DIR:-/var/lib/erpcore/e2e-reports}" - -# ── Build images (builds run in CPU/memory-capped containers on the shared 8 GB VPS) ─────── -DOTNET_SDK_IMAGE="mcr.microsoft.com/dotnet/sdk:10.0" -NODE_IMAGE="node:24-alpine" -WEB_IMAGE_REPO="erp-web" -DOTNET_EF_VERSION="10.0.9" # keep in step with Microsoft.EntityFrameworkCore.* in ERPCore.csproj - -log() { printf '[erp] %s\n' "$*"; } -warn() { printf '[erp] WARNING: %s\n' "$*" >&2; } -fail() { - printf '[erp] ERROR: %s\n' "$*" >&2 - exit 1 -} - -# load_env — per-environment values. Ports, unit/container names and database -# names must match the server (systemd unit, Apache vhosts, /etc/erpcore/.env). -# While both branches are on hold every call fails, so the lines after the case are unreachable. -# shellcheck disable=SC2317 -load_env() { - ERP_ENV="$1" - case "$ERP_ENV" in - # ON HOLD — test environment (deploy/on-hold/README.md, docs/40-DEPLOYMENT.md "On hold"). - # test) - # SERVICE="erpcore@test" - # BACKEND_PORT=5230 - # WEB_CONTAINER="erp-web-test" - # WEB_PORT=3030 - # DB_NAME="ERPCoreStaging" - # BACKUP_POLICY="on-migration" - # HEALTH_TIMEOUT=90 - # PUBLIC_URL="https://test-erp.hexdive.com" - # ;; - # ON HOLD — the two-environment layout for release (/opt/erpcore, erpcore@release). NOT how - # production runs today: the active deploy is .gitea/workflows/ci-cd.yml → erpcore.service + - # /home/erp/core/publish. Nothing active calls load_env. - # release) - # SERVICE="erpcore@release" - # BACKEND_PORT=5220 - # WEB_CONTAINER="erp-web" - # WEB_PORT=3020 - # DB_NAME="ERPCore" - # BACKUP_POLICY="always" - # HEALTH_TIMEOUT=300 - # PUBLIC_URL="https://erp.hexdive.com" - # ;; - *) fail "unknown environment '$ERP_ENV' (expected test or release)" ;; - esac - ENV_DIR="$ERP_ROOT/$ERP_ENV" - STATE_DIR="$ENV_DIR/.deployed" - CURRENT_LINK="$ENV_DIR/backend/current" -} - -# env_for_branch — the environment a push to deploys to. -env_for_branch() { - case "$1" in - # ON HOLD — test environment: Dev) echo test ;; - production) echo release ;; - *) return 1 ;; - esac -} - -# tree_hash [rev] — git tree (or blob) id of a path. Identical folder content gives an -# identical hash no matter which commit, branch or merge produced it — that is what lets a -# Dev → production release reuse the build test already made. Empty if the path is absent. -tree_hash() { git rev-parse --verify --quiet "${2:-HEAD}:$1" 2>/dev/null || true; } - -read_state() { cat "$STATE_DIR/$1" 2>/dev/null || true; } -write_state() { - mkdir -p "$STATE_DIR" - printf '%s\n' "$2" >"$STATE_DIR/$1.tmp" - mv -f "$STATE_DIR/$1.tmp" "$STATE_DIR/$1" -} -# record_history — append-only audit of every deploy attempt. -record_history() { - mkdir -p "$STATE_DIR" - printf '%s\t%s\t%s\t%s\tsha=%s\trun=%s\n' "$(date -u +%FT%TZ)" "$1" "$2" "$3" \ - "${GITHUB_SHA:-manual}" "${GITHUB_RUN_NUMBER:-}" >>"$STATE_DIR/history.log" -} - -# set_output — Gitea Actions step output (only logged when run by hand). -set_output() { - if [[ -n "${GITHUB_OUTPUT:-}" ]]; then printf '%s=%s\n' "$1" "$2" >>"$GITHUB_OUTPUT"; fi - log "$1=$2" -} - -# with_build_lock — serialize heavy work on this host. The runner's capacity is 1 today; this -# keeps two jobs from writing the same releases/.tmp or image tag if that is ever raised. -with_build_lock() { - mkdir -p "$ERP_ROOT" - exec 9>"$BUILD_LOCK" - flock 9 -} - -# git_remote — run git with the ERPSECRET token as an HTTP header (never echoed, -# never written to .git/config). -git_remote() { - : "${ERP_GIT_TOKEN:?ERP_GIT_TOKEN (secrets.ERPSECRET) is required}" - local basic - basic="$(printf 'oauth2:%s' "$ERP_GIT_TOKEN" | base64 | tr -d '\n')" - git -c "http.extraHeader=Authorization: Basic $basic" "$@" -} - -# selinux_mount_suffix — ":z" relabels bind mounts on SELinux hosts (AlmaLinux) so containers -# can read them; empty elsewhere (e.g. a developer laptop). -selinux_mount_suffix() { - if command -v selinuxenabled >/dev/null 2>&1 && selinuxenabled; then echo ":z"; fi -} diff --git a/deploy/scripts/migration-scan.sh b/deploy/scripts/migration-scan.sh deleted file mode 100644 index ae44579..0000000 --- a/deploy/scripts/migration-scan.sh +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env bash -# ci-cd.yml › backend — flag destructive EF migrations introduced by this pull request. -# -# A failed deploy rolls the backend binaries back but never the database, so every release must -# leave the PREVIOUS backend working on the NEW schema: add tables/columns now, drop or rename -# them in a later release (expand → contract, docs/40-DEPLOYMENT.md §8). -# into Dev → warning -# into production → failure, unless the PR title contains [allow-destructive-migration] -# shellcheck source=common.sh -source "$(dirname "${BASH_SOURCE[0]}")/common.sh" - -: "${BASE_REF:?BASE_REF (the PR target branch) is required}" -base="refs/remotes/origin/$BASE_REF" -git_remote fetch -q --depth 1 "$REPO_GIT_URL" "+refs/heads/$BASE_REF:$base" - -# Two-dot tree diff: needs only the two commits, not their history (depth-1 friendly). -mapfile -t files < <( - git diff --name-only --diff-filter=AM "$base" HEAD -- "$MIGRATIONS_PATH" | - grep -E '\.cs$' | grep -vE '\.Designer\.cs$|ModelSnapshot\.cs$' || true -) - -if ((${#files[@]} == 0)); then - log "no new or changed migrations" - exit 0 -fi - -hits="" -for f in "${files[@]}"; do - # Only Up() matters — every Down() legitimately drops what its Up() created. - found="$(awk ' - /override void Up\(/ { up = 1 } - /override void Down\(/ { up = 0 } - up && /migrationBuilder\.(DropTable|DropColumn|RenameColumn|RenameTable|DropSchema|DropSequence)\(/ { - printf " %s:%d: %s\n", FILENAME, FNR, $0 - }' "$f")" - hits+="$found" -done - -if [[ -z "$hits" ]]; then - log "checked ${#files[@]} migration file(s): additive only" - exit 0 -fi - -printf '%s\n' "$hits" -msg="destructive operation in a migration's Up() — keep the previous release working on the new schema: expand now, contract in a later release (docs/40-DEPLOYMENT.md §8)" -if [[ "$BASE_REF" == production ]]; then - if [[ "${PR_TITLE:-}" == *"[allow-destructive-migration]"* ]]; then - warn "$msg — allowed by the [allow-destructive-migration] PR title override" - exit 0 - fi - fail "$msg" -fi -warn "$msg" diff --git a/deploy/scripts/wait-healthy.sh b/deploy/scripts/wait-healthy.sh deleted file mode 100644 index 6103de7..0000000 --- a/deploy/scripts/wait-healthy.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env bash -# ci-cd.yml › Build & Deploy › Verify backend — wait until erpcore.service answers /health, and fail the job otherwise. -# -# EF migrations + seeding run BEFORE the app starts listening, so a slow start is normal. Only a -# crash (unit failed, or systemd auto-restarted it) or the ceiling counts as failure — a crashed -# service fails within seconds, and a slow but live migration is never cut short by a tight timer. -# -# Env: SERVICE (erpcore.service) · BACKEND_PORT (5220) · HEALTH_TIMEOUT seconds (300) -# RESTARTS_AT_START — NRestarts captured right after `systemctl start` (the Start step exports it) -set -euo pipefail - -service="${SERVICE:-erpcore.service}" -port="${BACKEND_PORT:-5220}" -timeout="${HEALTH_TIMEOUT:-300}" -restarts_at_start="${RESTARTS_AT_START:-$(systemctl show -p NRestarts --value "$service")}" - -show_journal() { journalctl -u "$service" -n 80 --no-pager || true; } - -deadline=$((SECONDS + timeout)) -while ((SECONDS < deadline)); do - if curl -fsS --max-time 5 -o /dev/null "http://127.0.0.1:$port/health" 2>/dev/null; then - echo "✅ $service is healthy (after ${SECONDS}s)" - exit 0 - fi - state="$(systemctl show -p ActiveState --value "$service")" - restarts="$(systemctl show -p NRestarts --value "$service")" - if [[ "$state" == failed || "$restarts" != "$restarts_at_start" ]]; then - echo "❌ $service crashed during startup (state=$state, restarts $restarts_at_start → $restarts)" >&2 - show_journal - exit 1 - fi - sleep 3 -done - -echo "❌ $service did not answer /health within ${timeout}s (still starting? DB reachable?)" >&2 -show_journal -exit 1 diff --git a/deploy/systemd/erpcore.service b/deploy/systemd/erpcore.service deleted file mode 100644 index 5f679fa..0000000 --- a/deploy/systemd/erpcore.service +++ /dev/null @@ -1,38 +0,0 @@ -# ERP-Core API — production (erp.hexdive.com). -# -# Install once (docs/40-DEPLOYMENT.md, "Active" A.2): -# cp deploy/systemd/erpcore.service /etc/systemd/system/ -# systemctl daemon-reload && systemctl enable erpcore.service -# .gitea/workflows/ci-cd.yml stops it, replaces /home/erp/core/publish, then starts it again. - -[Unit] -Description=ERP-Core API (production) -After=network-online.target postgresql-16.service -Wants=network-online.target -# Stop hammering the database if a release crash-loops. -StartLimitIntervalSec=300 -StartLimitBurst=5 - -[Service] -Type=simple -User=erp -Group=erp -WorkingDirectory=/home/erp/core/publish -ExecStart=/home/erp/core/publish/ERPCore -# Secrets + settings (template: deploy/env/erpcore.env.example). -EnvironmentFile=/etc/erpcore/erpcore.env -Environment=DOTNET_CLI_TELEMETRY_OPTOUT=1 -Restart=on-failure -RestartSec=5 -TimeoutStopSec=30 - -# Shared 8 GB VPS: a runaway process must not take Gitea/Postgres down with it. -MemoryMax=768M - -# Hardening. No ProtectHome: the app itself lives under /home/erp. -NoNewPrivileges=true -PrivateTmp=true -ProtectSystem=full - -[Install] -WantedBy=multi-user.target diff --git a/docs/00-CORE.md b/docs/00-CORE.md index cb3ab94..0218b00 100644 --- a/docs/00-CORE.md +++ b/docs/00-CORE.md @@ -247,8 +247,14 @@ Resulting `ERPCore.csproj`: "Jwt": { "SigningKey": "dev-only-signing-key-please-change-me-0123456789" } } ``` -The deployed environment (production; a test environment is on hold) has **no** `appsettings..json`: every environment-specific value and secret is an environment variable in `/etc/erpcore/erpcore.env`, and outside Development the API refuses to start when a required key is blank — see `40-DEPLOYMENT.md §5`. -> **Secrets note:** `appsettings.Development.json` is git-ignored (start from `appsettings.Development.json.example`) and the base `appsettings.json` holds no secrets. Credentials committed before this change stay readable in git history and must be rotated (`02-SECURITY.md` AR-06). +`appsettings.Production.json` (values injected from environment variables at deploy time): +```json +{ + "ConnectionStrings": { "DefaultConnection": "" }, + "Jwt": { "SigningKey": "" } +} +``` +> **Secrets note (current phase):** we are on **localhost dev only**, so the Development file holds local credentials for convenience. Before any shared/staging/production use, move secrets to **User Secrets** (dev) and **environment variables** (prod), keep `appsettings.Development.json` / `appsettings.Production.json` out of git (see root `.gitignore`), and rotate any credential that was ever committed. ### 5.6 Minimal `Program.cs` wiring (outline) ```csharp diff --git a/docs/01-DOC-GUIDE.md b/docs/01-DOC-GUIDE.md index b5b6af0..3545ee6 100644 --- a/docs/01-DOC-GUIDE.md +++ b/docs/01-DOC-GUIDE.md @@ -45,11 +45,10 @@ | `30-BACKEND-PHASE2.md` | High | Manufacturing (Production Lines) backend: SRS, ER model, status machines, stock/costing integration, **and** the full API contract — model and API in one doc, unlike Phase 1. Schema is **authoritative** here. | Any manufacturing model / rule / API work. | Claude + humans | | `21-FRONTEND-PHASE2.md` | High | Manufacturing frontend **user-flows**: template canvas builder, run board, run execution screens. | Any manufacturing frontend work. | Claude + humans | | `21-GENERAL-LEDGER-FRONTEND.md` | High | **Ledgers** sidebar section: statutory-format report screens + PDF download, Cash/Bank Accounts (list/create; edit gap explained). Extends `20-FRONTEND.md` rather than duplicating it. | Any work on `app/dashboard/ledgers/*`. | Claude + humans | -| `40-DEPLOYMENT.md` | High (ops) | **CI/CD + deployment runbook:** PR checks, change detection, test/release environments, server bootstrap, config & secrets, rollback, operations. | Any work on `.gitea/workflows/`, `deploy/`, server config, or when deploying / rolling back. | Claude + humans | | `Backend/PROGRESS.md` | Low | Backend **change checklist**, git-shared. | After making backend changes. | **Claude** | | `Frontend/PROGRESS.md` | Low | Frontend **change checklist**, git-shared. | After making frontend changes. | **Claude** | -Numbering convention: `0x` = high-level hub/guide/cross-cutting, `1x` = backend, `2x` = frontend, `4x` = operations (CI/CD, deployment). Numbers sort in read order in any file browser. +Numbering convention: `0x` = high-level hub/guide/cross-cutting, `1x` = backend, `2x` = frontend. Numbers sort in read order in any file browser. > **Known deviation:** `30-BACKEND-PHASE2.md` is a backend doc numbered `3x` rather than continuing the `1x` decade, and it merges the model and the API contract into one file instead of the `10`/`11` split. Left as-is — it is already the established reference for the manufacturing phase and renumbering it would invalidate every existing cross-reference. New backend phases should return to the `1x` convention. diff --git a/docs/02-SECURITY.md b/docs/02-SECURITY.md index 792163d..6271795 100644 --- a/docs/02-SECURITY.md +++ b/docs/02-SECURITY.md @@ -17,7 +17,7 @@ These are known, deliberately-accepted Phase-1 exposures. Each has a compensatin | **AR-03** | **IDOR** — sequential IDs + no RBAC → a user can access another's document by guessing the ID. | Consequence of AR-01. | Audit trail. | Closed with RBAC. | | **AR-04** | **GRN-without-PO** — receive (and value) goods with no order; a fraud vector. | Emergency/direct receipt is useful (FR-GRN-02). | Permission-gated (when RBAC on); flagged for review; audit; cost entered here gets extra scrutiny (see C.3). | With RBAC / policy on direct receipts. | | **AR-05** | **In-transit loss window** — dispatched-but-not-received stock is untracked shrinkage risk. | Inherent to in-transit transfers (FR-STK-05). | In-transit aging monitoring report. | Add stuck-transfer alert (Part D). | -| **AR-06** | **Committed secrets (historical).** `appsettings.Development.json` (DB password), `appsettings.json` (GL API key) and `Backend/smoke/smoke_common.py` (AuthHex login) were committed while the repo was publicly readable. | Local-dev convenience in early phases. | **Now closed going forward:** env-specific appsettings untracked, base `appsettings.json` and smoke defaults hold no secrets, deployed secrets live only in `/etc/erpcore/erpcore.env` on the server (`40-DEPLOYMENT.md` A.2 / §5), non-Development startup fails on missing keys. | The exposed values stay readable in git history → **rotate** the GL API key, Postgres password and the AuthHex account password, and keep the repo private. | +| **AR-06** | **Localhost dev secrets** in `appsettings.Development.json`. | Local-dev convenience, current phase. | `.gitignore` + localhost only. | Before any shared/staging/prod → User Secrets / env vars; rotate. | | **AR-07** | **`getUserDetails` / `LogoutUser` callable without a bearer token** — `GET /api/v1/auth/users/{userId}` and `POST /api/v1/auth/logout` resolve the target user from the URL/payload, not the caller's session, so any anonymous caller can fetch a profile or log out an arbitrary user's sessions by GUID. | Carried over verbatim from AuthHex's own dispatcher contract (API_REFERENCE.md §3) — ERPCore's `AuthController` proxies it as-is rather than silently tightening a contract it doesn't own. | GUIDs are not enumerable; every call is written to `AuthEventLogs` upstream in AuthHex. | Revisit once AuthHex exposes a token-scoped variant, or add ERPCore-side rate limiting / auth requirement ahead of AuthHex. | | **AR-08** | **No rate limiting on `AuthController`'s anonymous endpoints** (login, register, refresh, recovery, OTP send/verify) — brute-force and account-enumeration exposure. | Not built in this pass (docs/11 §2.0, added 2026-07-16); AuthHex may rate-limit server-side but ERPCore does not add its own layer yet. | AuthHex's own lockout/backoff (per docs/10 NFR-03), immutable audit trail. | Add ASP.NET Core rate limiting middleware to `AuthController` before any non-local deployment. | | **AR-09** | **HRM inherits AR-01 for salary/PII data** — any door-admitted authenticated user can currently view/download any employee's salary figures, payslip, or uploaded documents (NIC scans, contracts). This is a **explicit, flagged decision, not a silent inheritance**: salary/PII is categorically more sensitive than Phase-1 inventory data, and this was called out to the business owner before HRM build started (see `12-BACKEND-HRM.md` A.1). | RBAC still deferred repo-wide; a coarse HR-role door-gate was not made a blocking prerequisite for HRM go-live. | Immutable audit trail (as AR-01); sidebar-visibility hiding of Employees/Attendance/Payroll sections for non-HR roles via the existing `NavItem`/`RolePermission` mechanism (UI-level only, not a server-enforced gate). | **HRM should be the forcing function that enables per-endpoint RBAC ahead of the rest of the system** (see Part D) — salary-data exposure is a materially worse blast radius than inventory data. | diff --git a/docs/40-DEPLOYMENT.md b/docs/40-DEPLOYMENT.md deleted file mode 100644 index 8cd8a97..0000000 --- a/docs/40-DEPLOYMENT.md +++ /dev/null @@ -1,347 +0,0 @@ -# 40 · DEPLOYMENT — CI/CD, Environments & Server Runbook - -> **What this is:** how code gets from a pull request to `erp.hexdive.com` (the `test-erp.hexdive.com` environment is on hold), how the server is set up, and how to operate it (config changes, rollback, backups). -> **Navigation:** you arrived from `00-CORE.md` or `01-DOC-GUIDE.md`. Security posture: `02-SECURITY.md` (AR-06 covers the secrets history). -> **Files (active):** `.gitea/workflows/ci-cd.yml` (PR checks + production deploy, one file) · `deploy/scripts/{common,ci-detect,ci-backend,migration-scan,wait-healthy}.sh` · `deploy/systemd/erpcore.service` · `deploy/apache/erp.hexdive.com.conf` · `deploy/env/erpcore.env.example`. -> **Files (on hold):** `deploy/on-hold/` — the test + release pipeline, see its README. - ---- - -# Active: production-only deployment (since 2026-09-15) - -Only `erp.hexdive.com` exists, so production is deployed the same way as the Fitness apps (`Fitness_Core_BE`, `Client_New_Fe`), plus three safeguards: only the side that changed is deployed, the database is backed up when migrations change, and the verify steps really fail the job. The test environment and the two-environment pipeline described further down are **on hold**. From that part, §5 (configuration), the PR-checks table in §6, §7 (lint backlog) and §8 (migrations) still apply. - -## A.1 Flow — `.gitea/workflows/ci-cd.yml` - -One workflow file holds both the pull-request checks and the production deploy. Every job has an `if` that picks the event it belongs to: - -| Event | Jobs that run | -|---|---| -| PR opened / updated / reopened into `Dev` or `production` | checks: `detect` → `backend` / `frontend` / `e2e-typecheck` / `scripts` (only the sides the PR touches) → `ci-ok` (§6) | -| PR **merged** into `production` | `Build & Deploy` (checks skipped) | -| PR merged into `Dev`, or closed without merging | nothing — every job shows as skipped | -| *Actions → CI/CD - ERP Core → Run workflow* | `Build & Deploy` with the `deploy_backend` / `deploy_frontend` boxes | - -The deploy job checks `pull_request.base.ref == 'production'` itself, because the file also listens to PRs into `Dev`. It has no `needs: ci-ok`: that job belongs to the PR-open events and is skipped on a merge. The gate before a merge is branch protection requiring `ci-ok`. The whole file shares one concurrency group that is **never cancelled**, so a PR push can't interrupt a deploy or its migration. The cost is that superseded check runs queue instead of being cancelled (with runner capacity 1 they ran one after another anyway). - -**Deploy job** (`Build & Deploy`, on the `linux` host runner): - -| # | Step | Runs when | What | -|---|---|---|---| -| 1 | Checkout code | always | fetch the merge commit (`merge_commit_sha`) and production-before-the-PR (`base.sha`) | -| 2 | Detect changes | always | compare folder **tree hashes** base → merge for `Backend/ERPCore`, `Frontend/erp-system`, `Backend/ERPCore/Migrations`. Works for merge, squash and rebase merges. Manual runs use the `deploy_backend` / `deploy_frontend` checkboxes. | -| 3 | Restore, Publish | backend changed | `dotnet restore` + `dotnet publish -r linux-x64 --self-contained` into `/tmp/erp-deploy` (on the host, like Fitness) | -| 4 | Backup database | backend **and** migrations changed | `pg_dump -Fc ERPCore` → `/var/backups/erpcore/`, dumps older than 30 days deleted | -| 5 | Guard data paths | backend changed | fail if `FileStorage__RootPath` / `LogFile__Path` in `/etc/erpcore/erpcore.env` are missing, relative, or inside the publish folder | -| 6 | Stop → Copy → Fix ownership → Start | backend changed | `systemctl stop erpcore.service` · empty `/home/erp/core/publish` and copy the build · `chown erp:erp`, 755/644, `+x ERPCore` · `systemctl start` | -| 7 | Verify backend | backend changed | `deploy/scripts/wait-healthy.sh`: waits for `/health` 200. A crash (unit failed / auto-restarted) fails within seconds; otherwise it waits up to 300 s, because migrations run at startup. Failure prints the journal and turns the job **red**. | -| 8 | Build frontend image → Replace container | frontend changed | `docker build -t erp-web:latest Frontend/erp-system` (build inside the image, like Client_New_Fe) · recreate `erp-web` on `127.0.0.1:3020`, 512 MB, `--restart unless-stopped` | -| 9 | Verify frontend → Remove old images | frontend changed | `GET /login` within 30 s or print container logs and fail · `docker image prune -f`, `docker builder prune` older than 7 days | - -The backend always runs before the frontend in the same job, so a failed backend never gets a new UI on top of it. - -**Limits of this mode — know them:** -- **No automatic rollback.** A failed deploy leaves the service down (backend) or the site without a working UI (frontend: the old container is already removed and the old image untagged) until someone fixes it and redeploys — a revert merge means waiting for a full rebuild. -- **Detection only sees the PR being merged.** A deploy that failed is **not** retried by the next merge; redeploy that side with the manual run. -- **Rollback means old code on the current schema.** There are no versioned release folders: rolling back is merging a revert (or checking out an older commit and redeploying), and the database keeps any migrations that already ran. That only works while migrations stay **additive**, so `migration-scan.sh` failing destructive migrations in PRs into `production` is load-bearing (§8). Restore a `pg_dump` only as a last resort. -- **Builds are not memory-capped** (host `dotnet publish`, `docker build`). Keep the runner at `capacity: 1`. - -## A.2 One-time server setup -```bash -# Runner: one job at a time + 4 GB swap (see §4.1 for the commands) -dotnet --list-sdks # needs a 10.0.x SDK; install it next to the existing SDKs if missing -# (Fitness/AuthHex have no global.json, so they will build with SDK 10 too once it is the newest — normally fine) - -# Service user + folders. Uploads, logs and backups live OUTSIDE the publish folder, which every deploy empties. -useradd --create-home --home-dir /home/erp --shell /sbin/nologin erp -install -d -m 755 -o erp -g erp /home/erp/core /home/erp/core/publish -install -d -m 750 -o erp -g erp /var/lib/erpcore/hr-documents /var/log/erpcore -install -d -m 700 /var/backups/erpcore -install -d -m 750 -o root -g erp /etc/erpcore - -# Database (Postgres 16, localhost only) -sudo -u postgres psql -c "CREATE ROLE erpcore LOGIN PASSWORD 'CHANGE_ME';" -c 'CREATE DATABASE "ERPCore" OWNER erpcore;' - -# Settings + secrets (fill in CHANGE_ME / ports — keys in §5) -install -m 640 -o root -g erp deploy/env/erpcore.env.example /etc/erpcore/erpcore.env - -# Service -cp deploy/systemd/erpcore.service /etc/systemd/system/ && systemctl daemon-reload && systemctl enable erpcore.service - -# Apache + TLS (needs SELinux httpd_can_network_connect=on) -cp deploy/apache/erp.hexdive.com.conf /etc/httpd/conf.d/ && apachectl configtest && systemctl reload httpd -certbot --apache -d erp.hexdive.com -``` -**Gitea:** Actions enabled for the repo · the `linux` runner available to `New_REP_SYSTEM` · secret **`ERPSECRET`** (a token that can read the repo) · branch protection on `production`: require PR, 1 approval, required check `ci-ok` (it appears as `CI/CD - ERP Core / ci-ok …` after the first PR run; a pattern like `CI/CD - ERP Core / ci-ok*` matches it), no direct push. `Dev`: require PR + the same check. - -**First deploy:** merge a PR that touches both folders, or use the manual run with both boxes ticked. If `erpcore.service` fails with `status=203/EXEC`, run `restorecon -Rv /home/erp/core/publish` (SELinux label). - -## A.3 Operations -| Task | How | -|---|---| -| Config change | edit `/etc/erpcore/erpcore.env` → Run workflow with **deploy_backend** (or `systemctl restart erpcore` + `bash deploy/scripts/wait-healthy.sh`) | -| Redeploy after a failure | fix the cause → Run workflow with the failed side ticked | -| Roll back | merge a revert PR into `production` (it deploys like any change); see the limits above | -| Logs | `journalctl -u erpcore -f` · `/var/log/erpcore/erpcore-*.log` · `docker logs -f erp-web` · `/var/log/httpd/erp.hexdive.com-error.log` | -| Restore a backup | `systemctl stop erpcore` → `sudo -u postgres pg_restore --clean --if-exists -d ERPCore /var/backups/erpcore/.dump` → deploy the code that matches that schema | -| Swagger | off in Production | - -## A.4 Troubleshooting -| Symptom | Likely cause | -|---|---| -| Job stays "Waiting" | runner not available to this repo/org, or offline | -| "Guard data paths" fails | `FileStorage__RootPath` / `LogFile__Path` missing, relative or inside `/home/erp/core/publish` | -| Verify backend fails in seconds with `Missing required configuration …` | a blank key in `/etc/erpcore/erpcore.env` | -| Verify backend times out after 300 s | DB unreachable (`/health` 503), a very long migration, or wrong AuthHex/GL URLs | -| Login loops back to `/login` | not on HTTPS (cookies are Secure) | -| Nothing deployed after a merge | the PR changed neither `Backend/ERPCore` nor `Frontend/erp-system` (the detect step prints the flags) | - ---- - -# On hold: two-environment design (test + release) - -> **Not active.** Kept for when `test-erp.hexdive.com` exists — files in `deploy/on-hold/`, restore steps in its README. §5, the PR-checks part of §6 (now inside `ci-cd.yml`), §7 and §8 below still apply to the active production deployment. Where this part says `ci.yml` / `deploy.yml`, the active equivalent is the single `ci-cd.yml`. - -## 1. Overview - -```mermaid -flowchart LR - PR[Pull request] -->|ci.yml| CI{changed sides only} - CI --> OK[ci-ok ✔ required check] - OK -->|merge into Dev| TEST[deploy.yml → test] - OK -->|merge Dev → production| REL[deploy.yml → release] - TEST --> D1{tree hash ≠ deployed?} - REL --> D2{tree hash ≠ deployed?} - D1 -->|backend| B1[build or reuse → backup? → swap → /health] --> F1[frontend: image → swap → /login] - D2 -->|backend| B2[reuse test's build → backup → swap → /health] --> F2[frontend: reuse image → swap] --> R[Gitea release + tag] -``` - -- **One VPS** (Hostinger, AlmaLinux, `187.127.102.190`) runs Gitea, its `linux` host runner, Postgres 16, Apache, AuthHex, the GL service — and both ERP environments. -- **Backend** = self-contained .NET publish run by systemd (`erpcore@test`, `erpcore@release`). **Frontend** = Docker container of a runtime-only Next.js image. Both listen on `127.0.0.1` only; Apache is the single public entry point. -- **Only what changed is built and deployed**, and whatever was already built is reused (§3). Backend always goes before frontend, because a new UI must never talk to an old API. - -## 2. Environments - -| | **test** | **release** | -|---|---|---| -| Deployed from | push/merge to `Dev` | merge `Dev → production` | -| URL | https://test-erp.hexdive.com | https://erp.hexdive.com | -| Backend unit / port | `erpcore@test` · `127.0.0.1:5230` | `erpcore@release` · `127.0.0.1:5220` | -| Frontend container / port | `erp-web-test` · `127.0.0.1:3030` | `erp-web` · `127.0.0.1:3020` | -| Database / role | `ERPCoreStaging` / `erpcore_test` | `ERPCore` / `erpcore_release` | -| `ASPNETCORE_ENVIRONMENT` | `Staging` (Swagger on, tunnel-only) | `Production` | -| Demo seed data | on (`Seed__DemoData=true`) | **off** | -| DB backup before backend deploy | only when migrations changed | **always** | -| Health-check ceiling | 90 s | 300 s | -| After deploy | nightly / on-demand Playwright | Gitea release `release-YYYY.MM.DD-N` | - -These values live in `deploy/scripts/common.sh` (`load_env`) and must match the server (§4). -The test database is deliberately **not** `ERPCoreTest`/`ERPCoreDev`: developers use those through SSH tunnels, and a local `dotnet ef database update` from an unmerged branch would move the deployed test schema ahead of the deployed code. - -## 3. Change detection — why a push builds only what changed - -Git stores every folder as a **tree object** whose id is a hash of its exact contents: `git rev-parse HEAD:Backend/ERPCore`. Same content ⇒ same hash, regardless of commit, branch or merge. - -| Folder | Hash decides | -|---|---| -| `Backend/ERPCore` | backend build + deploy | -| `Backend/ERPCore/Migrations` | whether test takes a DB backup | -| `Frontend/erp-system` | frontend image + deploy | -| anything else (`docs/`, `*.md`, `Testing/`, `Backend/smoke/`, `deploy/`) | **nothing deploys** | - -- **Deploy:** `detect.sh` compares each hash with the one last deployed **successfully** to that environment (`/opt/erpcore//.deployed/{backend,frontend,migrations}`). A failed deploy never updates that file, so the next run retries it. Works on depth-1 clones; no history needed. -- **Build once, reuse everywhere:** backend releases are stored as `/opt/erpcore/releases/backend//` and images as `erp-web:`. The PR check builds the image; the test deploy reuses it; the release deploy reuses both (a `Dev → production` merge carries the same trees). A release normally compiles **nothing**. -- **Pull requests:** `ci-detect.sh` compares the PR head's hashes with the target branch's, so a docs-only PR finishes in seconds. -- **Changes to `deploy/` or the workflows** do not trigger a deploy by themselves (scripts always run from the deployed commit, so they take effect on the next deploy). Use a forced run (§9) if needed. - -## 4. Server bootstrap (one time) - -Run as root on the VPS. Keep names/ports in sync with §2. - -### 4.0 Prerequisites (repo owner) -1. `New_REP_SYSTEM/ERP-core` is **private**, and the credentials listed in `02-SECURITY.md` AR-06 are **rotated**. -2. DNS A records `erp.hexdive.com` and `test-erp.hexdive.com` → `187.127.102.190`. -3. Repo **Settings → Actions** enabled. The existing `linux` runner must be available to this repo: it was registered for the HexaFitness repos, so check **Site Administration → Actions → Runners** — if it is org-scoped to HexaFitness, register it at instance level or add one for `New_REP_SYSTEM`, otherwise jobs sit in "Waiting". -4. Repo secrets: `ERPSECRET` (bot token with repo **write** — it clones and creates releases), `E2E_ADMIN_EMAIL`, `E2E_ADMIN_PASSWORD` (a dedicated AuthHex test user with Receiving/Production/Stock access, not an admin). -5. Note the AuthHex and GL service ports: `ss -ltnp`. - -### 4.1 Runner and host tools -```bash -# One job at a time — REQUIRED. Keeps peak memory under 8 GB and serializes builds of the same -# hash (test and release use different concurrency groups). Find the config via: systemctl cat act_runner -# runner: -# capacity: 1 -systemctl restart act_runner - -# Host tools used by deploy/scripts (runner runs jobs as root on the host): -dnf install -y git curl util-linux coreutils python3 docker-ce postgresql16 # most already present -docker info >/dev/null && echo docker ok - -# 4 GB swap so a build spike slows down instead of OOM-killing a live service -fallocate -l 4G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile -echo '/swapfile none swap defaults 0 0' >> /etc/fstab -``` - -### 4.2 Service user and folders -```bash -useradd --system --no-create-home --shell /sbin/nologin erpcore -install -d -m 755 /opt/erpcore/releases/backend /opt/erpcore/{test,release}/{backend,.deployed} -install -d -m 750 -o erpcore -g erpcore /var/lib/erpcore/{test,release}/hr-documents /var/log/erpcore/{test,release} -install -d -m 700 /var/backups/erpcore /var/lib/erpcore/e2e-reports -install -d -m 750 -o root -g erpcore /etc/erpcore -``` -The `erpcore` ownership on `/var/lib/erpcore/` and `/var/log/erpcore/` is essential: Serilog's file sink fails **silently** without it, and HR uploads return 500. Releases stay root-owned and read-only for the service. - -### 4.3 PostgreSQL -```bash -sudo -u postgres psql -lqt | cut -d'|' -f1 # see which names exist -sudo -u postgres psql <<'SQL' -CREATE ROLE erpcore_test LOGIN PASSWORD 'CHANGE_ME'; -CREATE DATABASE "ERPCoreStaging" OWNER erpcore_test; -CREATE ROLE erpcore_release LOGIN PASSWORD 'CHANGE_ME'; -CREATE DATABASE "ERPCore" OWNER erpcore_release; -SQL -``` -Each role **owns** its database (migrations run DDL at startup) and cannot touch the other. Postgres stays localhost-only; `pg_hba.conf` already allows `127.0.0.1/32` with scram-sha-256. - -### 4.4 Environment files → §5 -```bash -install -m 640 -o root -g erpcore deploy/env/erpcore.env.example /etc/erpcore/test.env -install -m 640 -o root -g erpcore deploy/env/erpcore.env.example /etc/erpcore/release.env -# edit both: replace CHANGE_ME/, and switch the release file to Production/5220/ERPCore/erpcore_release/false -``` - -### 4.5 systemd -```bash -cp deploy/systemd/erpcore@.service /etc/systemd/system/ -systemctl daemon-reload -systemctl enable erpcore@test erpcore@release # the first deploy starts them -``` - -### 4.6 Apache + TLS -```bash -getsebool httpd_can_network_connect # must be on (setsebool -P httpd_can_network_connect 1) -httpd -M | grep -E 'proxy_module|proxy_http|headers|ssl' -cp deploy/apache/erp.hexdive.com.conf deploy/apache/test-erp.hexdive.com.conf /etc/httpd/conf.d/ -apachectl configtest && systemctl reload httpd -certbot --apache -d erp.hexdive.com -d test-erp.hexdive.com -``` -`/api/` goes straight to the backend, everything else to Next. `/health` and `/swagger` are not proxied. HTTPS is mandatory: the session cookies are `Secure` + `SameSite=Strict`. - -### 4.7 Gitea branch protection (after the first green `ci.yml` run) -| Branch | Rules | -|---|---| -| `Dev` | require PR · required status check **`ci-ok`** | -| `production` | require PR · **1 approval** · required check **`ci-ok`** · no direct push | - -`ci-ok` also rejects PRs into `production` from anything other than `Dev` or `hotfix/*`. Approving the `Dev → production` PR **is** the release approval. - -### 4.8 Housekeeping cron -```bash -install -m 750 deploy/scripts/common.sh deploy/scripts/prune.sh /opt/erpcore/ -echo '0 4 * * 0 root /opt/erpcore/prune.sh >> /var/log/erpcore/prune.log 2>&1' > /etc/cron.d/erpcore-prune -``` -Keeps the newest 5 backend releases and images (plus whatever is deployed), prunes build cache weekly, DB backups after 7 days (test) / 30 days (release), E2E reports after 14 days. Re-copy the two scripts if `prune.sh` changes. - -### 4.9 First deploy -Merge the pipeline into `Dev` → the Deploy run builds and deploys both sides to test (no `.deployed` files yet ⇒ everything counts as changed; there is no rollback target on this very first run). After the first test deploy, run `restorecon -R /opt/erpcore /var/lib/erpcore /var/log/erpcore` once, then walk §11's checklist. - -## 5. Configuration & secrets - -.NET reads `appsettings.json` → `appsettings..json` → **environment variables** (last wins; `:` becomes `__`). Deployed environments have **no** environment-specific appsettings file. - -| Key | Where | Notes | -|---|---|---| -| `ConnectionStrings__DefaultConnection` | `/etc/erpcore/.env` | secret | -| `GeneralLedgerService__ApiKey` | env file | secret | -| `GeneralLedgerService__BaseUrl`, `AuthHex__BaseUrl` | env file | `http://127.0.0.1:` | -| `Auth__RequiredUserTypeCode`, `Auth__RequiredRoleCode` | env file | release should set real codes | -| `FileStorage__RootPath`, `LogFile__Path` | env file | under `/var/lib/erpcore/`, `/var/log/erpcore/` | -| `ASPNETCORE_ENVIRONMENT`, `ASPNETCORE_URLS`, `Seed__DemoData` | env file | per §2 | -| `Auth:Issuer/Audience/RsaPublicKeyXml`, GL account codes, `FileStorage:MaxSizeBytes` | `appsettings.json` | not secret, same everywhere | - -- **Fail fast:** outside Development the API refuses to start if any of `ConnectionStrings:DefaultConnection`, `AuthHex:BaseUrl`, `GeneralLedgerService:BaseUrl`, `GeneralLedgerService:ApiKey`, `Auth:RsaPublicKeyXml` is blank, and names the missing keys — the deploy's health check then fails and rolls back. -- **Gitea secrets** hold only `ERPSECRET` and the E2E user. The pipeline never reads the env files. -- **Frontend** needs no runtime secrets (it only calls same-origin `/api/v1`). -- **Local development:** copy `Backend/ERPCore/appsettings.Development.json.example` to `appsettings.Development.json` (git-ignored) and fill it in; demo data seeds by default in Development. -- `pg_dump` runs as the local `postgres` user (peer auth) — no database password is stored for backups. - -## 6. Pipelines - -### PR checks — `ci-cd.yml` jobs for pull requests into `Dev` / `production` (active) -| Job | Runs when | What | -|---|---|---| -| `detect` | every PR open / update / reopen (not on close) | tree hashes vs target branch (`ci-detect.sh`) | -| `backend` | `Backend/ERPCore`, `ERPCore.Tests` or the `.sln` changed | `dotnet build` + `dotnet test` + `dotnet ef migrations has-pending-model-changes` (`ci-backend.sh`); destructive-migration scan (`migration-scan.sh`, §8) | -| `frontend` | `Frontend/erp-system` changed | `npm ci` → `tsc --noEmit` → `eslint` → `next build` in a capped `node:24-alpine` container (checks only; no image — the production deploy builds its own) | -| `e2e-typecheck` | `Testing/e2e` changed | `npm ci` + `tsc --noEmit` | -| `scripts` | `deploy/` or `.gitea/` changed | `bash -n` + shellcheck | -| **`ci-ok`** | **always** on PR open / update / reopen | passes when every job passed or was skipped — the one required check | - -Newer pushes to the same PR **queue** behind the older run (the workflow's single concurrency group never cancels, to protect deploys — A.1). Worst case ≈ 6 min; docs-only ≈ 10 s. - -**Security model — know its limits.** The dotnet/npm/tsc/eslint work runs in capped containers with no secrets, but the `deploy/scripts/*.sh` files that drive it come from the PR's own checkout and run **on the host runner as root**; `ci-detect.sh` and `migration-scan.sh` also receive `ERPSECRET`. Anyone who can open a PR can therefore run commands on the VPS. That is acceptable only while the repo is **private** and every PR author is a trusted team member. If that ever changes: run the detect/scan steps with scripts fetched from the **base** branch, and give PR jobs a separate, container-only runner. - -### `deploy.yml` — test, release, nightly E2E (on hold: `deploy/on-hold/deploy.two-env.yml`) -| Job | Runs when | What | -|---|---|---| -| `detect` | push / manual | environment from branch, what changed (`detect.sh`) | -| `backend` | backend changed or forced | `build-backend.sh` (reuse or capped build) → `deploy-backend.sh` (backup → stop → repoint `current` → start → wait for `/health`; on failure restore previous release) | -| `frontend` | frontend changed or forced, **after** backend succeeded/skipped | `build-frontend.sh` (reuse or build) → `deploy-frontend.sh` (swap container → `/login` check; on failure restart previous image) | -| `release` | release env, real changes, nothing failed | `release.sh` → Gitea release + tag with hashes and PR list | -| `e2e` | nightly 02:00 Asia/Colombo, or manual `run_e2e` | `e2e.sh` → Playwright in a capped container against **test** | - -Deploys never cancel each other mid-way (`cancel-in-progress: false`). Scheduled runs are read from the **default branch (`production`)**, so the nightly E2E starts after the first release that contains this workflow. - -**Resource caps on the shared 8 GB box:** backend build 2 CPU / 1.5 GB · frontend build 2 CPU / 2.5 GB (`NODE_OPTIONS=--max-old-space-size=2048`) · E2E 2 CPU / 1.5 GB · running frontend container 512 MB · backend unit `MemoryMax=768M`. All builds also take `/opt/erpcore/.build.lock`. - -## 7. Lint backlog - -`eslint.config.mjs` downgrades **`react-hooks/set-state-in-effect`** to a warning (≈80 pre-existing violations from the React Compiler rules eslint-config-next 16 introduced) so lint can block PRs today; every other rule is an error. Clear that backlog in normal PRs, then set the rule back to `"error"`. - -## 8. Database migrations — expand, then contract - -Deploys roll **binaries** back, never the **database**. EF migrations run at startup, so after any deploy the previous backend may have to run against the new schema. - -- **Expand (this release):** add tables, add nullable columns / columns with defaults, add indexes. The old code ignores them. -- **Contract (a later release):** drop or rename what the new code no longer uses. -- `migration-scan.sh` scans the `Up()` of new/changed migrations for `DropTable`, `DropColumn`, `RenameColumn`, `RenameTable`, `DropSchema`, `DropSequence`: **warning** into `Dev`, **failure** into `production` — unless the PR title contains `[allow-destructive-migration]` (use only with a fresh backup and a plan that accepts no backend rollback). -- `has-pending-model-changes` fails a PR whose entities changed without a migration (runtime suppresses `PendingModelChangesWarning`, so CI is the only guard). Always `dotnet build` before `dotnet ef migrations add`. - -## 9. Operations - -All manual actions: **Gitea → ERP-core → Actions → Deploy → Run workflow**, choosing the branch (`Dev` = test, `production` = release). - -| Task | How | -|---|---| -| **Config change** | edit `/etc/erpcore/.env` → run with `force_backend` (restart + health check + automatic rollback) | -| **Redeploy as-is** | `force_backend` and/or `force_frontend` | -| **Rollback** | take `backend_hash` / `frontend_hash` from an earlier Gitea release (or `/opt/erpcore//.deployed/history.log`) → run with those inputs. Artifacts must still exist (newest 5 are kept). The DB stays as it is (§8). | -| **Run E2E now** | `run_e2e` (targets test) — report saved under `/var/lib/erpcore/e2e-reports//playwright-report/` | -| **What is deployed?** | `cat /opt/erpcore//.deployed/{backend,frontend}` · `readlink /opt/erpcore//backend/current` · `tail /opt/erpcore//.deployed/history.log` | -| **Logs** | `journalctl -u erpcore@ -f` · `/var/log/erpcore//erpcore-*.log` · `docker logs -f erp-web[-test]` · `/var/log/httpd/-error.log` | -| **Swagger on test** | `ssh -L 5230:127.0.0.1:5230 ` → http://localhost:5230/swagger | -| **Restore a backup** | `systemctl stop erpcore@` → `sudo -u postgres pg_restore --clean --if-exists -d /var/backups/erpcore/.dump` → deploy the matching `backend_hash` (from the file name / release notes) | - -## 10. Troubleshooting - -| Symptom | Likely cause / fix | -|---|---| -| Jobs stay "Waiting" | runner not available to this repo/org (§4.0 #3) or offline | -| `bash\r: No such file or directory` | a script was committed with CRLF — `.gitattributes` forces LF; re-add the file | -| Backend job red, log shows `Missing required configuration …` | a key is blank in `/etc/erpcore/.env` (§5); previous release was restored | -| Backend job red after ~90/300 s, no crash | startup blocked: DB unreachable (`/health` 503), a very long migration, or AuthHex/GL URLs wrong — `journalctl -u erpcore@` | -| Apache 503 to the app | SELinux `httpd_can_network_connect` off, or the unit/container is down | -| Login loops back to `/login` | site not on HTTPS (Secure cookies) | -| Build killed (exit 137) | memory cap hit — raise the cap in the script before anything else; check swap | -| `no release for … different tree` | rollback target was pruned; pick a newer release | -| Permission denied inside a build container | SELinux relabel missing — scripts add `:z` automatically when SELinux is enabled | -| Nightly E2E never runs | workflow not yet on `production` (schedules read the default branch) | - -## 11. Notes for developers - -- **Pulling the commit that untracked `appsettings.Development.json` deletes your local copy.** Back it up first (`cp appsettings.Development.json ~/erp-dev-settings.json`) and restore it after the pull, or recreate it from the `.example`. It now also needs `GeneralLedgerService:BaseUrl`/`ApiKey`, which moved out of the base file. -- `Backend/smoke` needs `ERP_SMOKE_PASSWORD` (no default password anymore). -- Scripts in `deploy/scripts/` are plain bash; test changes with `bash -n` and shellcheck (the `scripts` CI job does both). - -**First test deploy checklist:** `https://test-erp.hexdive.com/login` loads and login works · data screens load · `/opt/erpcore/test/.deployed/*` written · logs appear in `/var/log/erpcore/test/` · an HR document upload succeeds · a docs-only push deploys nothing · a frontend-only merge leaves `systemctl show -p ActiveEnterTimestamp erpcore@test` unchanged · a deliberately broken env value makes the job red and the previous release keeps serving.