Merge pull request 'Feat/ci cd implementation' (#59) from feat/ci-cd-implementation into Dev
CI/CD - ERP Core / Build & Deploy (pull_request) Failing after 13m56s
CI/CD - ERP Core / detect (pull_request) Failing after 14m48s
CI/CD - ERP Core / scripts (pull_request) Failing after 12m24s
CI/CD - ERP Core / e2e-typecheck (pull_request) Failing after 13m15s
CI/CD - ERP Core / frontend (pull_request) Failing after 14m7s
CI/CD - ERP Core / backend (pull_request) Failing after 14m59s
CI/CD - ERP Core / ci-ok (pull_request) Failing after 14m58s
CI/CD - ERP Core / Build & Deploy (pull_request) Failing after 13m56s
CI/CD - ERP Core / detect (pull_request) Failing after 14m48s
CI/CD - ERP Core / scripts (pull_request) Failing after 12m24s
CI/CD - ERP Core / e2e-typecheck (pull_request) Failing after 13m15s
CI/CD - ERP Core / frontend (pull_request) Failing after 14m7s
CI/CD - ERP Core / backend (pull_request) Failing after 14m59s
CI/CD - ERP Core / ci-ok (pull_request) Failing after 14m58s
Reviewed-on: #59
This commit was merged in pull request #59.
This commit is contained in:
+103
-13
@@ -36,7 +36,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
deploy_backend:
|
deploy_backend:
|
||||||
description: "Deploy the backend (after editing /etc/erpcore/erpcore.env, or after a failed deploy)"
|
description: "Deploy the backend (after changing the config in this file's env: block, or after a failed deploy)"
|
||||||
type: boolean
|
type: boolean
|
||||||
default: false
|
default: false
|
||||||
deploy_frontend:
|
deploy_frontend:
|
||||||
@@ -236,14 +236,28 @@ jobs:
|
|||||||
PUBLISH_DIR: /home/erp/core/publish
|
PUBLISH_DIR: /home/erp/core/publish
|
||||||
BUILD_DIR: /tmp/erp-deploy
|
BUILD_DIR: /tmp/erp-deploy
|
||||||
SERVICE: erpcore.service
|
SERVICE: erpcore.service
|
||||||
SERVICE_USER: erp
|
SERVICE_USER: erpuser
|
||||||
BACKEND_PORT: "5220"
|
BACKEND_PORT: "5220"
|
||||||
ENV_FILE: /etc/erpcore/erpcore.env
|
|
||||||
DB_NAME: ERPCore
|
DB_NAME: ERPCore
|
||||||
|
DB_USER: erpcore
|
||||||
BACKUP_DIR: /var/backups/erpcore
|
BACKUP_DIR: /var/backups/erpcore
|
||||||
WEB_IMAGE: erp-web:latest
|
WEB_IMAGE: erp-web:latest
|
||||||
WEB_CONTAINER: erp-web
|
WEB_CONTAINER: erp-web
|
||||||
WEB_PORT: "3020"
|
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:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@@ -324,25 +338,60 @@ jobs:
|
|||||||
echo "Backup: $file"
|
echo "Backup: $file"
|
||||||
find "$BACKUP_DIR" -maxdepth 1 -name '*.dump' -mtime +30 -delete
|
find "$BACKUP_DIR" -maxdepth 1 -name '*.dump' -mtime +30 -delete
|
||||||
|
|
||||||
- name: Guard data paths
|
- name: Install unit + write config
|
||||||
if: steps.changes.outputs.backend == 'true'
|
if: steps.changes.outputs.backend == 'true'
|
||||||
|
env:
|
||||||
|
DB_PASSWORD: ${{ secrets.ERP_DB_PASSWORD }}
|
||||||
|
GL_API_KEY: ${{ secrets.ERP_GL_API_KEY }}
|
||||||
run: |
|
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.
|
# "Copy to deploy folder" empties the publish folder, so uploads and logs must live elsewhere.
|
||||||
[ -r "$ENV_FILE" ] || { echo "❌ $ENV_FILE not found"; exit 1; }
|
|
||||||
publish="$(realpath -m "$PUBLISH_DIR")"
|
publish="$(realpath -m "$PUBLISH_DIR")"
|
||||||
for key in FileStorage__RootPath LogFile__Path; do
|
for path in "$STORAGE_ROOT" "$LOG_PATH"; do
|
||||||
value="$(sed -n "s/^$key=//p" "$ENV_FILE" | tail -n 1 | tr -d '"')"
|
case "$path" in
|
||||||
[ -n "$value" ] || { echo "❌ $key is not set in $ENV_FILE"; exit 1; }
|
|
||||||
case "$value" in
|
|
||||||
/*) ;;
|
/*) ;;
|
||||||
*) echo "❌ $key=$value is relative — it would resolve inside $PUBLISH_DIR"; exit 1 ;;
|
*) echo "❌ $path is relative — it would resolve inside $PUBLISH_DIR"; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
case "$(realpath -m "$value")/" in
|
case "$(realpath -m "$path")/" in
|
||||||
"$publish"/*) echo "❌ $key=$value is inside $PUBLISH_DIR, which every deploy wipes"; exit 1 ;;
|
"$publish"/*) echo "❌ $path is inside $PUBLISH_DIR, which every deploy wipes"; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
echo "ok $key=$value"
|
echo "ok $path"
|
||||||
done
|
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
|
- name: Stop service
|
||||||
if: steps.changes.outputs.backend == 'true'
|
if: steps.changes.outputs.backend == 'true'
|
||||||
run: systemctl stop "$SERVICE" || true
|
run: systemctl stop "$SERVICE" || true
|
||||||
@@ -362,6 +411,47 @@ jobs:
|
|||||||
find "$PUBLISH_DIR" -type f -exec chmod 644 {} +
|
find "$PUBLISH_DIR" -type f -exec chmod 644 {} +
|
||||||
chmod +x "$PUBLISH_DIR/ERPCore"
|
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
|
- name: Start service
|
||||||
if: steps.changes.outputs.backend == 'true'
|
if: steps.changes.outputs.backend == 'true'
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ npm run dev
|
|||||||
|
|
||||||
## Configuration & secrets
|
## Configuration & secrets
|
||||||
- `appsettings.json` is committed and holds **no secrets**; environment-specific values are blank there.
|
- `appsettings.json` is committed and holds **no secrets**; environment-specific values are blank there.
|
||||||
- Local: `appsettings.Development.json` (git-ignored, from the `.example`). Deployed (production): environment variables in `/etc/erpcore/erpcore.env` on the server — outside Development the API refuses to start when a required key is missing.
|
- 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.
|
||||||
- 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).
|
- 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
|
## Deployment
|
||||||
|
|||||||
Vendored
-34
@@ -1,34 +0,0 @@
|
|||||||
# /etc/erpcore/erpcore.env — settings + secrets for erpcore.service (production, erp.hexdive.com).
|
|
||||||
#
|
|
||||||
# Create once (docs/40-DEPLOYMENT.md, "Active" A.2):
|
|
||||||
# install -m 640 -o root -g erp deploy/env/erpcore.env.example /etc/erpcore/erpcore.env
|
|
||||||
# then replace every CHANGE_ME / <port>. Never commit real values.
|
|
||||||
#
|
|
||||||
# Format rules (systemd EnvironmentFile, not a shell):
|
|
||||||
# • comments only on their own line — a trailing "# ..." becomes part of the value
|
|
||||||
# • ':' in a .NET config key becomes '__'
|
|
||||||
# • wrap a value in double quotes if it contains spaces, quotes or backslashes
|
|
||||||
# After editing: Actions → "CI/CD - ERP Core" → Run workflow with deploy_backend.
|
|
||||||
# (The test environment is on hold — see deploy/on-hold/README.md.)
|
|
||||||
|
|
||||||
ASPNETCORE_ENVIRONMENT=Production
|
|
||||||
# Must match deploy/apache/erp.hexdive.com.conf and BACKEND_PORT in .gitea/workflows/ci-cd.yml
|
|
||||||
ASPNETCORE_URLS=http://127.0.0.1:5220
|
|
||||||
|
|
||||||
ConnectionStrings__DefaultConnection=Host=127.0.0.1;Port=5432;Database=ERPCore;Username=erpcore;Password=CHANGE_ME
|
|
||||||
|
|
||||||
AuthHex__BaseUrl=http://127.0.0.1:<authhex-port>
|
|
||||||
# Door policy — set the real AuthHex user type / role codes.
|
|
||||||
Auth__RequiredUserTypeCode=
|
|
||||||
Auth__RequiredRoleCode=
|
|
||||||
|
|
||||||
GeneralLedgerService__BaseUrl=http://127.0.0.1:<gl-port>/api/v1/
|
|
||||||
GeneralLedgerService__ApiKey=CHANGE_ME
|
|
||||||
|
|
||||||
# MUST be absolute and OUTSIDE /home/erp/core/publish — every backend deploy empties that folder.
|
|
||||||
# The workflow's "Guard data paths" step refuses to deploy otherwise.
|
|
||||||
FileStorage__RootPath=/var/lib/erpcore/hr-documents
|
|
||||||
LogFile__Path=/var/log/erpcore/erpcore-.log
|
|
||||||
|
|
||||||
# Production never seeds demo data.
|
|
||||||
Seed__DemoData=false
|
|
||||||
@@ -1,32 +1,38 @@
|
|||||||
# ERP-Core API — production (erp.hexdive.com).
|
# ERP-Core API — production (erp.hexdive.com).
|
||||||
#
|
#
|
||||||
# Install once (docs/40-DEPLOYMENT.md, "Active" A.2):
|
# This unit holds only what never changes. Everything configurable — listening URL, connection
|
||||||
# cp deploy/systemd/erpcore.service /etc/systemd/system/
|
# string, AuthHex/GL URLs, storage paths — is written by .gitea/workflows/ci-cd.yml into
|
||||||
# systemctl daemon-reload && systemctl enable erpcore.service
|
# /etc/systemd/system/erpcore.service.d/10-erpcore-env.conf
|
||||||
# .gitea/workflows/ci-cd.yml stops it, replaces /home/erp/core/publish, then starts it again.
|
# 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]
|
[Unit]
|
||||||
Description=ERP-Core API (production)
|
Description=ERP core service
|
||||||
After=network-online.target postgresql-16.service
|
After=network-online.target postgresql-16.service
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
# Stop hammering the database if a release crash-loops.
|
# 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
|
StartLimitIntervalSec=300
|
||||||
StartLimitBurst=5
|
StartLimitBurst=5
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
User=erp
|
User=erpuser
|
||||||
Group=erp
|
Group=erpuser
|
||||||
WorkingDirectory=/home/erp/core/publish
|
WorkingDirectory=/home/erp/core/publish
|
||||||
ExecStart=/home/erp/core/publish/ERPCore
|
ExecStart=/home/erp/core/publish/ERPCore
|
||||||
# Secrets + settings (template: deploy/env/erpcore.env.example).
|
SyslogIdentifier=erpcore
|
||||||
EnvironmentFile=/etc/erpcore/erpcore.env
|
Environment=ASPNETCORE_ENVIRONMENT=Production
|
||||||
|
Environment=DOTNET_PRINT_TELEMETRY_MESSAGE=false
|
||||||
Environment=DOTNET_CLI_TELEMETRY_OPTOUT=1
|
Environment=DOTNET_CLI_TELEMETRY_OPTOUT=1
|
||||||
Restart=on-failure
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=10
|
||||||
TimeoutStopSec=30
|
TimeoutStopSec=30
|
||||||
|
|
||||||
# Shared 8 GB VPS: a runaway process must not take Gitea/Postgres down with it.
|
# Shared 8 GB VPS: a runaway process must not take Gitea/Postgres/AuthHex down with it.
|
||||||
MemoryMax=768M
|
MemoryMax=768M
|
||||||
|
|
||||||
# Hardening. No ProtectHome: the app itself lives under /home/erp.
|
# Hardening. No ProtectHome: the app itself lives under /home/erp.
|
||||||
|
|||||||
+1
-1
@@ -247,7 +247,7 @@ 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 and secret is an environment variable in `/etc/erpcore/erpcore.env`, and outside Development the API refuses to start when a required key is blank — see `40-DEPLOYMENT.md §5`.
|
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`.
|
||||||
> **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).
|
> **Secrets note:** `appsettings.Development.json` is git-ignored (start from `appsettings.Development.json.example`) and the base `appsettings.json` holds no secrets. Credentials committed before this change stay readable in git history and must be rotated (`02-SECURITY.md` AR-06).
|
||||||
|
|
||||||
### 5.6 Minimal `Program.cs` wiring (outline)
|
### 5.6 Minimal `Program.cs` wiring (outline)
|
||||||
|
|||||||
+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 `/etc/erpcore/erpcore.env` on the server (`40-DEPLOYMENT.md` A.2 / §5), non-Development startup fails on missing keys. | The exposed values stay readable in git history → **rotate** the GL API key, Postgres password and the AuthHex account password, and keep the repo private. |
|
| **AR-06** | **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-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. |
|
||||||
|
|||||||
+79
-24
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> **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).
|
> **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).
|
> **Navigation:** you arrived from `00-CORE.md` or `01-DOC-GUIDE.md`. Security posture: `02-SECURITY.md` (AR-06 covers the secrets history).
|
||||||
> **Files (active):** `.gitea/workflows/ci-cd.yml` (PR checks + production deploy, one file) · `deploy/scripts/{common,ci-detect,ci-backend,migration-scan,wait-healthy}.sh` · `deploy/systemd/erpcore.service` · `deploy/apache/erp.hexdive.com.conf` · `deploy/env/erpcore.env.example`.
|
> **Files (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.
|
> **Files (on hold):** `deploy/on-hold/` — the test + release pipeline, see its README.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -15,12 +15,16 @@ Only `erp.hexdive.com` exists, so production is deployed the same way as the Fit
|
|||||||
|
|
||||||
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:
|
One workflow file holds both the pull-request checks and the production deploy. Every job has an `if` that picks the event it belongs to:
|
||||||
|
|
||||||
| Event | Jobs that run |
|
| Event in the run header | Jobs that run |
|
||||||
|---|---|
|
|---|---|
|
||||||
| PR opened / updated / reopened into `Dev` or `production` | checks: `detect` → `backend` / `frontend` / `e2e-typecheck` / `scripts` (only the sides the PR touches) → `ci-ok` (§6) |
|
| `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) |
|
||||||
| PR **merged** into `production` | `Build & Deploy` (checks skipped) |
|
| `pull_request` **closed**, merged, base `production` | `Build & Deploy` (checks skipped) |
|
||||||
| PR merged into `Dev`, or closed without merging | nothing — every job shows as skipped |
|
| `pull_request` **closed**, base `Dev`, or closed without merging | nothing — every job shows as skipped |
|
||||||
| *Actions → CI/CD - ERP Core → Run workflow* | `Build & Deploy` with the `deploy_backend` / `deploy_frontend` boxes |
|
| `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).
|
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).
|
||||||
|
|
||||||
@@ -32,14 +36,26 @@ The deploy job checks `pull_request.base.ref == 'production'` itself, because th
|
|||||||
| 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. |
|
| 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) |
|
| 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 |
|
| 4 | Backup database | backend **and** migrations changed | `pg_dump -Fc ERPCore` → `/var/backups/erpcore/`, dumps older than 30 days deleted |
|
||||||
| 5 | Guard data paths | backend changed | fail if `FileStorage__RootPath` / `LogFile__Path` in `/etc/erpcore/erpcore.env` are missing, relative, or inside the publish folder |
|
| 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 erp:erp`, 755/644, `+x ERPCore` · `systemctl start` |
|
| 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**. |
|
| 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` |
|
| 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 |
|
| 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.
|
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:**
|
**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.
|
- **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.
|
- **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.
|
||||||
@@ -53,33 +69,31 @@ dotnet --list-sdks # needs a 10.0.x SDK; install it next
|
|||||||
# (Fitness/AuthHex have no global.json, so they will build with SDK 10 too once it is the newest — normally fine)
|
# (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.
|
# Service user + folders. Uploads, logs and backups live OUTSIDE the publish folder, which every deploy empties.
|
||||||
useradd --create-home --home-dir /home/erp --shell /sbin/nologin erp
|
# (The deploy creates the data dirs too, but make them now so a first run has nothing to fix.)
|
||||||
install -d -m 755 -o erp -g erp /home/erp/core /home/erp/core/publish
|
useradd --create-home --home-dir /home/erp --shell /sbin/nologin erpuser
|
||||||
install -d -m 750 -o erp -g erp /var/lib/erpcore/hr-documents /var/log/erpcore
|
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
|
install -d -m 700 /var/backups/erpcore
|
||||||
install -d -m 750 -o root -g erp /etc/erpcore
|
|
||||||
|
|
||||||
# Database (Postgres 16, localhost only)
|
# 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;'
|
sudo -u postgres psql -c "CREATE ROLE erpcore LOGIN PASSWORD 'CHANGE_ME';" -c 'CREATE DATABASE "ERPCore" OWNER erpcore;'
|
||||||
|
|
||||||
# Settings + secrets (fill in CHANGE_ME / ports — keys in §5)
|
# Service: only `enable` by hand. The deploy installs the unit and writes the config drop-in itself,
|
||||||
install -m 640 -o root -g erp deploy/env/erpcore.env.example /etc/erpcore/erpcore.env
|
# 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`
|
||||||
# Service
|
|
||||||
cp deploy/systemd/erpcore.service /etc/systemd/system/ && systemctl daemon-reload && systemctl enable erpcore.service
|
|
||||||
|
|
||||||
# Apache + TLS (needs SELinux httpd_can_network_connect=on)
|
# 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
|
cp deploy/apache/erp.hexdive.com.conf /etc/httpd/conf.d/ && apachectl configtest && systemctl reload httpd
|
||||||
certbot --apache -d erp.hexdive.com
|
certbot --apache -d erp.hexdive.com
|
||||||
```
|
```
|
||||||
**Gitea:** Actions enabled for the repo · the `linux` runner available to `New_REP_SYSTEM` · secret **`ERPSECRET`** (a token that can read the repo) · branch protection on `production`: require PR, 1 approval, required check `ci-ok` (it appears as `CI/CD - ERP Core / ci-ok …` after the first PR run; a pattern like `CI/CD - ERP Core / ci-ok*` matches it), no direct push. `Dev`: require PR + the same check.
|
**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).
|
**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
|
## A.3 Operations
|
||||||
| Task | How |
|
| Task | How |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Config change | edit `/etc/erpcore/erpcore.env` → Run workflow with **deploy_backend** (or `systemctl restart erpcore` + `bash deploy/scripts/wait-healthy.sh`) |
|
| 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 |
|
| 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 |
|
| 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` |
|
| Logs | `journalctl -u erpcore -f` · `/var/log/erpcore/erpcore-*.log` · `docker logs -f erp-web` · `/var/log/httpd/erp.hexdive.com-error.log` |
|
||||||
@@ -90,12 +104,46 @@ certbot --apache -d erp.hexdive.com
|
|||||||
| Symptom | Likely cause |
|
| Symptom | Likely cause |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Job stays "Waiting" | runner not available to this repo/org, or offline |
|
| 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` |
|
| "Guard data paths" fails | `FileStorage__RootPath` / `LogFile__Path` missing, relative or inside `/home/erp/core/publish` |
|
||||||
| Verify backend fails in seconds with `Missing required configuration …` | a blank key in `/etc/erpcore/erpcore.env` |
|
| Verify backend 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), a very long migration, or wrong AuthHex/GL URLs |
|
| 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) |
|
| 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) |
|
| 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)
|
# On hold: two-environment design (test + release)
|
||||||
@@ -156,6 +204,8 @@ Git stores every folder as a **tree object** whose id is a hash of its exact con
|
|||||||
|
|
||||||
## 4. Server bootstrap (one time)
|
## 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.
|
Run as root on the VPS. Keep names/ports in sync with §2.
|
||||||
|
|
||||||
### 4.0 Prerequisites (repo owner)
|
### 4.0 Prerequisites (repo owner)
|
||||||
@@ -205,6 +255,9 @@ 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.
|
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
|
### 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
|
```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/test.env
|
||||||
install -m 640 -o root -g erpcore deploy/env/erpcore.env.example /etc/erpcore/release.env
|
install -m 640 -o root -g erpcore deploy/env/erpcore.env.example /etc/erpcore/release.env
|
||||||
@@ -274,12 +327,14 @@ Merge the pipeline into `Dev` → the Deploy run builds and deploys both sides t
|
|||||||
| `detect` | every PR open / update / reopen (not on close) | tree hashes vs target branch (`ci-detect.sh`) |
|
| `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) |
|
| `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) |
|
| `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` |
|
| `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 |
|
| `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 |
|
| **`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.
|
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.
|
**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`)
|
### `deploy.yml` — test, release, nightly E2E (on hold: `deploy/on-hold/deploy.two-env.yml`)
|
||||||
@@ -330,7 +385,7 @@ All manual actions: **Gitea → ERP-core → Actions → Deploy → Run workflow
|
|||||||
| Jobs stay "Waiting" | runner not available to this repo/org (§4.0 #3) or offline |
|
| 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 |
|
| `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, 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), a very long migration, or AuthHex/GL URLs wrong — `journalctl -u erpcore@<env>` |
|
| 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 |
|
| 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) |
|
| 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 |
|
| Build killed (exit 137) | memory cap hit — raise the cap in the script before anything else; check swap |
|
||||||
|
|||||||
Reference in New Issue
Block a user