diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b7dc900 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +# 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 new file mode 100644 index 0000000..95d6354 --- /dev/null +++ b/.gitea/workflows/ci-cd.yml @@ -0,0 +1,419 @@ +# 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 d5bfc9d..101cad2 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,8 @@ 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 55128d3..cdbccf9 100644 --- a/Backend/ERPCore.Tests/TestSupport/NotificationConfigTestSupport.cs +++ b/Backend/ERPCore.Tests/TestSupport/NotificationConfigTestSupport.cs @@ -11,6 +11,8 @@ 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 c779b85..01c3425 100644 --- a/Backend/ERPCore.sln +++ b/Backend/ERPCore.sln @@ -5,6 +5,8 @@ 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 @@ -27,6 +29,18 @@ 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 170879f..dbc036a 100644 --- a/Backend/ERPCore/Infra/Gl/GeneralLedgerClient.cs +++ b/Backend/ERPCore/Infra/Gl/GeneralLedgerClient.cs @@ -18,6 +18,11 @@ 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 eea8688..934ff3f 100644 --- a/Backend/ERPCore/Infra/Persistence/DataSeeder.cs +++ b/Backend/ERPCore/Infra/Persistence/DataSeeder.cs @@ -39,17 +39,29 @@ public static class DataSeeder ("PRD-REWORK-LOSS", "Production Rework Loss", ReasonContext.Production), ]; - public static async Task SeedAsync(ErpDbContext db, CancellationToken ct = default) + /// + /// 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) { + // 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 SeedSalesMastersAsync(db, ct); - dirty |= await SeedSalesStockAsync(db, ct); - dirty |= await SeedSalesAsync(db, ct); + dirty |= await SeedSalesSequencesAsync(db, ct); + + if (includeDemoData) + { + dirty |= await SeedSalesMastersAsync(db, ct); + dirty |= await SeedSalesStockAsync(db, ct); + dirty |= await SeedSalesAsync(db, ct); + } if (dirty) await db.SaveChangesAsync(ct); } @@ -344,9 +356,9 @@ public static class DataSeeder } /// - /// 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. + /// 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.) /// This intentionally never clears or rewrites any existing rows. /// private static async Task SeedSalesAsync(ErpDbContext db, CancellationToken ct) @@ -354,7 +366,6 @@ 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 3b3f451..0b00392 100644 --- a/Backend/ERPCore/Program.cs +++ b/Backend/ERPCore/Program.cs @@ -15,6 +15,7 @@ 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; @@ -23,10 +24,32 @@ using Serilog; var builder = WebApplication.CreateBuilder(args); -// Serilog (file sink) — reads levels from configuration, plus a rolling daily file. +// 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). builder.Host.UseSerilog((ctx, cfg) => cfg .ReadFrom.Configuration(ctx.Configuration) - .WriteTo.File("logs/erpcore-.log", rollingInterval: RollingInterval.Day)); + .WriteTo.File(ctx.Configuration["LogFile:Path"] ?? "logs/erpcore-.log", rollingInterval: RollingInterval.Day)); // Controllers + JSON: serialize enums as their string names (docs/11 §8, camelCase). builder.Services.AddControllers() @@ -49,8 +72,10 @@ builder.Services.AddErpJwtAuth(builder.Configuration); // AuthController proxy → AuthHex (docs/11 §2.0) builder.Services.AddHttpClient(c => { - var baseUrl = builder.Configuration["AuthHex:BaseUrl"] - ?? throw new InvalidOperationException("AuthHex:BaseUrl is not configured."); + // 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)."); c.BaseAddress = new Uri(baseUrl); }); builder.Services.AddScoped(); @@ -60,8 +85,9 @@ 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"] - ?? throw new InvalidOperationException("GeneralLedgerService:BaseUrl is not configured."); + 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)."); c.BaseAddress = new Uri(baseUrl); }); builder.Services.AddScoped(); @@ -205,7 +231,10 @@ using (var scope = app.Services.CreateScope()) await db.Database.MigrateAsync(); try { - await DataSeeder.SeedAsync(db); + // 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); } catch (Exception ex) { @@ -213,9 +242,18 @@ 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(); -if (app.Environment.IsDevelopment()) +// 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()) { app.UseSwagger(); app.UseSwaggerUI(); diff --git a/Backend/ERPCore/appsettings.Development.json.example b/Backend/ERPCore/appsettings.Development.json.example new file mode 100644 index 0000000..8796545 --- /dev/null +++ b/Backend/ERPCore/appsettings.Development.json.example @@ -0,0 +1,21 @@ +{ + "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.json b/Backend/ERPCore/appsettings.json index ed05dc3..6a604fb 100644 --- a/Backend/ERPCore/appsettings.json +++ b/Backend/ERPCore/appsettings.json @@ -6,7 +6,7 @@ } }, "ConnectionStrings": { - "DefaultConnection": "Host=localhost;Port=5432;Database=ERPCore;Username=root;Password=root" + "DefaultConnection": "" }, "Auth": { "Issuer": "AuthHex", @@ -16,15 +16,18 @@ "RequiredRoleCode": "" }, "AuthHex": { - "BaseUrl": "http://localhost:5602" + "BaseUrl": "" }, "FileStorage": { "RootPath": "App_Data/hr-documents", "MaxSizeBytes": 10485760 }, + "LogFile": { + "Path": "logs/erpcore-.log" + }, "GeneralLedgerService": { - "BaseUrl": "https://localhost:7024/api/v1/", - "ApiKey": "1A5FE0E389C029B1FBCD2A94650CEBF0656083BF06FF48E5E11987040ABA262D" + "BaseUrl": "", + "ApiKey": "" }, "Grn": { "GlInventoryAccountCode": "1100", diff --git a/Backend/PROGRESS.md b/Backend/PROGRESS.md index b80f704..af2cbe2 100644 --- a/Backend/PROGRESS.md +++ b/Backend/PROGRESS.md @@ -4,6 +4,34 @@ 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/smoke_common.py b/Backend/smoke/smoke_common.py index f8e5175..1bfcf4c 100644 --- a/Backend/smoke/smoke_common.py +++ b/Backend/smoke/smoke_common.py @@ -38,7 +38,8 @@ DEFAULTS = { "api": "http://localhost:5224/api/v1", "auth": "http://localhost:5602", "user": "admin@gmail.com", - "password": "Naveen@99", + # Deliberately no default: credentials never live in the repo (docs/40-DEPLOYMENT.md §5). + "password": None, } @@ -46,7 +47,10 @@ 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)) - return p.parse_args() + args = p.parse_args() + if not args.password: + p.error("no password given: set ERP_SMOKE_PASSWORD or pass --password") + return args class Response: diff --git a/Frontend/PROGRESS.md b/Frontend/PROGRESS.md index 627db79..6221ac3 100644 --- a/Frontend/PROGRESS.md +++ b/Frontend/PROGRESS.md @@ -345,3 +345,10 @@ 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 new file mode 100644 index 0000000..68c3d5a --- /dev/null +++ b/Frontend/erp-system/.dockerignore @@ -0,0 +1,10 @@ +# `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 new file mode 100644 index 0000000..5faee5d --- /dev/null +++ b/Frontend/erp-system/Dockerfile @@ -0,0 +1,38 @@ +# 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 7ae4efb..82d27a3 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,6 +112,17 @@ 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) @@ -125,15 +136,6 @@ 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 5e4dc0b..f4c1d95 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 9ae997f..61cc014 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 0d22975..4fc0365 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 e78493b..902233c 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 856958a..63bb03d 100644 --- a/Frontend/erp-system/app/login/forgot/reset/page.tsx +++ b/Frontend/erp-system/app/login/forgot/reset/page.tsx @@ -13,6 +13,19 @@ 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("") @@ -28,15 +41,6 @@ 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 (
@@ -97,7 +101,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 05e726d..85885c5 100644 --- a/Frontend/erp-system/eslint.config.mjs +++ b/Frontend/erp-system/eslint.config.mjs @@ -5,6 +5,14 @@ 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 3bdd152..3df14c2 100644 --- a/Frontend/erp-system/next.config.ts +++ b/Frontend/erp-system/next.config.ts @@ -8,9 +8,16 @@ 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 b9d2a2b..20cd2b9 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ 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 @@ -35,7 +36,8 @@ 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 -# set Username/Password in appsettings.Development.json to your local Postgres login +# 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 dotnet ef database update dotnet run ``` @@ -46,15 +48,19 @@ Full setup (packages, folder scaffold, Program.cs wiring) is in `docs/00-CORE.md ## Run the frontend ```bash -cd Frontend -cp .env.local.example .env.local # set NEXT_PUBLIC_API_BASE_URL=https://localhost: +cd Frontend/erp-system +cp .env.local.example .env.local # BACKEND_ORIGIN=http://localhost:5224 (the Next proxy target) npm install npm run dev ``` ## Configuration & secrets -- 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. +- `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`. ## 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 new file mode 100644 index 0000000..0e2deb5 --- /dev/null +++ b/deploy/apache/erp.hexdive.com.conf @@ -0,0 +1,33 @@ +# 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 new file mode 100644 index 0000000..1e39b2d --- /dev/null +++ b/deploy/env/erpcore.env.example @@ -0,0 +1,34 @@ +# /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 new file mode 100644 index 0000000..12dc819 --- /dev/null +++ b/deploy/on-hold/README.md @@ -0,0 +1,34 @@ +# 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 new file mode 100644 index 0000000..25442a2 --- /dev/null +++ b/deploy/on-hold/apache/test-erp.hexdive.com.conf @@ -0,0 +1,35 @@ +# 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 new file mode 100644 index 0000000..25150f8 --- /dev/null +++ b/deploy/on-hold/deploy.two-env.yml @@ -0,0 +1,205 @@ +# 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 new file mode 100644 index 0000000..7ff953a --- /dev/null +++ b/deploy/on-hold/scripts/build-backend.sh @@ -0,0 +1,54 @@ +#!/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 new file mode 100644 index 0000000..d63c824 --- /dev/null +++ b/deploy/on-hold/scripts/build-frontend.sh @@ -0,0 +1,63 @@ +#!/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 new file mode 100644 index 0000000..7faedba --- /dev/null +++ b/deploy/on-hold/scripts/deploy-backend.sh @@ -0,0 +1,91 @@ +#!/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 new file mode 100644 index 0000000..8d8b4b8 --- /dev/null +++ b/deploy/on-hold/scripts/deploy-frontend.sh @@ -0,0 +1,59 @@ +#!/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 new file mode 100644 index 0000000..5cdab3b --- /dev/null +++ b/deploy/on-hold/scripts/detect.sh @@ -0,0 +1,60 @@ +#!/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 new file mode 100644 index 0000000..12c4af9 --- /dev/null +++ b/deploy/on-hold/scripts/e2e.sh @@ -0,0 +1,35 @@ +#!/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 new file mode 100644 index 0000000..70c0e4a --- /dev/null +++ b/deploy/on-hold/scripts/prune.sh @@ -0,0 +1,59 @@ +#!/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 new file mode 100644 index 0000000..d8b2fb3 --- /dev/null +++ b/deploy/on-hold/scripts/release.sh @@ -0,0 +1,59 @@ +#!/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 new file mode 100644 index 0000000..466bf26 --- /dev/null +++ b/deploy/on-hold/systemd/erpcore@.service @@ -0,0 +1,40 @@ +# 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 new file mode 100644 index 0000000..c77ecde --- /dev/null +++ b/deploy/scripts/ci-backend.sh @@ -0,0 +1,33 @@ +#!/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 new file mode 100644 index 0000000..0d3e6ee --- /dev/null +++ b/deploy/scripts/ci-detect.sh @@ -0,0 +1,26 @@ +#!/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 new file mode 100644 index 0000000..0aa0d14 --- /dev/null +++ b/deploy/scripts/common.sh @@ -0,0 +1,131 @@ +#!/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 new file mode 100644 index 0000000..ae44579 --- /dev/null +++ b/deploy/scripts/migration-scan.sh @@ -0,0 +1,53 @@ +#!/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 new file mode 100644 index 0000000..6103de7 --- /dev/null +++ b/deploy/scripts/wait-healthy.sh @@ -0,0 +1,37 @@ +#!/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 new file mode 100644 index 0000000..5f679fa --- /dev/null +++ b/deploy/systemd/erpcore.service @@ -0,0 +1,38 @@ +# 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 0218b00..cb3ab94 100644 --- a/docs/00-CORE.md +++ b/docs/00-CORE.md @@ -247,14 +247,8 @@ Resulting `ERPCore.csproj`: "Jwt": { "SigningKey": "dev-only-signing-key-please-change-me-0123456789" } } ``` -`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. +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). ### 5.6 Minimal `Program.cs` wiring (outline) ```csharp diff --git a/docs/01-DOC-GUIDE.md b/docs/01-DOC-GUIDE.md index 3545ee6..b5b6af0 100644 --- a/docs/01-DOC-GUIDE.md +++ b/docs/01-DOC-GUIDE.md @@ -45,10 +45,11 @@ | `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. Numbers sort in read order in any file browser. +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. > **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 6271795..792163d 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** | **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-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-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 new file mode 100644 index 0000000..8cd8a97 --- /dev/null +++ b/docs/40-DEPLOYMENT.md @@ -0,0 +1,347 @@ +# 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.