Merge pull request 'Dev' (#62) from Dev into production
Reviewed-on: #62
This commit was merged in pull request #62.
This commit is contained in:
@@ -1,10 +0,0 @@
|
|||||||
# Deployment assets execute on the Linux runner / VPS. Force LF no matter which OS commits them:
|
|
||||||
# a CRLF script fails with "/usr/bin/env: 'bash\r': No such file or directory".
|
|
||||||
*.sh text eol=lf
|
|
||||||
*.service text eol=lf
|
|
||||||
*.conf text eol=lf
|
|
||||||
*.yml text eol=lf
|
|
||||||
*.yaml text eol=lf
|
|
||||||
Dockerfile text eol=lf
|
|
||||||
.dockerignore text eol=lf
|
|
||||||
*.env.example text eol=lf
|
|
||||||
@@ -1,509 +0,0 @@
|
|||||||
# CI/CD — ERP Core. One workflow for pull-request checks and the production deploy
|
|
||||||
# (https://erp.hexdive.com). Runbook: docs/40-DEPLOYMENT.md, "Active".
|
|
||||||
#
|
|
||||||
# Which jobs run for which event:
|
|
||||||
# PR opened / updated into Dev or production → checks: detect → backend / frontend / e2e-typecheck /
|
|
||||||
# scripts (only the sides the PR touches) → ci-ok
|
|
||||||
# PR merged into production → build-and-deploy (checks are skipped)
|
|
||||||
# PR merged into Dev, or closed unmerged → nothing (every job skipped)
|
|
||||||
# Run workflow (manual) → build-and-deploy with the deploy_backend / deploy_frontend boxes
|
|
||||||
#
|
|
||||||
# Checks: `ci-ok` is the single required status check for branch protection. It passes when every
|
|
||||||
# check passed or was skipped, and rejects PRs into production that don't come from Dev or hotfix/*.
|
|
||||||
#
|
|
||||||
# Deploy: same shape as the Fitness workflows (publish on the host → stop → copy → fix permissions →
|
|
||||||
# start → verify), plus: only the side whose folder changed in the PR is deployed, the database is
|
|
||||||
# backed up when the PR changes migrations, and the verify steps FAIL the job (no automatic rollback).
|
|
||||||
# Detection only sees the PR being merged: after a failed deploy, fix it and use "Run workflow".
|
|
||||||
# The test environment is on hold (deploy/on-hold/).
|
|
||||||
#
|
|
||||||
# Security model: the dotnet/npm/tsc/eslint check work runs in capped containers without secrets, but
|
|
||||||
# the deploy/scripts/*.sh steps come from the PR checkout and run on the host runner as root, some with
|
|
||||||
# ERPSECRET in their environment. Acceptable only while the repo is private and every PR author is a
|
|
||||||
# trusted team member.
|
|
||||||
name: CI/CD - ERP Core
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- Dev
|
|
||||||
- production
|
|
||||||
types:
|
|
||||||
- opened
|
|
||||||
- synchronize
|
|
||||||
- reopened
|
|
||||||
- closed
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
deploy_backend:
|
|
||||||
description: "Deploy the backend (after changing the config in this file's env: block, 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: erpuser
|
|
||||||
BACKEND_PORT: "5220"
|
|
||||||
DB_NAME: ERPCore
|
|
||||||
DB_USER: erpcore
|
|
||||||
BACKUP_DIR: /var/backups/erpcore
|
|
||||||
WEB_IMAGE: erp-web:latest
|
|
||||||
WEB_CONTAINER: erp-web
|
|
||||||
WEB_PORT: "3020"
|
|
||||||
# ── Backend runtime config ────────────────────────────────────────────────────────────
|
|
||||||
# There is no /etc/erpcore/erpcore.env. These values are the single source of truth: the
|
|
||||||
# "Install unit + write config" step writes them into a systemd drop-in on every backend
|
|
||||||
# deploy. Change a setting here, then merge or use "Run workflow" with deploy_backend.
|
|
||||||
# The two secrets (DB password, GL API key) come from Gitea secrets, never from this file.
|
|
||||||
AUTHHEX_BASE_URL: "http://127.0.0.1:5433"
|
|
||||||
GL_BASE_URL: "http://127.0.0.1:5433/api/v1/"
|
|
||||||
STORAGE_ROOT: /var/lib/erpcore/hr-documents
|
|
||||||
LOG_PATH: /var/log/erpcore/erpcore-.log
|
|
||||||
SEED_DEMO_DATA: "false"
|
|
||||||
# Door policy: blank = any valid AuthHex token is admitted. Set the real AuthHex codes to
|
|
||||||
# restrict ERP to one user type / role (Infra/Auth/JwtAuthExtensions.cs).
|
|
||||||
AUTH_REQUIRED_USER_TYPE: ""
|
|
||||||
AUTH_REQUIRED_ROLE: ""
|
|
||||||
|
|
||||||
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: Install unit + write config
|
|
||||||
if: steps.changes.outputs.backend == 'true'
|
|
||||||
env:
|
|
||||||
DB_PASSWORD: ${{ secrets.ERP_DB_PASSWORD }}
|
|
||||||
GL_API_KEY: ${{ secrets.ERP_GL_API_KEY }}
|
|
||||||
run: |
|
|
||||||
# Config lives in this workflow's env: block, not in a file on the server. It is written
|
|
||||||
# to a systemd drop-in here, before the service is stopped, so a bad value fails the job
|
|
||||||
# while the OLD release is still serving.
|
|
||||||
case "$AUTHHEX_BASE_URL$GL_BASE_URL" in
|
|
||||||
*CHANGE_ME*) echo "❌ AUTHHEX_BASE_URL / GL_BASE_URL still hold CHANGE_ME — set the real ports in ci-cd.yml (run 'ss -ltnp' on the VPS)"; exit 1 ;;
|
|
||||||
esac
|
|
||||||
[ -n "$DB_PASSWORD" ] || { echo "❌ Gitea secret ERP_DB_PASSWORD is not set"; exit 1; }
|
|
||||||
[ -n "$GL_API_KEY" ] || { echo "❌ Gitea secret ERP_GL_API_KEY is not set"; exit 1; }
|
|
||||||
case "$DB_PASSWORD" in
|
|
||||||
*";"*) echo "❌ ERP_DB_PASSWORD contains ';' — Npgsql would truncate the connection string there. Use a password without ';'"; exit 1 ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# "Copy to deploy folder" empties the publish folder, so uploads and logs must live elsewhere.
|
|
||||||
publish="$(realpath -m "$PUBLISH_DIR")"
|
|
||||||
for path in "$STORAGE_ROOT" "$LOG_PATH"; do
|
|
||||||
case "$path" in
|
|
||||||
/*) ;;
|
|
||||||
*) echo "❌ $path is relative — it would resolve inside $PUBLISH_DIR"; exit 1 ;;
|
|
||||||
esac
|
|
||||||
case "$(realpath -m "$path")/" in
|
|
||||||
"$publish"/*) echo "❌ $path is inside $PUBLISH_DIR, which every deploy wipes"; exit 1 ;;
|
|
||||||
esac
|
|
||||||
echo "ok $path"
|
|
||||||
done
|
|
||||||
|
|
||||||
# The unit is installed from the repo, so editing deploy/systemd/erpcore.service and
|
|
||||||
# deploying is enough — no manual copy step on the server.
|
|
||||||
install -m 644 deploy/systemd/erpcore.service /etc/systemd/system/erpcore.service
|
|
||||||
install -d -m 755 /etc/systemd/system/erpcore.service.d
|
|
||||||
conf=/etc/systemd/system/erpcore.service.d/10-erpcore-env.conf
|
|
||||||
# Values are quoted: systemd would otherwise split a password containing spaces.
|
|
||||||
umask 077
|
|
||||||
# Only the listening URL. Everything else is app configuration and lands in
|
|
||||||
# appsettings.Production.json (written after the copy step) — ASPNETCORE_URLS is host
|
|
||||||
# configuration, the one setting appsettings cannot reliably provide.
|
|
||||||
cat > "$conf" <<EOF
|
|
||||||
[Service]
|
|
||||||
Environment="ASPNETCORE_URLS=http://127.0.0.1:$BACKEND_PORT"
|
|
||||||
EOF
|
|
||||||
chmod 600 "$conf"
|
|
||||||
systemctl daemon-reload
|
|
||||||
# Idempotent: creates the multi-user.target.wants symlink so the app survives a reboot.
|
|
||||||
systemctl enable "$SERVICE"
|
|
||||||
echo "✅ unit + drop-in installed ($(grep -c '^Environment=' "$conf") settings; values not printed)"
|
|
||||||
|
|
||||||
# Data dirs must exist and belong to the service user (Serilog fails silently otherwise).
|
|
||||||
install -d -m 750 -o "$SERVICE_USER" -g "$SERVICE_USER" "$STORAGE_ROOT" "$(dirname "$LOG_PATH")"
|
|
||||||
|
|
||||||
- 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"
|
|
||||||
|
|
||||||
# Written AFTER "Fix ownership" so it keeps mode 640 (root:erpuser) instead of being
|
|
||||||
# flattened to 644 — it holds the DB password and the GL API key.
|
|
||||||
- name: Write appsettings.Production.json
|
|
||||||
if: steps.changes.outputs.backend == 'true'
|
|
||||||
env:
|
|
||||||
DB_PASSWORD: ${{ secrets.ERP_DB_PASSWORD }}
|
|
||||||
GL_API_KEY: ${{ secrets.ERP_GL_API_KEY }}
|
|
||||||
run: |
|
|
||||||
# Deployed config lives in appsettings.Production.json, generated here from this
|
|
||||||
# workflow's env: block + the Gitea secrets. It is NOT in git (.gitignore ignores
|
|
||||||
# appsettings.*.json) and is rewritten on every backend deploy, which is why the
|
|
||||||
# publish folder being wiped does not matter. It MERGES over appsettings.json, so
|
|
||||||
# Auth:RsaPublicKeyXml, Issuer, Audience and FileStorage:MaxSizeBytes still come
|
|
||||||
# from the committed base file.
|
|
||||||
conf="$PUBLISH_DIR/appsettings.Production.json"
|
|
||||||
# python3 emits the JSON so a password containing " or \ is escaped correctly.
|
|
||||||
python3 - > "$conf" <<'PY'
|
|
||||||
import json, os
|
|
||||||
e = os.environ
|
|
||||||
print(json.dumps({
|
|
||||||
"ConnectionStrings": {
|
|
||||||
"DefaultConnection":
|
|
||||||
"Host=127.0.0.1;Port=5432;Database=%s;Username=%s;Password=%s"
|
|
||||||
% (e["DB_NAME"], e["DB_USER"], e["DB_PASSWORD"]),
|
|
||||||
},
|
|
||||||
"AuthHex": {"BaseUrl": e["AUTHHEX_BASE_URL"]},
|
|
||||||
"GeneralLedgerService": {"BaseUrl": e["GL_BASE_URL"], "ApiKey": e["GL_API_KEY"]},
|
|
||||||
"Auth": {
|
|
||||||
"RequiredUserTypeCode": e["AUTH_REQUIRED_USER_TYPE"],
|
|
||||||
"RequiredRoleCode": e["AUTH_REQUIRED_ROLE"],
|
|
||||||
},
|
|
||||||
"FileStorage": {"RootPath": e["STORAGE_ROOT"]},
|
|
||||||
"LogFile": {"Path": e["LOG_PATH"]},
|
|
||||||
"Seed": {"DemoData": e["SEED_DEMO_DATA"] == "true"},
|
|
||||||
}, indent=2))
|
|
||||||
PY
|
|
||||||
chown "root:$SERVICE_USER" "$conf"
|
|
||||||
chmod 640 "$conf"
|
|
||||||
python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$conf"
|
|
||||||
echo "✅ appsettings.Production.json written ($(wc -c < "$conf") bytes, mode 640, values not printed)"
|
|
||||||
|
|
||||||
- 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
|
|
||||||
@@ -36,8 +36,4 @@ Testing/e2e/test-results/
|
|||||||
Testing/e2e/.auth/
|
Testing/e2e/.auth/
|
||||||
Testing/e2e/blob-report/
|
Testing/e2e/blob-report/
|
||||||
|
|
||||||
# ── Python (Backend/smoke) ────────────────────────────────────────────
|
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ internal sealed class TestCurrentUser : ICurrentUser
|
|||||||
public string UserId => "system";
|
public string UserId => "system";
|
||||||
public int AuditUserId => User.SystemUserId;
|
public int AuditUserId => User.SystemUserId;
|
||||||
public bool IsAuthenticated => false;
|
public bool IsAuthenticated => false;
|
||||||
public string? RoleCode => null;
|
|
||||||
public bool IsAdmin => false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed class ThrowingUnitOfWork : ERPCore.Infra.UoW.IUnitOfWork
|
internal sealed class ThrowingUnitOfWork : ERPCore.Infra.UoW.IUnitOfWork
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ VisualStudioVersion = 17.0.31903.59
|
|||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ERPCore", "ERPCore\ERPCore.csproj", "{B38203ED-80EA-41C6-8B7D-B664451299E3}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ERPCore", "ERPCore\ERPCore.csproj", "{B38203ED-80EA-41C6-8B7D-B664451299E3}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ERPCore.Tests", "ERPCore.Tests\ERPCore.Tests.csproj", "{E451472F-550F-40A0-AE82-E24ECD6C647A}"
|
|
||||||
EndProject
|
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -29,18 +27,6 @@ Global
|
|||||||
{B38203ED-80EA-41C6-8B7D-B664451299E3}.Release|x64.Build.0 = Release|Any CPU
|
{B38203ED-80EA-41C6-8B7D-B664451299E3}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{B38203ED-80EA-41C6-8B7D-B664451299E3}.Release|x86.ActiveCfg = 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
|
{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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
|||||||
@@ -18,11 +18,6 @@ public sealed class GeneralLedgerClient(HttpClient http, IConfiguration configur
|
|||||||
public async Task<GeneralLedgerResponse> SendAsync(
|
public async Task<GeneralLedgerResponse> SendAsync(
|
||||||
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct)
|
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;
|
var relativeUri = path.TrimStart('/') + queryString;
|
||||||
using var request = new HttpRequestMessage(method, relativeUri);
|
using var request = new HttpRequestMessage(method, relativeUri);
|
||||||
request.Headers.TryAddWithoutValidation("X-Api-Key", _apiKey);
|
request.Headers.TryAddWithoutValidation("X-Api-Key", _apiKey);
|
||||||
|
|||||||
@@ -39,29 +39,17 @@ public static class DataSeeder
|
|||||||
("PRD-REWORK-LOSS", "Production Rework Loss", ReasonContext.Production),
|
("PRD-REWORK-LOSS", "Production Rework Loss", ReasonContext.Production),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// <param name="includeDemoData">
|
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 (<c>Seed:DemoData</c>). 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).
|
|
||||||
/// </param>
|
|
||||||
public static async Task SeedAsync(ErpDbContext db, bool includeDemoData, CancellationToken ct = default)
|
|
||||||
{
|
{
|
||||||
// Reference data — every environment.
|
|
||||||
var dirty = await SeedReasonCodesAsync(db, ct);
|
var dirty = await SeedReasonCodesAsync(db, ct);
|
||||||
dirty |= await SeedItemTypesAsync(db, ct);
|
dirty |= await SeedItemTypesAsync(db, ct);
|
||||||
// dirty |= await SeedCompanyProfileAsync(db, ct);
|
// dirty |= await SeedCompanyProfileAsync(db, ct);
|
||||||
dirty |= await SeedProductConfigAsync(db, ct);
|
dirty |= await SeedProductConfigAsync(db, ct);
|
||||||
dirty |= await SeedBranchConfigAsync(db, ct);
|
dirty |= await SeedBranchConfigAsync(db, ct);
|
||||||
dirty |= await SeedNotificationConfigAsync(db, ct);
|
dirty |= await SeedNotificationConfigAsync(db, ct);
|
||||||
dirty |= await SeedSalesSequencesAsync(db, ct);
|
dirty |= await SeedSalesMastersAsync(db, ct);
|
||||||
|
dirty |= await SeedSalesStockAsync(db, ct);
|
||||||
if (includeDemoData)
|
dirty |= await SeedSalesAsync(db, ct);
|
||||||
{
|
|
||||||
dirty |= await SeedSalesMastersAsync(db, ct);
|
|
||||||
dirty |= await SeedSalesStockAsync(db, ct);
|
|
||||||
dirty |= await SeedSalesAsync(db, ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dirty) await db.SaveChangesAsync(ct);
|
if (dirty) await db.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
@@ -356,9 +344,9 @@ public static class DataSeeder
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Seeds the demo sales data used for UI/backend development: a couple of customer
|
/// Seeds the minimum sales bootstrap data needed for UI/backend development:
|
||||||
/// rows and a few draft invoice/slip samples when the required master data already
|
/// a couple of customer rows, current-year document counters, and a few draft
|
||||||
/// exists. (Current-year document counters are reference data, seeded in SeedAsync.)
|
/// invoice/slip samples when the required master data already exists.
|
||||||
/// This intentionally never clears or rewrites any existing rows.
|
/// This intentionally never clears or rewrites any existing rows.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static async Task<bool> SeedSalesAsync(ErpDbContext db, CancellationToken ct)
|
private static async Task<bool> SeedSalesAsync(ErpDbContext db, CancellationToken ct)
|
||||||
@@ -366,6 +354,7 @@ public static class DataSeeder
|
|||||||
var dirty = false;
|
var dirty = false;
|
||||||
|
|
||||||
dirty |= await SeedSalesCustomersAsync(db, ct);
|
dirty |= await SeedSalesCustomersAsync(db, ct);
|
||||||
|
dirty |= await SeedSalesSequencesAsync(db, ct);
|
||||||
dirty |= await SeedSampleSalesDocsAsync(db, ct);
|
dirty |= await SeedSampleSalesDocsAsync(db, ct);
|
||||||
|
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ using ERPCore.Services.Production;
|
|||||||
using ERPCore.Services.Stock;
|
using ERPCore.Services.Stock;
|
||||||
using ERPCore.System.Errors;
|
using ERPCore.System.Errors;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.HttpOverrides;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
using Microsoft.OpenApi;
|
using Microsoft.OpenApi;
|
||||||
@@ -24,32 +23,10 @@ using Serilog;
|
|||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
// Fail fast on missing deployment config. Outside Development these arrive as environment
|
// Serilog (file sink) — reads levels from configuration, plus a rolling daily file.
|
||||||
// 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
|
builder.Host.UseSerilog((ctx, cfg) => cfg
|
||||||
.ReadFrom.Configuration(ctx.Configuration)
|
.ReadFrom.Configuration(ctx.Configuration)
|
||||||
.WriteTo.File(ctx.Configuration["LogFile:Path"] ?? "logs/erpcore-.log", rollingInterval: RollingInterval.Day));
|
.WriteTo.File("logs/erpcore-.log", rollingInterval: RollingInterval.Day));
|
||||||
|
|
||||||
// Controllers + JSON: serialize enums as their string names (docs/11 §8, camelCase).
|
// Controllers + JSON: serialize enums as their string names (docs/11 §8, camelCase).
|
||||||
builder.Services.AddControllers()
|
builder.Services.AddControllers()
|
||||||
@@ -72,10 +49,8 @@ builder.Services.AddErpJwtAuth(builder.Configuration);
|
|||||||
// AuthController proxy → AuthHex (docs/11 §2.0)
|
// AuthController proxy → AuthHex (docs/11 §2.0)
|
||||||
builder.Services.AddHttpClient<IAuthHexClient, AuthHexClient>(c =>
|
builder.Services.AddHttpClient<IAuthHexClient, AuthHexClient>(c =>
|
||||||
{
|
{
|
||||||
// Blank counts as missing: the base appsettings.json ships these keys empty on purpose.
|
var baseUrl = builder.Configuration["AuthHex:BaseUrl"]
|
||||||
var baseUrl = builder.Configuration["AuthHex:BaseUrl"];
|
?? throw new InvalidOperationException("AuthHex:BaseUrl is not configured.");
|
||||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
|
||||||
throw new InvalidOperationException("AuthHex:BaseUrl is not configured (set it in appsettings.Development.json locally).");
|
|
||||||
c.BaseAddress = new Uri(baseUrl);
|
c.BaseAddress = new Uri(baseUrl);
|
||||||
});
|
});
|
||||||
builder.Services.AddScoped<IAuthUserService, AuthUserService>();
|
builder.Services.AddScoped<IAuthUserService, AuthUserService>();
|
||||||
@@ -85,9 +60,8 @@ builder.Services.AddScoped<IAuthAltService, AuthAltService>();
|
|||||||
// General Ledger service proxy → external GL microservice (docs/12-GENERAL-LEDGER-INTEGRATION.md)
|
// General Ledger service proxy → external GL microservice (docs/12-GENERAL-LEDGER-INTEGRATION.md)
|
||||||
builder.Services.AddHttpClient<IGeneralLedgerClient, GeneralLedgerClient>(c =>
|
builder.Services.AddHttpClient<IGeneralLedgerClient, GeneralLedgerClient>(c =>
|
||||||
{
|
{
|
||||||
var baseUrl = builder.Configuration["GeneralLedgerService:BaseUrl"];
|
var baseUrl = builder.Configuration["GeneralLedgerService:BaseUrl"]
|
||||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
?? throw new InvalidOperationException("GeneralLedgerService:BaseUrl is not configured.");
|
||||||
throw new InvalidOperationException("GeneralLedgerService:BaseUrl is not configured (set it in appsettings.Development.json locally).");
|
|
||||||
c.BaseAddress = new Uri(baseUrl);
|
c.BaseAddress = new Uri(baseUrl);
|
||||||
});
|
});
|
||||||
builder.Services.AddScoped<IGeneralLedgerService, GeneralLedgerService>();
|
builder.Services.AddScoped<IGeneralLedgerService, GeneralLedgerService>();
|
||||||
@@ -231,10 +205,7 @@ using (var scope = app.Services.CreateScope())
|
|||||||
await db.Database.MigrateAsync();
|
await db.Database.MigrateAsync();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Demo rows (sample catalog, stock, customers, sales docs) default on only in Development;
|
await DataSeeder.SeedAsync(db);
|
||||||
// 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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -242,18 +213,9 @@ using (var scope = app.Services.CreateScope())
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Behind the co-located Apache reverse proxy (docs/40-DEPLOYMENT.md): take the client IP and
|
|
||||||
// scheme from X-Forwarded-*. Only loopback proxies are trusted by default, which is exactly it.
|
|
||||||
app.UseForwardedHeaders(new ForwardedHeadersOptions
|
|
||||||
{
|
|
||||||
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
|
|
||||||
});
|
|
||||||
|
|
||||||
app.UseSerilogRequestLogging();
|
app.UseSerilogRequestLogging();
|
||||||
|
|
||||||
// Off in Production only. The test deployment (Staging) keeps it; it is still not public there
|
if (app.Environment.IsDevelopment())
|
||||||
// because Apache proxies only /api/ — reach it through an SSH tunnel to the backend port.
|
|
||||||
if (!app.Environment.IsProduction())
|
|
||||||
{
|
{
|
||||||
app.UseSwagger();
|
app.UseSwagger();
|
||||||
app.UseSwaggerUI();
|
app.UseSwaggerUI();
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"DefaultConnection": "Host=127.0.0.1;Port=5433;Database=ERPCoreTest;Username=postgres;Password=post@hexdive"
|
||||||
|
},
|
||||||
|
"AuthHex": {
|
||||||
|
"BaseUrl": "http://localhost:5011"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"Logging": {
|
|
||||||
"LogLevel": {
|
|
||||||
"Default": "Information",
|
|
||||||
"Microsoft.AspNetCore": "Warning"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"ConnectionStrings": {
|
|
||||||
"DefaultConnection": "Host=127.0.0.1;Port=5432;Database=ERPCore;Username=postgres;Password=CHANGE_ME"
|
|
||||||
},
|
|
||||||
"AuthHex": {
|
|
||||||
"BaseUrl": "http://localhost:5011"
|
|
||||||
},
|
|
||||||
"GeneralLedgerService": {
|
|
||||||
"BaseUrl": "https://localhost:7024/api/v1/",
|
|
||||||
"ApiKey": "ask-the-team-for-the-dev-key"
|
|
||||||
},
|
|
||||||
"Seed": {
|
|
||||||
"DemoData": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"DefaultConnection": ""
|
||||||
|
},
|
||||||
|
"Jwt": {
|
||||||
|
"SigningKey": ""
|
||||||
|
},
|
||||||
|
"AuthHex": {
|
||||||
|
"BaseUrl": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"DefaultConnection": ""
|
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCore;Username=root;Password=root"
|
||||||
},
|
},
|
||||||
"Auth": {
|
"Auth": {
|
||||||
"Issuer": "AuthHex",
|
"Issuer": "AuthHex",
|
||||||
@@ -16,18 +16,15 @@
|
|||||||
"RequiredRoleCode": ""
|
"RequiredRoleCode": ""
|
||||||
},
|
},
|
||||||
"AuthHex": {
|
"AuthHex": {
|
||||||
"BaseUrl": ""
|
"BaseUrl": "http://localhost:5602"
|
||||||
},
|
},
|
||||||
"FileStorage": {
|
"FileStorage": {
|
||||||
"RootPath": "App_Data/hr-documents",
|
"RootPath": "App_Data/hr-documents",
|
||||||
"MaxSizeBytes": 10485760
|
"MaxSizeBytes": 10485760
|
||||||
},
|
},
|
||||||
"LogFile": {
|
|
||||||
"Path": "logs/erpcore-.log"
|
|
||||||
},
|
|
||||||
"GeneralLedgerService": {
|
"GeneralLedgerService": {
|
||||||
"BaseUrl": "",
|
"BaseUrl": "https://localhost:7024/api/v1/",
|
||||||
"ApiKey": ""
|
"ApiKey": "1A5FE0E389C029B1FBCD2A94650CEBF0656083BF06FF48E5E11987040ABA262D"
|
||||||
},
|
},
|
||||||
"Grn": {
|
"Grn": {
|
||||||
"GlInventoryAccountCode": "1100",
|
"GlInventoryAccountCode": "1100",
|
||||||
|
|||||||
@@ -4,34 +4,6 @@ Legend: `[ ]` not started · `[~]` in progress · `[x]` done
|
|||||||
Spec: `docs/10-BACKEND-PHASE1.md` (model + rules) · `docs/11-BACKEND-PHASE1.md` (API)
|
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.
|
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
|
## 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
|
The UOM re-model put `ContentQty`/`ContentUnit` on `Item` correctly, but the item **create page
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -38,8 +38,7 @@ DEFAULTS = {
|
|||||||
"api": "http://localhost:5224/api/v1",
|
"api": "http://localhost:5224/api/v1",
|
||||||
"auth": "http://localhost:5602",
|
"auth": "http://localhost:5602",
|
||||||
"user": "admin@gmail.com",
|
"user": "admin@gmail.com",
|
||||||
# Deliberately no default: credentials never live in the repo (docs/40-DEPLOYMENT.md §5).
|
"password": "Naveen@99",
|
||||||
"password": None,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -47,10 +46,7 @@ def parse_args(description: str) -> argparse.Namespace:
|
|||||||
p = argparse.ArgumentParser(description=description)
|
p = argparse.ArgumentParser(description=description)
|
||||||
for key, default in DEFAULTS.items():
|
for key, default in DEFAULTS.items():
|
||||||
p.add_argument(f"--{key}", default=os.environ.get(f"ERP_SMOKE_{key.upper()}", default))
|
p.add_argument(f"--{key}", default=os.environ.get(f"ERP_SMOKE_{key.upper()}", default))
|
||||||
args = p.parse_args()
|
return p.parse_args()
|
||||||
if not args.password:
|
|
||||||
p.error("no password given: set ERP_SMOKE_PASSWORD or pass --password")
|
|
||||||
return args
|
|
||||||
|
|
||||||
|
|
||||||
class Response:
|
class Response:
|
||||||
|
|||||||
@@ -345,10 +345,3 @@ Spec: `docs/21-FRONTEND-PHASE2.md` (flows/screens) · contract: `docs/30-BACKEND
|
|||||||
5. **Inline "add variant category":** a "+" icon button next to the checkboxes opens an inline name field that calls `variantCategoriesApi.create()` directly from `/new`, appends the result to the in-memory list, and auto-checks it — so a brand-new dimension (e.g. "Material") can be added without leaving the Item form, and it also then appears on `/dashboard/products/variants`.
|
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 `<input type="color">` 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 `"<name>|<hex>"` 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`.
|
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 `<input type="color">` 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 `"<name>|<hex>"` 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.
|
- 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.
|
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
# `next build` runs inside the image (see Dockerfile), so local build output and dependencies must
|
|
||||||
# not be sent as build context: they are large, and a Windows node_modules would break the Linux build.
|
|
||||||
node_modules
|
|
||||||
.next
|
|
||||||
.git
|
|
||||||
.env*
|
|
||||||
*.tsbuildinfo
|
|
||||||
npm-debug.log*
|
|
||||||
Dockerfile
|
|
||||||
.dockerignore
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# ERP frontend image — built on the server by .gitea/workflows/ci-cd.yml (`docker build`), using the
|
|
||||||
# same pattern as Client_New_Fe. There are no build args: the app only calls same-origin /api/v1 and
|
|
||||||
# Apache routes /api/ straight to ERPCore (docs/40-DEPLOYMENT.md, "Active").
|
|
||||||
|
|
||||||
FROM node:24-alpine AS deps
|
|
||||||
WORKDIR /app
|
|
||||||
RUN apk add --no-cache libc6-compat
|
|
||||||
COPY package.json package-lock.json ./
|
|
||||||
RUN npm ci --no-audit --no-fund
|
|
||||||
|
|
||||||
FROM node:24-alpine AS builder
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
|
||||||
COPY . .
|
|
||||||
# Shared 8 GB VPS: keep the build's heap bounded (docker build has no --memory under BuildKit).
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1 \
|
|
||||||
NODE_ENV=production \
|
|
||||||
NODE_OPTIONS=--max-old-space-size=2048
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
FROM node:24-alpine AS runner
|
|
||||||
WORKDIR /app
|
|
||||||
ENV NODE_ENV=production \
|
|
||||||
NEXT_TELEMETRY_DISABLED=1 \
|
|
||||||
PORT=3000 \
|
|
||||||
HOSTNAME=0.0.0.0
|
|
||||||
|
|
||||||
RUN addgroup --system --gid 1001 nodejs \
|
|
||||||
&& adduser --system --uid 1001 nextjs
|
|
||||||
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
|
||||||
|
|
||||||
USER nextjs
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
CMD ["node", "server.js"]
|
|
||||||
@@ -112,17 +112,6 @@ function NewPurchaseInvoiceContent() {
|
|||||||
[vendors]
|
[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) {
|
function toggleGrn(grnId: number, checked: boolean) {
|
||||||
setSelectedGrnIds((prev) => {
|
setSelectedGrnIds((prev) => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
@@ -136,6 +125,15 @@ function NewPurchaseInvoiceContent() {
|
|||||||
setSelectedGrnIds(checked ? new Set(visibleGrns.map((g) => g.grnId)) : new Set())
|
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(
|
const selectedGrns = useMemo(
|
||||||
() => (eligibleGrns ?? []).filter((g) => selectedGrnIds.has(g.grnId)),
|
() => (eligibleGrns ?? []).filter((g) => selectedGrnIds.has(g.grnId)),
|
||||||
[eligibleGrns, selectedGrnIds]
|
[eligibleGrns, selectedGrnIds]
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export default function SalesDayEndPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-foreground">Day End</h1>
|
<h1 className="text-2xl font-bold text-foreground">Day End</h1>
|
||||||
<p className="text-base text-muted-foreground">
|
<p className="text-base text-muted-foreground">
|
||||||
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.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -149,14 +149,14 @@ export default function InvoiceTemplatePage() {
|
|||||||
<Switch checked={showBankDetails} onCheckedChange={setShowBankDetails} className="mt-1 shrink-0" aria-label="Show bank details" />
|
<Switch checked={showBankDetails} onCheckedChange={setShowBankDetails} className="mt-1 shrink-0" aria-label="Show bank details" />
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-base font-medium text-foreground">Show bank details</span>
|
<span className="text-base font-medium text-foreground">Show bank details</span>
|
||||||
<span className="text-sm text-muted-foreground">Prints the company's bank name, account and SWIFT code on invoices.</span>
|
<span className="text-sm text-muted-foreground">Prints the company's bank name, account and SWIFT code on invoices.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start gap-4 py-4">
|
<div className="flex items-start gap-4 py-4">
|
||||||
<Switch checked={showTaxRegNo} onCheckedChange={setShowTaxRegNo} className="mt-1 shrink-0" aria-label="Show tax registration no." />
|
<Switch checked={showTaxRegNo} onCheckedChange={setShowTaxRegNo} className="mt-1 shrink-0" aria-label="Show tax registration no." />
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-base font-medium text-foreground">Show tax registration no.</span>
|
<span className="text-base font-medium text-foreground">Show tax registration no.</span>
|
||||||
<span className="text-sm text-muted-foreground">Prints the company's tax registration number in the invoice header.</span>
|
<span className="text-sm text-muted-foreground">Prints the company's tax registration number in the invoice header.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export default function ForgotOtpPage() {
|
|||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold text-foreground text-center">Verify your email</h1>
|
<h1 className="text-2xl font-bold text-foreground text-center">Verify your email</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground text-center mb-8">We've sent a verification code to your email address. Enter it below to continue.</p>
|
<p className="text-sm text-muted-foreground text-center mb-8">We've sent a verification code to your email address. Enter it below to continue.</p>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
@@ -48,7 +48,7 @@ export default function ForgotOtpPage() {
|
|||||||
}}
|
}}
|
||||||
className="text-sm font-medium text-foreground hover:text-foreground/80 transition-colors"
|
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
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">Resend code available in <span className="font-semibold text-foreground">{Math.max(0, secondsLeft)}s</span></p>
|
<p className="text-sm text-muted-foreground">Resend code available in <span className="font-semibold text-foreground">{Math.max(0, secondsLeft)}s</span></p>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export default function ForgotEmailPage() {
|
|||||||
<div className="w-full max-w-2xl rounded-xl bg-card p-8 pl-10 shadow-lg border border-border">
|
<div className="w-full max-w-2xl rounded-xl bg-card p-8 pl-10 shadow-lg border border-border">
|
||||||
<div className="mb-8 text-center">
|
<div className="mb-8 text-center">
|
||||||
<h1 className="text-2xl font-bold text-foreground">Reset your password</h1>
|
<h1 className="text-2xl font-bold text-foreground">Reset your password</h1>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">Enter the email address associated with your account and we'll send you a one-time code to verify your identity.</p>
|
<p className="mt-2 text-sm text-muted-foreground">Enter the email address associated with your account and we'll send you a one-time code to verify your identity.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|||||||
@@ -13,19 +13,6 @@ import {
|
|||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import { Check, Eye, EyeOff } from "lucide-react"
|
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 (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className={`flex items-center justify-center w-5 h-5 rounded-full shrink-0 text-sm font-semibold ${met ? 'bg-success/10 text-success' : 'bg-muted text-muted-foreground'}`}>
|
|
||||||
{met ? <Check className="size-4" /> : <span>○</span>}
|
|
||||||
</div>
|
|
||||||
<span className={`text-sm ${met ? 'text-foreground font-medium' : 'text-muted-foreground'}`}>{label}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ForgotResetPage() {
|
export default function ForgotResetPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [password, setPassword] = useState("")
|
const [password, setPassword] = useState("")
|
||||||
@@ -41,6 +28,15 @@ export default function ForgotResetPage() {
|
|||||||
const passwordsMatch = password === confirm && password.length > 0
|
const passwordsMatch = password === confirm && password.length > 0
|
||||||
const allValid = meetsLength && hasUpper && hasNumber && hasSpecial && passwordsMatch
|
const allValid = meetsLength && hasUpper && hasNumber && hasSpecial && passwordsMatch
|
||||||
|
|
||||||
|
const PasswordRequirement = ({ met, label }: { met: boolean; label: string }) => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`flex items-center justify-center w-5 h-5 rounded-full shrink-0 text-sm font-semibold ${met ? 'bg-success/10 text-success' : 'bg-muted text-muted-foreground'}`}>
|
||||||
|
{met ? <Check className="size-4" /> : <span>○</span>}
|
||||||
|
</div>
|
||||||
|
<span className={`text-sm ${met ? 'text-foreground font-medium' : 'text-muted-foreground'}`}>{label}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-background/95 p-4">
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-background/95 p-4">
|
||||||
<div className="w-full max-w-xl rounded-xl bg-card p-8 shadow-lg border border-border">
|
<div className="w-full max-w-xl rounded-xl bg-card p-8 shadow-lg border border-border">
|
||||||
@@ -101,7 +97,7 @@ export default function ForgotResetPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!passwordsMatch && confirm.length > 0 && (
|
{!passwordsMatch && confirm.length > 0 && (
|
||||||
<p className="text-sm text-error font-medium">Passwords don't match.</p>
|
<p className="text-sm text-error font-medium">Passwords don't match.</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
|
|||||||
@@ -5,14 +5,6 @@ import nextTs from "eslint-config-next/typescript";
|
|||||||
const eslintConfig = defineConfig([
|
const eslintConfig = defineConfig([
|
||||||
...nextVitals,
|
...nextVitals,
|
||||||
...nextTs,
|
...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.
|
// Override default ignores of eslint-config-next.
|
||||||
globalIgnores([
|
globalIgnores([
|
||||||
// Default ignores of eslint-config-next:
|
// Default ignores of eslint-config-next:
|
||||||
|
|||||||
@@ -8,16 +8,9 @@ import path from "path"
|
|||||||
//
|
//
|
||||||
// Defaults to the backend's HTTP port: the HTTPS port uses a self-signed dev cert that
|
// Defaults to the backend's HTTP port: the HTTPS port uses a self-signed dev cert that
|
||||||
// this proxy would reject.
|
// 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 BACKEND_ORIGIN = process.env.BACKEND_ORIGIN ?? "http://localhost:5224"
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
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: {
|
turbopack: {
|
||||||
root: path.join(__dirname),
|
root: path.join(__dirname),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ erp-monorepo/
|
|||||||
| `docs/10-BACKEND-PHASE1.md` | Backend spec: SRS + ER model + architecture |
|
| `docs/10-BACKEND-PHASE1.md` | Backend spec: SRS + ER model + architecture |
|
||||||
| `docs/11-BACKEND-PHASE1.md` | Backend API reference (complete req/res) |
|
| `docs/11-BACKEND-PHASE1.md` | Backend API reference (complete req/res) |
|
||||||
| `docs/20-FRONTEND.md` | Frontend user-flows + rules + validation posture |
|
| `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) |
|
| `Backend/PROGRESS.md`, `Frontend/PROGRESS.md` | Change checklists (git-shared) |
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
@@ -36,8 +35,7 @@ erp-monorepo/
|
|||||||
# create the database in your local Postgres first, e.g.:
|
# create the database in your local Postgres first, e.g.:
|
||||||
# createdb -U postgres ERPCore (or: CREATE DATABASE "ERPCore"; in psql)
|
# createdb -U postgres ERPCore (or: CREATE DATABASE "ERPCore"; in psql)
|
||||||
cd Backend/ERPCore
|
cd Backend/ERPCore
|
||||||
# local settings are git-ignored: copy the template, then fill in your Postgres login and the GL key
|
# set Username/Password in appsettings.Development.json to your local Postgres login
|
||||||
cp appsettings.Development.json.example appsettings.Development.json
|
|
||||||
dotnet ef database update
|
dotnet ef database update
|
||||||
dotnet run
|
dotnet run
|
||||||
```
|
```
|
||||||
@@ -48,19 +46,15 @@ Full setup (packages, folder scaffold, Program.cs wiring) is in `docs/00-CORE.md
|
|||||||
|
|
||||||
## Run the frontend
|
## Run the frontend
|
||||||
```bash
|
```bash
|
||||||
cd Frontend/erp-system
|
cd Frontend
|
||||||
cp .env.local.example .env.local # BACKEND_ORIGIN=http://localhost:5224 (the Next proxy target)
|
cp .env.local.example .env.local # set NEXT_PUBLIC_API_BASE_URL=https://localhost:<port>
|
||||||
npm install
|
npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration & secrets
|
## Configuration & secrets
|
||||||
- `appsettings.json` is committed and holds **no secrets**; environment-specific values are blank there.
|
- Three appsettings files: base (`appsettings.json`, placeholders only), `Development` (localhost), `Production` (env vars).
|
||||||
- Local: `appsettings.Development.json` (git-ignored, from the `.example`). Deployed (production): set in the `env:` block of `.gitea/workflows/ci-cd.yml`; the deploy generates `appsettings.Production.json` from it on each run, with the DB password and GL API key coming from Gitea secrets — outside Development the API refuses to start when a required key is missing.
|
- **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.
|
||||||
- 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
|
## 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.
|
**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.
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
# ERP-Core RELEASE environment (deployed from the production branch) — docs/40-DEPLOYMENT.md §4.
|
|
||||||
#
|
|
||||||
# Install: cp deploy/apache/erp.hexdive.com.conf /etc/httpd/conf.d/
|
|
||||||
# apachectl configtest && systemctl reload httpd
|
|
||||||
# certbot --apache -d erp.hexdive.com
|
|
||||||
# certbot copies this vhost to :443 with the certificate and turns :80 into a redirect to https.
|
|
||||||
# The session cookies are Secure + SameSite=Strict, so login only works over HTTPS.
|
|
||||||
# Needs mod_proxy, mod_proxy_http, mod_headers, mod_ssl and SELinux httpd_can_network_connect=on.
|
|
||||||
|
|
||||||
<VirtualHost *:80>
|
|
||||||
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
|
|
||||||
</VirtualHost>
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# On hold — test environment + two-environment pipeline
|
|
||||||
|
|
||||||
Only `erp.hexdive.com` exists today, so since 2026-09-15 production is deployed Fitness-style by the
|
|
||||||
`Build & Deploy` job in `.gitea/workflows/ci-cd.yml`, the one workflow that also runs the PR checks
|
|
||||||
(docs/40-DEPLOYMENT.md, "Active"). Everything in this folder is the
|
|
||||||
**test + release** design built on 2026-09-14, kept intact but **inactive**: Gitea only runs workflows
|
|
||||||
from `.gitea/workflows/`, and nothing active calls these scripts.
|
|
||||||
|
|
||||||
| File | What it did |
|
|
||||||
|---|---|
|
|
||||||
| `deploy.two-env.yml` | The two-environment workflow: push to `Dev` → test, push to `production` → release; tree-hash change detection against the last successful deploy; nightly Playwright against test; Gitea release per production deploy |
|
|
||||||
| `scripts/detect.sh` | Decided which side to deploy by comparing tree hashes with `/opt/erpcore/<env>/.deployed` |
|
|
||||||
| `scripts/build-backend.sh`, `scripts/build-frontend.sh` | Capped container builds into versioned `/opt/erpcore/releases/backend/<hash>` and `erp-web:<hash>`, 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).
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
# ERP-Core TEST environment (deployed from the Dev branch) — docs/40-DEPLOYMENT.md §4.
|
|
||||||
#
|
|
||||||
# Install: cp deploy/apache/test-erp.hexdive.com.conf /etc/httpd/conf.d/
|
|
||||||
# apachectl configtest && systemctl reload httpd
|
|
||||||
# certbot --apache -d test-erp.hexdive.com
|
|
||||||
# certbot copies this vhost to :443 with the certificate and turns :80 into a redirect to https.
|
|
||||||
# The session cookies are Secure + SameSite=Strict, so login only works over HTTPS.
|
|
||||||
# Needs mod_proxy, mod_proxy_http, mod_headers, mod_ssl and SELinux httpd_can_network_connect=on.
|
|
||||||
|
|
||||||
<VirtualHost *:80>
|
|
||||||
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
|
|
||||||
</VirtualHost>
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
# Deployments — one workflow for both environments (docs/40-DEPLOYMENT.md §6):
|
|
||||||
# push to Dev → test (https://test-erp.hexdive.com)
|
|
||||||
# push to production → release (https://erp.hexdive.com)
|
|
||||||
# Only the side(s) whose folder changed since the last successful deploy are built and deployed,
|
|
||||||
# backend (and its migrations) always before frontend. Nightly/manual Playwright runs hit test only.
|
|
||||||
name: Deploy
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [Dev, production]
|
|
||||||
schedule:
|
|
||||||
- cron: "30 20 * * *" # 02:00 Asia/Colombo. Gitea reads schedules from the default branch (production).
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
force_backend:
|
|
||||||
description: "Redeploy the backend even if unchanged (e.g. after editing /etc/erpcore/<env>.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
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# deploy.yml › backend — build (or reuse) the self-contained release for BACKEND_HASH into
|
|
||||||
# /opt/erpcore/releases/backend/<hash>. 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
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# ci.yml › frontend and deploy.yml › frontend — build (or reuse) image erp-web:<FRONTEND_HASH>.
|
|
||||||
#
|
|
||||||
# `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 <shell steps> — 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
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# deploy.yml › backend — switch an environment to BACKEND_HASH:
|
|
||||||
# pg_dump (policy) → stop → repoint `current` → start → wait for /health
|
|
||||||
# On failure the previous release is restored and the job fails (red).
|
|
||||||
# The DATABASE is never rolled back — hence add-only migrations (docs/40-DEPLOYMENT.md §8).
|
|
||||||
# shellcheck source=common.sh
|
|
||||||
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
|
|
||||||
|
|
||||||
: "${ERP_ENV:?ERP_ENV is required}" "${BACKEND_HASH:?BACKEND_HASH is required}"
|
|
||||||
load_env "$ERP_ENV"
|
|
||||||
is_rollback="${IS_ROLLBACK:-false}"
|
|
||||||
migrations_changed="${MIGRATIONS_CHANGED:-false}"
|
|
||||||
|
|
||||||
target="$RELEASES_DIR/$BACKEND_HASH"
|
|
||||||
[[ -x "$target/ERPCore" ]] || fail "backend release not found: $target"
|
|
||||||
|
|
||||||
backup_database() {
|
|
||||||
local pg_dump file
|
|
||||||
pg_dump="$(command -v pg_dump || echo /usr/pgsql-16/bin/pg_dump)"
|
|
||||||
[[ -x "$pg_dump" ]] || fail "pg_dump not found (install postgresql16 client tools or fix PATH)"
|
|
||||||
mkdir -p "$BACKUP_DIR"
|
|
||||||
file="$BACKUP_DIR/$ERP_ENV-$(date -u +%Y%m%dT%H%M%SZ)-${BACKEND_HASH:0:12}.dump"
|
|
||||||
log "backing up database $DB_NAME → $file"
|
|
||||||
runuser -u postgres -- "$pg_dump" -Fc "$DB_NAME" >"$file.partial"
|
|
||||||
mv "$file.partial" "$file"
|
|
||||||
}
|
|
||||||
|
|
||||||
# wait_healthy — succeeds once /health answers 200. EF migrations + seeding run BEFORE the app
|
|
||||||
# starts listening, so a slow start is normal. Only a crash (unit failed / auto-restarted) or the
|
|
||||||
# generous per-environment ceiling counts as failure: never cut a live migration on a tight timer.
|
|
||||||
wait_healthy() {
|
|
||||||
local deadline=$((SECONDS + HEALTH_TIMEOUT)) restarts_at_start state restarts
|
|
||||||
restarts_at_start="$(systemctl show -p NRestarts --value "$SERVICE")"
|
|
||||||
while ((SECONDS < deadline)); do
|
|
||||||
if curl -fsS --max-time 5 -o /dev/null "http://127.0.0.1:$BACKEND_PORT/health" 2>/dev/null; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
state="$(systemctl show -p ActiveState --value "$SERVICE")"
|
|
||||||
restarts="$(systemctl show -p NRestarts --value "$SERVICE")"
|
|
||||||
if [[ "$state" == failed || "$restarts" != "$restarts_at_start" ]]; then
|
|
||||||
warn "$SERVICE crashed during startup (state=$state, restarts $restarts_at_start → $restarts)"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
sleep 3
|
|
||||||
done
|
|
||||||
warn "$SERVICE was not healthy within ${HEALTH_TIMEOUT}s"
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
switch_to() {
|
|
||||||
systemctl stop "$SERVICE" || true
|
|
||||||
mkdir -p "$(dirname "$CURRENT_LINK")"
|
|
||||||
ln -sfn "$1" "$CURRENT_LINK"
|
|
||||||
systemctl reset-failed "$SERVICE" 2>/dev/null || true
|
|
||||||
systemctl start "$SERVICE" || warn "systemctl start $SERVICE returned an error"
|
|
||||||
}
|
|
||||||
|
|
||||||
if [[ "$is_rollback" != true ]] && [[ "$BACKUP_POLICY" == always || "$migrations_changed" == true ]]; then
|
|
||||||
backup_database
|
|
||||||
else
|
|
||||||
log "no database backup (policy=$BACKUP_POLICY, migrations changed=$migrations_changed, rollback=$is_rollback)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
previous="$(readlink "$CURRENT_LINK" 2>/dev/null || true)"
|
|
||||||
log "deploying backend $BACKEND_HASH to $ERP_ENV (previous: ${previous:-none})"
|
|
||||||
switch_to "$target"
|
|
||||||
|
|
||||||
if wait_healthy; then
|
|
||||||
write_state backend "$BACKEND_HASH"
|
|
||||||
if [[ "$is_rollback" != true && -n "${MIGRATIONS_HASH:-}" ]]; then write_state migrations "$MIGRATIONS_HASH"; fi
|
|
||||||
if [[ "$is_rollback" == true ]]; then result=rollback; else result=deployed; fi
|
|
||||||
record_history backend "$BACKEND_HASH" "$result"
|
|
||||||
log "✅ backend $BACKEND_HASH is healthy on $ERP_ENV"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
journalctl -u "$SERVICE" -n 80 --no-pager || true
|
|
||||||
record_history backend "$BACKEND_HASH" failed
|
|
||||||
if [[ -n "$previous" && "$previous" != "$target" && -x "$previous/ERPCore" ]]; then
|
|
||||||
warn "restoring previous release $previous"
|
|
||||||
switch_to "$previous"
|
|
||||||
if wait_healthy; then
|
|
||||||
log "previous release is serving again"
|
|
||||||
else
|
|
||||||
warn "the previous release is not healthy either — investigate now"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
warn "no previous release to restore; stopping $SERVICE"
|
|
||||||
systemctl stop "$SERVICE" || true
|
|
||||||
fi
|
|
||||||
fail "backend deploy of $BACKEND_HASH to $ERP_ENV failed"
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# deploy.yml › frontend — replace the environment's web container with erp-web:<FRONTEND_HASH>
|
|
||||||
# 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"
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# deploy.yml › detect — decide what this push deploys.
|
|
||||||
#
|
|
||||||
# A side deploys when its folder's tree hash differs from the hash last deployed SUCCESSFULLY to
|
|
||||||
# this environment (stored on the server in /opt/erpcore/<env>/.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"
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# deploy.yml › e2e — Playwright suite against the TEST environment. Never release: the suite
|
|
||||||
# creates vendors, items and POs on every run.
|
|
||||||
# shellcheck source=common.sh
|
|
||||||
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
|
|
||||||
|
|
||||||
: "${E2E_ADMIN_EMAIL:?secrets.E2E_ADMIN_EMAIL is required}" "${E2E_ADMIN_PASSWORD:?secrets.E2E_ADMIN_PASSWORD is required}"
|
|
||||||
load_env test
|
|
||||||
base_url="${E2E_BASE_URL:-$PUBLIC_URL}"
|
|
||||||
|
|
||||||
# The container's browsers must match the locked @playwright/test version exactly.
|
|
||||||
pw_version="$(grep -A1 '"node_modules/@playwright/test"' "$E2E_PATH/package-lock.json" |
|
|
||||||
sed -n 's/.*"version": "\([^"]*\)".*/\1/p')"
|
|
||||||
[[ -n "$pw_version" ]] || fail "could not read the locked @playwright/test version"
|
|
||||||
image="mcr.microsoft.com/playwright:v$pw_version-noble"
|
|
||||||
z="$(selinux_mount_suffix)"
|
|
||||||
|
|
||||||
with_build_lock # Chromium is the second-heaviest workload on the box — never overlap a build
|
|
||||||
|
|
||||||
log "E2E against $base_url with $image (capped: 2 CPU / 1500 MB)"
|
|
||||||
status=0
|
|
||||||
docker run --rm --cpus=2 --cpu-shares=512 --memory=1500m --ipc=host \
|
|
||||||
-e CI=true -e E2E_BASE_URL="$base_url" -e E2E_API_URL="$base_url" \
|
|
||||||
-e E2E_ADMIN_EMAIL -e E2E_ADMIN_PASSWORD \
|
|
||||||
-v "$PWD/$E2E_PATH:/e2e$z" -w /e2e "$image" \
|
|
||||||
bash -ec 'npm ci --no-audit --no-fund && npx playwright test' || status=$?
|
|
||||||
|
|
||||||
if [[ -d "$E2E_PATH/playwright-report" ]]; then
|
|
||||||
report_dir="$E2E_REPORTS_DIR/$(date -u +%Y%m%dT%H%M%SZ)-run${GITHUB_RUN_NUMBER:-manual}"
|
|
||||||
mkdir -p "$report_dir"
|
|
||||||
cp -r "$E2E_PATH/playwright-report" "$report_dir/"
|
|
||||||
if [[ -d "$E2E_PATH/test-results" ]]; then cp -r "$E2E_PATH/test-results" "$report_dir/"; fi
|
|
||||||
log "report saved on the server: $report_dir/playwright-report/index.html"
|
|
||||||
fi
|
|
||||||
exit "$status"
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Weekly housekeeping — root cron on the VPS (docs/40-DEPLOYMENT.md §4):
|
|
||||||
# 0 4 * * 0 /opt/erpcore/prune.sh >> /var/log/erpcore/prune.log 2>&1
|
|
||||||
# Keeps the newest KEEP backend releases and frontend images (anything currently deployed in
|
|
||||||
# either environment is always kept), prunes build cache, old database backups and E2E reports.
|
|
||||||
# shellcheck source=common.sh
|
|
||||||
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
|
|
||||||
|
|
||||||
KEEP="${KEEP:-5}"
|
|
||||||
with_build_lock # never delete a release or image that a running job is producing
|
|
||||||
|
|
||||||
live=()
|
|
||||||
for e in test release; do
|
|
||||||
load_env "$e"
|
|
||||||
live+=("$(read_state backend)" "$(read_state frontend)")
|
|
||||||
if [[ -L "$CURRENT_LINK" ]]; then live+=("$(basename "$(readlink "$CURRENT_LINK")")"); fi
|
|
||||||
done
|
|
||||||
is_live() {
|
|
||||||
local h
|
|
||||||
for h in "${live[@]}"; do
|
|
||||||
if [[ -n "$h" && "$h" == "$1" ]]; then return 0; fi
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if [[ -d "$RELEASES_DIR" ]]; then
|
|
||||||
n=0
|
|
||||||
while IFS= read -r dir; do
|
|
||||||
name="$(basename "$dir")"
|
|
||||||
if [[ "$name" == *.tmp ]]; then
|
|
||||||
log "removing abandoned build $name"
|
|
||||||
rm -rf "$dir"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
n=$((n + 1))
|
|
||||||
if ((n > KEEP)) && ! is_live "$name"; then
|
|
||||||
log "removing backend release $name"
|
|
||||||
rm -rf "$dir"
|
|
||||||
fi
|
|
||||||
done < <(find "$RELEASES_DIR" -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\n' | sort -rn | cut -d' ' -f2-)
|
|
||||||
fi
|
|
||||||
|
|
||||||
n=0
|
|
||||||
while IFS= read -r tag; do
|
|
||||||
if [[ -z "$tag" || "$tag" == "<none>" ]]; 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"
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# deploy.yml › release — record a successful release deploy as a Gitea release + tag
|
|
||||||
# (release-YYYY.MM.DD-N on the deployed commit), including the tree hashes to roll back to.
|
|
||||||
# shellcheck source=common.sh
|
|
||||||
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
|
|
||||||
|
|
||||||
: "${GITHUB_SHA:?}" "${ERP_GIT_TOKEN:?}" "${BACKEND_HASH:?}" "${FRONTEND_HASH:?}"
|
|
||||||
command -v python3 >/dev/null 2>&1 || fail "python3 is required to build the release JSON"
|
|
||||||
load_env release
|
|
||||||
|
|
||||||
# side_result <job result> <reused> — 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"
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# ERP-Core API — one template for both environments: erpcore@test and erpcore@release.
|
|
||||||
#
|
|
||||||
# Install once (docs/40-DEPLOYMENT.md §4):
|
|
||||||
# cp deploy/systemd/erpcore@.service /etc/systemd/system/
|
|
||||||
# systemctl daemon-reload && systemctl enable erpcore@test erpcore@release
|
|
||||||
# The pipeline never edits this file: a deploy only repoints /opt/erpcore/<env>/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
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# ci-cd.yml › backend — build, unit tests and the "entity changed but no migration" check.
|
|
||||||
# The dotnet work runs inside a capped SDK container. ASPNETCORE_ENVIRONMENT=Development is set
|
|
||||||
# explicitly for the EF step: `dotnet ef` boots Program.cs, and outside Development the startup
|
|
||||||
# config check would (correctly) refuse to run without the deployment secrets CI doesn't have.
|
|
||||||
# shellcheck source=common.sh
|
|
||||||
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
|
|
||||||
|
|
||||||
z="$(selinux_mount_suffix)"
|
|
||||||
with_build_lock
|
|
||||||
|
|
||||||
log "backend checks (capped: 2 CPU / 1500 MB)"
|
|
||||||
# The inner script is single-quoted on purpose: it expands inside the container, not here.
|
|
||||||
# shellcheck disable=SC2016
|
|
||||||
docker run --rm --cpus=2 --cpu-shares=512 --memory=1500m \
|
|
||||||
-e DOTNET_CLI_TELEMETRY_OPTOUT=1 -e DOTNET_NOLOGO=1 -e EF_VERSION="$DOTNET_EF_VERSION" \
|
|
||||||
-e ASPNETCORE_ENVIRONMENT=Development \
|
|
||||||
-v "$PWD/Backend:/src$z" \
|
|
||||||
-v erp-nuget:/root/.nuget/packages \
|
|
||||||
-v erp-dotnet-tools:/root/.dotnet/tools \
|
|
||||||
-w /src "$DOTNET_SDK_IMAGE" bash -ec '
|
|
||||||
dotnet restore ERPCore.sln
|
|
||||||
dotnet build ERPCore.sln -c Release --no-restore
|
|
||||||
dotnet test ERPCore.Tests/ERPCore.Tests.csproj -c Release --no-build
|
|
||||||
|
|
||||||
# Program.cs suppresses PendingModelChangesWarning at runtime, so this is the only guard
|
|
||||||
# against an entity/configuration change merged without its migration. --no-build is safe
|
|
||||||
# here: the assembly was built from this exact source two commands ago.
|
|
||||||
dotnet tool update --global dotnet-ef --version "$EF_VERSION" >/dev/null
|
|
||||||
export PATH="$PATH:/root/.dotnet/tools"
|
|
||||||
dotnet ef migrations has-pending-model-changes \
|
|
||||||
--project ERPCore/ERPCore.csproj --configuration Release --no-build
|
|
||||||
'
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# ci-cd.yml › detect — which checks does this pull request need?
|
|
||||||
# Compares the PR head's folder tree hashes with the target branch's. Tree hashes are content
|
|
||||||
# addressed, so this needs no merge-base and works on depth-1 clones.
|
|
||||||
# shellcheck source=common.sh
|
|
||||||
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
|
|
||||||
|
|
||||||
: "${BASE_REF:?BASE_REF (the PR target branch) is required}"
|
|
||||||
base="refs/remotes/origin/$BASE_REF"
|
|
||||||
git_remote fetch -q --depth 1 "$REPO_GIT_URL" "+refs/heads/$BASE_REF:$base"
|
|
||||||
|
|
||||||
# changed <path...> — 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")"
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Shared settings + helpers for the ERP-Core CI/CD scripts. Sourced by the other scripts, never
|
|
||||||
# run directly. Runbook: docs/40-DEPLOYMENT.md (values here must match the server bootstrap, §4).
|
|
||||||
# shellcheck disable=SC2034 # variables are consumed by the scripts that source this file
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# ── Repository (folders whose git tree hash decides what is checked / built / deployed) ─────
|
|
||||||
BACKEND_PATH="Backend/ERPCore"
|
|
||||||
BACKEND_TESTS_PATH="Backend/ERPCore.Tests"
|
|
||||||
MIGRATIONS_PATH="Backend/ERPCore/Migrations"
|
|
||||||
FRONTEND_PATH="Frontend/erp-system"
|
|
||||||
E2E_PATH="Testing/e2e"
|
|
||||||
GITEA_URL="${GITEA_URL:-https://gitea.hexdive.com}"
|
|
||||||
REPO_SLUG="New_REP_SYSTEM/ERP-core"
|
|
||||||
REPO_GIT_URL="$GITEA_URL/$REPO_SLUG.git"
|
|
||||||
|
|
||||||
# ── Server layout of the ON-HOLD two-environment design (deploy/on-hold/). Active production does
|
|
||||||
# not use these paths — see .gitea/workflows/ci-cd.yml (erpcore.service, /home/erp/core/publish).
|
|
||||||
# ERP_ROOT still hosts the build lock taken by ci-backend.sh. ─────────────────────────────────
|
|
||||||
ERP_ROOT="${ERP_ROOT:-/opt/erpcore}"
|
|
||||||
RELEASES_DIR="$ERP_ROOT/releases/backend"
|
|
||||||
BUILD_LOCK="$ERP_ROOT/.build.lock"
|
|
||||||
BACKUP_DIR="${ERP_BACKUP_DIR:-/var/backups/erpcore}"
|
|
||||||
E2E_REPORTS_DIR="${ERP_E2E_REPORTS_DIR:-/var/lib/erpcore/e2e-reports}"
|
|
||||||
|
|
||||||
# ── Build images (builds run in CPU/memory-capped containers on the shared 8 GB VPS) ───────
|
|
||||||
DOTNET_SDK_IMAGE="mcr.microsoft.com/dotnet/sdk:10.0"
|
|
||||||
NODE_IMAGE="node:24-alpine"
|
|
||||||
WEB_IMAGE_REPO="erp-web"
|
|
||||||
DOTNET_EF_VERSION="10.0.9" # keep in step with Microsoft.EntityFrameworkCore.* in ERPCore.csproj
|
|
||||||
|
|
||||||
log() { printf '[erp] %s\n' "$*"; }
|
|
||||||
warn() { printf '[erp] WARNING: %s\n' "$*" >&2; }
|
|
||||||
fail() {
|
|
||||||
printf '[erp] ERROR: %s\n' "$*" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# load_env <test|release> — per-environment values. Ports, unit/container names and database
|
|
||||||
# names must match the server (systemd unit, Apache vhosts, /etc/erpcore/<env>.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 <branch> — the environment a push to <branch> deploys to.
|
|
||||||
env_for_branch() {
|
|
||||||
case "$1" in
|
|
||||||
# ON HOLD — test environment: Dev) echo test ;;
|
|
||||||
production) echo release ;;
|
|
||||||
*) return 1 ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
# tree_hash <path> [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 <side> <hash> <result> — 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 <name> <value> — 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/<hash>.tmp or image tag if that is ever raised.
|
|
||||||
with_build_lock() {
|
|
||||||
mkdir -p "$ERP_ROOT"
|
|
||||||
exec 9>"$BUILD_LOCK"
|
|
||||||
flock 9
|
|
||||||
}
|
|
||||||
|
|
||||||
# git_remote <git args...> — 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
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# ci-cd.yml › backend — flag destructive EF migrations introduced by this pull request.
|
|
||||||
#
|
|
||||||
# A failed deploy rolls the backend binaries back but never the database, so every release must
|
|
||||||
# leave the PREVIOUS backend working on the NEW schema: add tables/columns now, drop or rename
|
|
||||||
# them in a later release (expand → contract, docs/40-DEPLOYMENT.md §8).
|
|
||||||
# into Dev → warning
|
|
||||||
# into production → failure, unless the PR title contains [allow-destructive-migration]
|
|
||||||
# shellcheck source=common.sh
|
|
||||||
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
|
|
||||||
|
|
||||||
: "${BASE_REF:?BASE_REF (the PR target branch) is required}"
|
|
||||||
base="refs/remotes/origin/$BASE_REF"
|
|
||||||
git_remote fetch -q --depth 1 "$REPO_GIT_URL" "+refs/heads/$BASE_REF:$base"
|
|
||||||
|
|
||||||
# Two-dot tree diff: needs only the two commits, not their history (depth-1 friendly).
|
|
||||||
mapfile -t files < <(
|
|
||||||
git diff --name-only --diff-filter=AM "$base" HEAD -- "$MIGRATIONS_PATH" |
|
|
||||||
grep -E '\.cs$' | grep -vE '\.Designer\.cs$|ModelSnapshot\.cs$' || true
|
|
||||||
)
|
|
||||||
|
|
||||||
if ((${#files[@]} == 0)); then
|
|
||||||
log "no new or changed migrations"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
hits=""
|
|
||||||
for f in "${files[@]}"; do
|
|
||||||
# Only Up() matters — every Down() legitimately drops what its Up() created.
|
|
||||||
found="$(awk '
|
|
||||||
/override void Up\(/ { up = 1 }
|
|
||||||
/override void Down\(/ { up = 0 }
|
|
||||||
up && /migrationBuilder\.(DropTable|DropColumn|RenameColumn|RenameTable|DropSchema|DropSequence)\(/ {
|
|
||||||
printf " %s:%d: %s\n", FILENAME, FNR, $0
|
|
||||||
}' "$f")"
|
|
||||||
hits+="$found"
|
|
||||||
done
|
|
||||||
|
|
||||||
if [[ -z "$hits" ]]; then
|
|
||||||
log "checked ${#files[@]} migration file(s): additive only"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
printf '%s\n' "$hits"
|
|
||||||
msg="destructive operation in a migration's Up() — keep the previous release working on the new schema: expand now, contract in a later release (docs/40-DEPLOYMENT.md §8)"
|
|
||||||
if [[ "$BASE_REF" == production ]]; then
|
|
||||||
if [[ "${PR_TITLE:-}" == *"[allow-destructive-migration]"* ]]; then
|
|
||||||
warn "$msg — allowed by the [allow-destructive-migration] PR title override"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
fail "$msg"
|
|
||||||
fi
|
|
||||||
warn "$msg"
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# ci-cd.yml › Build & Deploy › Verify backend — wait until erpcore.service answers /health, and fail the job otherwise.
|
|
||||||
#
|
|
||||||
# EF migrations + seeding run BEFORE the app starts listening, so a slow start is normal. Only a
|
|
||||||
# crash (unit failed, or systemd auto-restarted it) or the ceiling counts as failure — a crashed
|
|
||||||
# service fails within seconds, and a slow but live migration is never cut short by a tight timer.
|
|
||||||
#
|
|
||||||
# Env: SERVICE (erpcore.service) · BACKEND_PORT (5220) · HEALTH_TIMEOUT seconds (300)
|
|
||||||
# RESTARTS_AT_START — NRestarts captured right after `systemctl start` (the Start step exports it)
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
service="${SERVICE:-erpcore.service}"
|
|
||||||
port="${BACKEND_PORT:-5220}"
|
|
||||||
timeout="${HEALTH_TIMEOUT:-300}"
|
|
||||||
restarts_at_start="${RESTARTS_AT_START:-$(systemctl show -p NRestarts --value "$service")}"
|
|
||||||
|
|
||||||
show_journal() { journalctl -u "$service" -n 80 --no-pager || true; }
|
|
||||||
|
|
||||||
deadline=$((SECONDS + timeout))
|
|
||||||
while ((SECONDS < deadline)); do
|
|
||||||
if curl -fsS --max-time 5 -o /dev/null "http://127.0.0.1:$port/health" 2>/dev/null; then
|
|
||||||
echo "✅ $service is healthy (after ${SECONDS}s)"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
state="$(systemctl show -p ActiveState --value "$service")"
|
|
||||||
restarts="$(systemctl show -p NRestarts --value "$service")"
|
|
||||||
if [[ "$state" == failed || "$restarts" != "$restarts_at_start" ]]; then
|
|
||||||
echo "❌ $service crashed during startup (state=$state, restarts $restarts_at_start → $restarts)" >&2
|
|
||||||
show_journal
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
sleep 3
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "❌ $service did not answer /health within ${timeout}s (still starting? DB reachable?)" >&2
|
|
||||||
show_journal
|
|
||||||
exit 1
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
# ERP-Core API — production (erp.hexdive.com).
|
|
||||||
#
|
|
||||||
# This unit holds only what never changes. Everything configurable — listening URL, connection
|
|
||||||
# string, AuthHex/GL URLs, storage paths — is written by .gitea/workflows/ci-cd.yml into
|
|
||||||
# /etc/systemd/system/erpcore.service.d/10-erpcore-env.conf
|
|
||||||
# on every backend deploy, from the values in that workflow's `env:` block plus Gitea secrets.
|
|
||||||
# There is no /etc/erpcore/erpcore.env any more. To change a setting, edit the workflow and
|
|
||||||
# redeploy (docs/40-DEPLOYMENT.md, "Active" A.2/A.3).
|
|
||||||
#
|
|
||||||
# The workflow installs this file itself, so editing it here and deploying is enough.
|
|
||||||
|
|
||||||
[Unit]
|
|
||||||
Description=ERP core service
|
|
||||||
After=network-online.target postgresql-16.service
|
|
||||||
Wants=network-online.target
|
|
||||||
# Stop hammering the database if a release crash-loops: give up after 5 starts in 5 minutes
|
|
||||||
# (systemd then reports "failed", which is what wait-healthy.sh watches for).
|
|
||||||
StartLimitIntervalSec=300
|
|
||||||
StartLimitBurst=5
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=erpuser
|
|
||||||
Group=erpuser
|
|
||||||
WorkingDirectory=/home/erp/core/publish
|
|
||||||
ExecStart=/home/erp/core/publish/ERPCore
|
|
||||||
SyslogIdentifier=erpcore
|
|
||||||
Environment=ASPNETCORE_ENVIRONMENT=Production
|
|
||||||
Environment=DOTNET_PRINT_TELEMETRY_MESSAGE=false
|
|
||||||
Environment=DOTNET_CLI_TELEMETRY_OPTOUT=1
|
|
||||||
Restart=always
|
|
||||||
RestartSec=10
|
|
||||||
TimeoutStopSec=30
|
|
||||||
|
|
||||||
# Shared 8 GB VPS: a runaway process must not take Gitea/Postgres/AuthHex 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
|
|
||||||
+8
-2
@@ -247,8 +247,14 @@ Resulting `ERPCore.csproj`:
|
|||||||
"Jwt": { "SigningKey": "dev-only-signing-key-please-change-me-0123456789" }
|
"Jwt": { "SigningKey": "dev-only-signing-key-please-change-me-0123456789" }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
The deployed environment (production; a test environment is on hold) has **no** `appsettings.<Environment>.json`: every environment-specific value is set in the `env:` block of `.gitea/workflows/ci-cd.yml`, and the deploy generates `appsettings.Production.json` from it (the two secrets come from Gitea secrets, and the file is never committed), and outside Development the API refuses to start when a required key is blank — see `40-DEPLOYMENT.md §5`.
|
`appsettings.Production.json` (values injected from environment variables at deploy time):
|
||||||
> **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).
|
```json
|
||||||
|
{
|
||||||
|
"ConnectionStrings": { "DefaultConnection": "" },
|
||||||
|
"Jwt": { "SigningKey": "" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
> **Secrets note (current phase):** we are on **localhost dev only**, so the Development file holds local credentials for convenience. Before any shared/staging/production use, move secrets to **User Secrets** (dev) and **environment variables** (prod), keep `appsettings.Development.json` / `appsettings.Production.json` out of git (see root `.gitignore`), and rotate any credential that was ever committed.
|
||||||
|
|
||||||
### 5.6 Minimal `Program.cs` wiring (outline)
|
### 5.6 Minimal `Program.cs` wiring (outline)
|
||||||
```csharp
|
```csharp
|
||||||
|
|||||||
@@ -45,11 +45,10 @@
|
|||||||
| `30-BACKEND-PHASE2.md` | High | Manufacturing (Production Lines) backend: SRS, ER model, status machines, stock/costing integration, **and** the full API contract — model and API in one doc, unlike Phase 1. Schema is **authoritative** here. | Any manufacturing model / rule / API work. | Claude + humans |
|
| `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-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 |
|
| `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** |
|
| `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** |
|
| `Frontend/PROGRESS.md` | Low | Frontend **change checklist**, git-shared. | After making frontend changes. | **Claude** |
|
||||||
|
|
||||||
Numbering convention: `0x` = high-level hub/guide/cross-cutting, `1x` = backend, `2x` = frontend, `4x` = operations (CI/CD, deployment). Numbers sort in read order in any file browser.
|
Numbering convention: `0x` = high-level hub/guide/cross-cutting, `1x` = backend, `2x` = frontend. Numbers sort in read order in any file browser.
|
||||||
|
|
||||||
> **Known deviation:** `30-BACKEND-PHASE2.md` is a backend doc numbered `3x` rather than continuing the `1x` decade, and it merges the model and the API contract into one file instead of the `10`/`11` split. Left as-is — it is already the established reference for the manufacturing phase and renumbering it would invalidate every existing cross-reference. New backend phases should return to the `1x` convention.
|
> **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.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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-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-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-05** | **In-transit loss window** — dispatched-but-not-received stock is untracked shrinkage risk. | Inherent to in-transit transfers (FR-STK-05). | In-transit aging monitoring report. | Add stuck-transfer alert (Part D). |
|
||||||
| **AR-06** | **Committed secrets (historical).** `appsettings.Development.json` (DB password), `appsettings.json` (GL API key) and `Backend/smoke/smoke_common.py` (AuthHex login) were committed while the repo was publicly readable. | Local-dev convenience in early phases. | **Now closed going forward:** env-specific appsettings untracked, base `appsettings.json` and smoke defaults hold no secrets, deployed secrets live only in Gitea secrets (`ERP_DB_PASSWORD`, `ERP_GL_API_KEY`) and are written at deploy time into `appsettings.Production.json` (mode 640, never committed — `.gitignore` ignores `appsettings.*.json`; `40-DEPLOYMENT.md` A.1 step 6b); non-secret deploy config is in `ci-cd.yml`, non-Development startup fails on missing keys. | The exposed values stay readable in git history → **rotate** the GL API key, Postgres password and the AuthHex account password, and keep the repo private. |
|
| **AR-06** | **Localhost dev secrets** in `appsettings.Development.json`. | Local-dev convenience, current phase. | `.gitignore` + localhost only. | Before any shared/staging/prod → User Secrets / env vars; rotate. |
|
||||||
| **AR-07** | **`getUserDetails` / `LogoutUser` callable without a bearer token** — `GET /api/v1/auth/users/{userId}` and `POST /api/v1/auth/logout` resolve the target user from the URL/payload, not the caller's session, so any anonymous caller can fetch a profile or log out an arbitrary user's sessions by GUID. | Carried over verbatim from AuthHex's own dispatcher contract (API_REFERENCE.md §3) — ERPCore's `AuthController` proxies it as-is rather than silently tightening a contract it doesn't own. | GUIDs are not enumerable; every call is written to `AuthEventLogs` upstream in AuthHex. | Revisit once AuthHex exposes a token-scoped variant, or add ERPCore-side rate limiting / auth requirement ahead of AuthHex. |
|
| **AR-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-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. |
|
| **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. |
|
||||||
|
|||||||
@@ -1,402 +0,0 @@
|
|||||||
# 40 · DEPLOYMENT — CI/CD, Environments & Server Runbook
|
|
||||||
|
|
||||||
> **What this is:** how code gets from a pull request to `erp.hexdive.com` (the `test-erp.hexdive.com` environment is on hold), how the server is set up, and how to operate it (config changes, rollback, backups).
|
|
||||||
> **Navigation:** you arrived from `00-CORE.md` or `01-DOC-GUIDE.md`. Security posture: `02-SECURITY.md` (AR-06 covers the secrets history).
|
|
||||||
> **Files (active):** `.gitea/workflows/ci-cd.yml` (PR checks + production deploy, one file) · `deploy/scripts/{common,ci-detect,ci-backend,migration-scan,wait-healthy}.sh` · `deploy/systemd/erpcore.service` · `deploy/apache/erp.hexdive.com.conf`. Runtime config lives in the workflow's `env:` block — there is no env file on the server.
|
|
||||||
> **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 in the run header | Jobs that run |
|
|
||||||
|---|---|
|
|
||||||
| `pull_request` **opened / synchronize / reopened**, base `Dev` or `production` | checks: `detect` → `backend` / `frontend` / `e2e-typecheck` / `scripts` (only the sides the PR touches) → `ci-ok` (§6) |
|
|
||||||
| `pull_request` **closed**, merged, base `production` | `Build & Deploy` (checks skipped) |
|
|
||||||
| `pull_request` **closed**, base `Dev`, or closed without merging | nothing — every job shows as skipped |
|
|
||||||
| `workflow_dispatch` (*Actions → CI/CD - ERP Core → Run workflow*) | `Build & Deploy` with the `deploy_backend` / `deploy_frontend` boxes |
|
|
||||||
|
|
||||||
**Two runs that look spurious but are not.** Read the run header — it names the event and the PR.
|
|
||||||
- **A push to `Dev` starts a run.** `synchronize` fires on a push to the **head** branch of any *open* PR whose base is `Dev` or `production`. While a `Dev` → `production` PR is open, every push to `Dev` re-runs that PR's checks — correct, and the reason its `ci-ok` stays current.
|
|
||||||
- **Merging into `Dev` starts an empty run.** The file listens to `closed` on both branches, so a `Dev` merge starts a run in which the checks skip (`action != 'closed'`) *and* `Build & Deploy` skips (`base.ref != 'production'`). Nothing executes, but the run is still listed.
|
|
||||||
|
|
||||||
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 | Install unit + write config | backend changed | install `deploy/systemd/erpcore.service`, write the `ASPNETCORE_URLS` drop-in, `daemon-reload` + `enable`. Validates first, while the **old** release is still serving: fails on a `CHANGE_ME` URL, a missing secret, a `;` in the DB password, or a storage/log path 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 erpuser:erpuser`, 755/644, `+x ERPCore` · `systemctl start` |
|
|
||||||
| 6b | Write `appsettings.Production.json` | backend changed | generate it into the publish folder from the `env:` block + Gitea secrets (mode 640 `root:erpuser`), after the chmod pass so it is not world-readable. It **merges over** the committed `appsettings.json`, which keeps `Auth:RsaPublicKeyXml`, `Issuer`, `Audience` and `FileStorage:MaxSizeBytes`. Not in git — `.gitignore` ignores `appsettings.*.json` |
|
|
||||||
| 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.
|
|
||||||
|
|
||||||
**Where this deliberately differs from the Fitness workflows** — check here before "fixing" ERP to match them:
|
|
||||||
|
|
||||||
| | Fitness | ERP-Core | Why |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Backend verify | `systemctl is-active … && echo ✅ \|\| echo ❌` | `wait-healthy.sh`, exits non-zero | Fitness's verify **can never fail its job** — a dead service still ends green. Do not copy it. |
|
|
||||||
| Frontend config | `--build-arg NEXT_PUBLIC_CORE_API_URL` + `--env-file /home/hexafit/web/.env` | neither | `next.config.ts` here is same-origin `/api/*` and Apache proxies `/api/` to `:5220` before Next sees it, so there is no public API URL to bake in and no env file to mount. |
|
|
||||||
| Restore / publish | `restore` the `.sln`, RID only on `publish` | `restore … -r linux-x64`, `publish --no-restore` | the RID must match between restore and publish, or publish silently re-restores. |
|
|
||||||
| Checkout | `git clone --branch production https://oauth2:$TOKEN@…` | `git init` + `http.extraHeader: Basic` | keeps the token out of `.git/config`. Unproven — see §A.4. |
|
|
||||||
|
|
||||||
Also: ERPCore targets **`net10.0`**. Fitness building green on this runner does not prove a 10.0.x SDK is installed — check `dotnet --list-sdks` (§A.2).
|
|
||||||
|
|
||||||
**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.
|
|
||||||
# (The deploy creates the data dirs too, but make them now so a first run has nothing to fix.)
|
|
||||||
useradd --create-home --home-dir /home/erp --shell /sbin/nologin erpuser
|
|
||||||
install -d -m 755 -o erpuser -g erpuser /home/erp/core /home/erp/core/publish
|
|
||||||
install -d -m 750 -o erpuser -g erpuser /var/lib/erpcore/hr-documents /var/log/erpcore
|
|
||||||
install -d -m 700 /var/backups/erpcore
|
|
||||||
|
|
||||||
# Database (Postgres 16, localhost only). This password goes into the Gitea secret ERP_DB_PASSWORD.
|
|
||||||
sudo -u postgres psql -c "CREATE ROLE erpcore LOGIN PASSWORD 'CHANGE_ME';" -c 'CREATE DATABASE "ERPCore" OWNER erpcore;'
|
|
||||||
|
|
||||||
# Service: only `enable` by hand. The deploy installs the unit and writes the config drop-in itself,
|
|
||||||
# so there is NO file to create under /etc/erpcore — that directory is not used any more.
|
|
||||||
# nothing to do here — the deploy installs the unit, writes the config and runs `systemctl enable`
|
|
||||||
|
|
||||||
# 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`, with its label mapped to **`host`** (§A.4) · secrets **`ERPSECRET`** (repo-read token), **`ERP_DB_PASSWORD`** (the `erpcore` role's password) and **`ERP_GL_API_KEY`** · 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 the `env:` block of `.gitea/workflows/ci-cd.yml` (or the Gitea secret, for the two credentials) → merge → Run workflow with **deploy_backend**. `appsettings.Production.json` is regenerated on every backend deploy. |
|
|
||||||
| 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/<file>.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 |
|
|
||||||
| **Every job red, `Set up job` took minutes, every later step 0 s** | **the `linux` label resolves to a container, not the host** — see below |
|
|
||||||
| "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 key is empty in the generated config — `jq 'paths(scalars) | join(":")' /home/erp/core/publish/appsettings.Production.json` lists the keys it actually got (keys only, no values) |
|
|
||||||
| Verify backend times out after 300 s | DB unreachable (`/health` 503) or a very long migration. **Not** AuthHex/GL: `/health` is `AddDbContextCheck` only, so it depends on Postgres alone (§A.5) |
|
|
||||||
| 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) |
|
|
||||||
|
|
||||||
### `Set up job` hangs, then every job fails
|
|
||||||
|
|
||||||
`Set up job` is act_runner's own preparation phase, before step 1. This workflow has **no `container:` key and no `uses:` actions** — every step is a plain `run:` — so on a host runner it is near-instant. If it burns minutes, the runner is resolving `runs-on: linux` to a **container image and pulling it**. Expand the `Set up job` block to tell the three causes apart:
|
|
||||||
|
|
||||||
| Log line | Cause | Fix |
|
|
||||||
|---|---|---|
|
|
||||||
| `unable to find image … locally` + pull progress | the label maps to `docker://…` | re-register the runner with `linux:host` |
|
|
||||||
| `no space left on device` | disk full | `docker system prune`, clear old dumps in `/var/backups/erpcore` |
|
|
||||||
| `failed to create container` / dial or TLS errors | docker daemon or registry unreachable | fix the daemon / egress |
|
|
||||||
|
|
||||||
A docker-mapped runner is wrong here regardless of whether the pull succeeds: the deploy job calls `systemctl`, `runuser -u postgres pg_dump`, `chown erpuser:erpuser` and `docker build` **on the host**. Same requirement as the Fitness workflows, which is why their `linux` label is `host`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
systemctl cat act_runner | grep -i config # find the --config path
|
|
||||||
grep -A15 '^ labels:' <config.yaml> # MUST contain - "linux:host"
|
|
||||||
grep -A5 '^ runner:' <config.yaml> # capacity: 1 (§A.1 limits)
|
|
||||||
journalctl -u act_runner --since '2 days ago' | tail -100
|
|
||||||
df -h / && docker system df
|
|
||||||
```
|
|
||||||
|
|
||||||
Several jobs each burning minutes **concurrently** is a second signal: that contradicts `capacity: 1`, and simultaneous multi-GB pulls on the shared 8 GB box are enough on their own to time the phase out.
|
|
||||||
|
|
||||||
## A.5 What `/health` actually proves
|
|
||||||
|
|
||||||
Worth knowing before blaming a red deploy on an unwired dependency:
|
|
||||||
|
|
||||||
- `MapHealthChecks("/health")` has **no fallback authorization policy** — it is anonymous, so `wait-healthy.sh` never gets a 401.
|
|
||||||
- The only registered check is `AddDbContextCheck<ErpDbContext>()`, so `/health` proves **Postgres** is reachable and nothing else.
|
|
||||||
- The AuthHex and GL typed clients are configured in their `AddHttpClient` delegates, which run **per request**, not at startup. An unreachable AuthHex or GL breaks `/api/v1/auth/*` at runtime but does **not** stop the service or turn `/health` red.
|
|
||||||
- Startup hard-fails on five blank keys (`Program.cs`, `!IsDevelopment()`). Four come from the generated `appsettings.Production.json`; the fifth, `Auth:RsaPublicKeyXml`, ships with a real value in `appsettings.json`. Blank `Auth__RequiredUserTypeCode` / `RequiredRoleCode` is deliberate — the door policy then requires only a valid ERP token.
|
|
||||||
|
|
||||||
So a backend deploy is expected to go **green with AuthHex still unwired**. `/health` 200 while `/api/v1/auth/login` returns 502/504 is the correct intermediate state, not a failed deploy.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 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/<env>/.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/<hash>/` and images as `erp-web:<hash>`. 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)
|
|
||||||
|
|
||||||
> **On hold — do not follow this to set up production.** This bootstraps the two-environment layout: service user **`erpcore`** under `/opt/erpcore`. The active production deploy needs user **`erpuser`** under `/home/erp/core/publish` (`chown erpuser:erpuser` in the workflow, `User=erpuser` in `erpcore.service`) — use **§A.2** instead. §4.0 (Gitea prerequisites), §4.1 (runner, host tools, swap), §4.3 (Postgres) and §4.7 (branch protection) apply to both; §4.2 does not.
|
|
||||||
|
|
||||||
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/<env>` and `/var/log/erpcore/<env>` 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
|
|
||||||
> `deploy/env/erpcore.env.example` was **deleted** when active production moved its config into the
|
|
||||||
> `ci-cd.yml` `env:` block + a systemd drop-in (§A.1 step 5). Restoring this two-environment design means
|
|
||||||
> recreating that template (it is in git history) or adopting the drop-in approach per environment.
|
|
||||||
```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/<port>, 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.<Environment>.json` → **environment variables** (last wins; `:` becomes `__`). Deployed environments have **no** environment-specific appsettings file.
|
|
||||||
|
|
||||||
| Key | Where | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| `ConnectionStrings__DefaultConnection` | `/etc/erpcore/<env>.env` | secret |
|
|
||||||
| `GeneralLedgerService__ApiKey` | env file | secret |
|
|
||||||
| `GeneralLedgerService__BaseUrl`, `AuthHex__BaseUrl` | env file | `http://127.0.0.1:<port>` |
|
|
||||||
| `Auth__RequiredUserTypeCode`, `Auth__RequiredRoleCode` | env file | release should set real codes |
|
|
||||||
| `FileStorage__RootPath`, `LogFile__Path` | env file | under `/var/lib/erpcore/<env>`, `/var/log/erpcore/<env>` |
|
|
||||||
| `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` — **compiles** the Playwright specs, runs none of them (see below) |
|
|
||||||
| `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.
|
|
||||||
|
|
||||||
**`e2e-typecheck` is not E2E coverage.** It type-checks the Playwright specs and page objects — no browser, no server, nothing launched — so it catches exactly one thing: a page-object or selector refactor that leaves the specs uncompilable. Because `ci-detect.sh` gates it on `Testing/e2e`, it skips at zero cost on a normal backend/frontend PR. **Nothing in the active pipeline exercises a running system**; the only runtime check is `wait-healthy.sh` at the end of the deploy (§A.5). Real E2E runs belong in the on-hold test environment (`deploy/on-hold/scripts/e2e.sh`), never in the production deploy path.
|
|
||||||
|
|
||||||
**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>.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/<env>/.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/<run>/playwright-report/` |
|
|
||||||
| **What is deployed?** | `cat /opt/erpcore/<env>/.deployed/{backend,frontend}` · `readlink /opt/erpcore/<env>/backend/current` · `tail /opt/erpcore/<env>/.deployed/history.log` |
|
|
||||||
| **Logs** | `journalctl -u erpcore@<env> -f` · `/var/log/erpcore/<env>/erpcore-*.log` · `docker logs -f erp-web[-test]` · `/var/log/httpd/<host>-error.log` |
|
|
||||||
| **Swagger on test** | `ssh -L 5230:127.0.0.1:5230 <vps>` → http://localhost:5230/swagger |
|
|
||||||
| **Restore a backup** | `systemctl stop erpcore@<env>` → `sudo -u postgres pg_restore --clean --if-exists -d <DB> /var/backups/erpcore/<file>.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>.env` (§5); previous release was restored |
|
|
||||||
| Backend job red after ~90/300 s, no crash | startup blocked: DB unreachable (`/health` 503) or a very long migration — `journalctl -u erpcore@<env>`. Wrong AuthHex/GL URLs do **not** cause this (§A.5) |
|
|
||||||
| 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 <hash> … 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.
|
|
||||||
Reference in New Issue
Block a user