18 KiB
| name | title | description |
|---|---|---|
| hermes-app-deploy | Hermes Application Automated Deployment | Single-script interactive deployment pattern for multi-module Hermes applications |
Hermes Application Automated Deployment
Overview
Deploy multi-module Hermes applications with a single interactive build.sh script that handles module cloning, database setup, configuration generation, and permission initialization in one step.
Architecture
All modules (reference + business) share a single database. rbac tables and business tables coexist — no separate rbac database.
Module Classification
| Type | Modules | Rules |
|---|---|---|
| Reference | apppublic, sqlor, ahserver, appbase, rbac | Never modify |
| Business | Per-application (e.g., customer_management, etc.) | Follow module-development-spec |
build.sh Pattern
The build script follows 10 sequential steps:
1. Create directories (pkgs/, logs/, files/, wwwroot/)
2. Setup Python venv
3. Install core deps (apppublic, sqlor, ahserver, bricks_for_python)
4. Clone ALL modules to pkgs/ (reference + business)
5. Generate DDL (xls2ddl/json2ddl) and CRUD UI (xls2ui)
6. Create wwwroot symlinks
7. Interactive DB configuration (prompts user)
8. Create DB, import schema, generate config.json (encrypted password)
9. Run permission initialization
10. Generate start.sh / stop.sh
Key Design Decisions
1. Clone modules to pkgs/ (not ~/repos)
build.sh clones all modules to pkgs/ — self-contained deployment, no dependency on external ~/repos structure.
2. Interactive DB prompts Instead of hardcoded or external config files, prompt the user:
read -p " MySQL host [localhost]: " DB_HOST
DB_HOST=${DB_HOST:-localhost}
read -sp " MySQL admin password: " DB_ADMIN_PASS
3. AES encrypt password in config.json
Use apppublic's aes_encode_b64() to encrypt the DB password before writing to config.json. The password_key field in config is used for decryption at runtime.
4. Single database for all modules rbac, appbase, and all business modules share ONE database. Do NOT create separate databases.
5. DDL generation — filter exception output
json2ddl/xls2ddl prints Exception: lines to stdout when model files are malformed (e.g., missing summary field, wrong summary format). These MUST be filtered before writing to the combined schema SQL file, otherwise they corrupt the SQL and cause mysql import errors:
TEMP_DDL=$(mktemp)
json2ddl mysql . > "$TEMP_DDL" 2>/dev/null || true
grep -v "^Exception:" "$TEMP_DDL" > "$MOD_DIR/mysql.ddl.sql"
6. rbac xlsx-generated DDL may have undersized column lengths
rbac models use .xlsx (not JSON) for table definitions. The xls2ddl tool may generate columns with insufficient VARCHAR lengths (e.g., permtype VARCHAR(4) instead of VARCHAR(255)). After importing the schema, always run:
mysql -u "$DB_USER" -p"$DB_PASS" "$DB_NAME" -e "ALTER TABLE permission MODIFY COLUMN permtype VARCHAR(255);"
This is a known issue — the xlsx source has short field lengths for some columns. The ALTER TABLE must come AFTER the schema import, not before.
Model JSON Format (Critical Prerequisite)
All model JSON files in models/ must have summary as a list (NOT dict), with primary field:
{
"summary": [{"name": "table_name", "title": "说明", "primary": "id", "catelog": "entity"}],
"fields": [{"name": "id", "type": "str", "length": 32, ...}]
}
Common issues that cause DDL generation failure:
summaryis a dict instead of list → fix: convert to[{"name": ..., "title": ..., "primary": "id"}]summarymissingprimarykey → add"primary": "id"- Fields missing
idfield → every table must have anidfield as primary key
Permission Configuration
perm_config.py Structure
# Role definitions (use underscores, NOT dots)
ROLES = [
{'id': 'sales_manager', 'name': '销售经理', 'desc': '...'},
{'id': 'admin_superuser', 'name': '超级用户', 'desc': '...'},
]
# Permission matrix
PERMISSION_MATRIX = {
'customer_management': {
'/customer_management/**': ['sales_manager', 'admin_superuser'],
},
}
# CRUD table paths
CRUD_TABLES = {
'customer_management': ['customers', 'customer_pool'],
}
Critical Rules
1. Role IDs use underscores, not dots
The frontend displays roles as orgtype.role_name (e.g., sales.manager). If the role ID also uses dots, they collide. Always use sales_manager as ID.
2. Convention role IDs are FIXED strings
rbac's userperm.py hardcodes checks:
if r.id == 'anonymous': k = 'anonymous'
elif r.id == 'any': k = 'any'
elif r.id == 'logined': k = 'logined'
These MUST use exact string IDs — never generate with getID().
3. Single-owner vs multi-org Most CRM applications are single-owner (one company's internal system). Don't create multi-org type structures (sales/customer/finance orgtypes) unless explicitly required.
Permission Initialization Flow
async def init_permissions_from_config(dbname):
# 1. Create convention roles with FIXED IDs
for fixed_id in ['any', 'logined']:
await ensure_role(sor, fixed_id, fixed_id)
# 2. Create defined roles from perm_config.py
for role in ROLES:
await ensure_role(sor, role['id'], role['name'])
# 3. Register paths from PERMISSION_MATRIX
for module, paths in PERMISSION_MATRIX.items():
for path_pattern, role_list in paths.items():
permid = await ensure_permission(sor, path_pattern)
for role_name in role_list:
await grant_permission(sor, role_ids[role_name], permid)
# 4. Register CRUD paths
for module, tables in CRUD_TABLES.items():
for table in tables:
crud_path = f'/{module}/{table}/'
permid = await ensure_permission(sor, crud_path)
ensure_role: Name-based matching for existing roles
When roles are pre-created (e.g., during org setup), they get UUID IDs but with recognizable names. The ensure_role function must match by name first to reuse existing roles instead of creating duplicates:
async def ensure_role(sor, roleid, name, desc=''):
# Try matching by name first (handles both Chinese and English names)
for match_name in [name, roleid]:
recs = await sor.R('role', {'name': match_name})
if recs:
return recs[0].id
# Try matching by ID
recs = await sor.R('role', {'id': roleid})
if recs:
return recs[0].id
# Create new role
await sor.C('role', {'id': roleid, 'orgtypeid': '*', 'name': name})
return roleid
Customer-org role permission sync
Users created within an organization context get UUID-based role IDs (e.g., Eicxrx2f1jElr5OUTAB03 for admin, icKx69-9UXf60zDIll0rg for superuser) with orgtypeid=customer. These are separate from the convention string role IDs (admin, superuser).
After initializing permissions for string role IDs, you must also sync to all orgtypeid=customer roles:
# After regular permission init...
admin_super_perms = await sor.R('rolepermission', {'roleid': role_ids['admin_superuser']})
all_roles = await sor.R('role', {'orgtypeid': 'customer'})
for r in all_roles:
if r.name in ('admin', 'superuser'):
for g in admin_super_perms:
await grant_permission(sor, r.id, g.permid)
Wildcard expansion: ** patterns at init time
RBAC only does exact string matching. ** patterns in perm_config.py must be expanded to actual file paths during init:
import os
def scan_wwwroot(base_dir='wwwroot'):
"""Scan all .ui/.dspy files and return normalized paths."""
paths = set()
for root, dirs, files in os.walk(base_dir):
for f in files:
if f.endswith(('.ui', '.dspy')):
rel = os.path.relpath(os.path.join(root, f), base_dir)
paths.add('/' + rel)
return paths
def expand_wildcard(pattern, all_paths):
if '**' not in pattern:
return {pattern}
prefix = pattern.replace('**', '').rstrip('/')
return {p for p in all_paths if p.startswith(prefix)}
# Usage:
all_paths = scan_wwwroot()
for path_pattern, roles in role_paths.items():
expanded = expand_wildcard(path_pattern, all_paths)
for exact_path in expanded:
# Register both /xxx and /main/xxx variants
register_permission(sor, exact_path, roles)
register_permission(sor, '/main' + exact_path, roles)
init_permissions.py: Wildcard expansion and /main prefix handling
The application's init_permissions.py must handle two critical path transformations at initialization time (since RBAC only does exact string matching):
-
**wildcard expansion: Scanwwwroot/for all.ui/.dspyfiles, then match**patterns against actual file paths. Register the expanded exact paths to the database. -
/mainprefix duplication: URLs come in with/mainprefix (e.g.,/main/customer_management/...) butperm_config.pydefines paths without it (e.g.,/customer_management/...). Register both variants to the DB.
Example init_permissions.py workflow:
import os
async def init_permissions(dbname):
# Step 1: Scan wwwroot for actual file paths
all_paths = set()
for root, dirs, files in os.walk('wwwroot'):
for f in files:
if f.endswith(('.ui', '.dspy')):
rel = os.path.relpath(os.path.join(root, f), 'wwwroot')
all_paths.add('/' + rel)
# Step 2: Expand ** patterns and register with /main prefix
for path_pattern, roles in role_paths.items():
expanded = expand_wildcard(path_pattern, all_paths)
for exact_path in expanded:
permid = await ensure_permission(sor, exact_path, permtype='page')
for role_name in roles:
if role_name in role_ids:
await grant_permission(sor, role_ids[role_name], permid)
# Also register /main variant
main_path = '/main' + exact_path
main_permid = await ensure_permission(sor, main_path, permtype='page')
for role_name in roles:
if role_name in role_ids:
await grant_permission(sor, role_ids[role_name], main_permid)
# Step 3: Ensure convention roles exist (any, logined, anonymous)
# Step 4: Sync customer-org roles (orgtypeid=customer) if applicable
# Step 5: Restart app to reload permission cache
Nginx Production Deployment
Nginx configuration for HTTPS with Let's Encrypt
After deployment, set up nginx as a reverse proxy:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
server_name crm.opencomputing.cn;
listen 443 ssl;
ssl_certificate /etc/letsencrypt/live/crm.opencomputing.cn/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/crm.opencomputing.cn/privkey.pem;
# Redirect root to /main/
location = / {
return 302 /main/;
}
# Redirect /main/ to /main/base.ui (no index.html in wwwroot)
location = /main/ {
return 302 /main/base.ui;
}
# Proxy all other requests to the app
location / {
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Scheme $scheme;
proxy_set_header X-real-ip $remote_addr;
proxy_read_timeout 600s;
proxy_pass http://localhost:8080/;
}
# WebSocket support
location /wss/ {
proxy_pass http://localhost:8080/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 86400;
}
}
server {
server_name crm.opencomputing.cn;
listen 80;
return 301 https://$host$request_uri;
}
Critical: wwwroot has no index.html
The wwwroot/ directory does NOT contain an index.html file. Accessing /main/ directly causes a 500 error ('NoneType' object is not iterable). The nginx redirect from /main/ to /main/base.ui is required.
Testing with curl through HTTPS
# Login
curl -sk -c /tmp/cookies.txt -X POST https://crm.opencomputing.cn/main/rbac/user/up_login.dspy \
-d 'username=superadmin&password=Kyy@123456'
# Access with cookie
curl -sk -b /tmp/cookies.txt https://crm.opencomputing.cn/main/base.ui
Permission init + restart cycle
After modifying init_permissions.py or perm_config.py:
- Stop the app:
pkill -f integrated_crm_app.py - Run init:
python app/init_permissions.py - Start the app:
nohup python app/integrated_crm_app.py --port 8080 > /tmp/crm_app.log 2>&1 & - Wait 5 seconds for startup before testing
The app loads permissions into memory at startup. DB changes alone won't take effect without a restart.
rbac Login Integration
rbac provides complete login/registration in rbac/wwwroot/user/:
login.ui/up_login.dspy— login page and handlerregister.ui/register.dspy— registrationlogout.dspy— logoutuserinfo.ui— user profile
Never create duplicate login files — just symlink rbac's wwwroot.
First User Setup
After deployment, create the initial admin:
- Register via
http://host:port/user/register.ui - Assign admin_superuser role:
INSERT INTO userroles (userid, roleid) VALUES ('<userid>', 'admin_superuser');
Sage App Start/Stop Scripts
Sage applications use start.sh and stop.sh in the app root directory for lifecycle management:
start.sh Pattern — Multi-Process Support
Modern Sage start.sh supports multi-process deployment:
- Reads CPU core count via
nprocand launches one worker per core - Each worker gets a unique port:
base_port + worker_index(base fromconf/config.json→website.port) - All worker PIDs are written to
sage.pid(one per line) - Per-worker logs:
logs/sage_worker_N.log - Uses virtualenv Python:
./py3/bin/python app/sage.py --workdir "$WORKDIR" --port $PORT - Checks for Redis (session storage dependency) — starts if not running:
redis-server --daemonize yes
Legacy single-process start.sh simply launches one process on the configured port. Both patterns work; multi-process is for production scaling.
stop.sh Pattern — Multi-Process Support
- Reads ALL PIDs from
sage.pid(one per line) - Sends SIGTERM to each worker, waits up to 10 seconds, then SIGKILL
- Falls back to process name search (
ps aux | grep "app/sage.py") if PID file missing - Cleans up
sage.pid
Password Encoding
For updating user passwords in the database:
from ahserver.globalEnv import password_encode
encoded = password_encode('plain_text_password')
# Then: sor.U('users', {'id': user_id, 'password': encoded})
Module pyproject.toml Dependencies
Business modules must declare correct dependencies in pyproject.toml:
- Use
"sqlor"(NOT"sqlor-database-module") - Use
"bricks_for_python"(NOT"bricks-framework") - Do NOT include foundation packages (
ahserver,appbase,rbac,apppublic) — these are installed bybuild.sh build.shalso installsbricks_for_pythonvia:pip install git+https://git.opencomputing.cn/yumoqing/bricks-for-python
Example:
[project]
name = "customer_management"
dependencies = [
"sqlor",
"bricks_for_python",
]
Pipeline App Deployment (pipeline.opencomputing.cn)
Directory Structure
~/pipeline-app/ # Source (git clone)
├── build.sh # Creates independent venv, clones deps to pkgs/
├── start.sh / stop.sh # Lifecycle
├── app/ # Application code (pipeline_app.py)
├── conf/config.json # Runtime config
├── wwwroot/ # Web files (bricks UI, dspy)
├── pkgs/ # Cloned dependencies
│ ├── appbase/ # Must be cloned (provides get_code.dspy + appcodes_kv)
│ ├── bricks/ # Frontend framework
│ └── pipeline-sdlc/ # SDLC module with wwwroot
├── pipeline_core/ # Business module (pip install .)
├── pipeline_ops/
└── py3/ # Independent venv (NOT Sage's py3)
config.json Requirements
Must be copied from Sage and adjusted:
password_key— MUST match Sage's key (used for AES password encrypt/decrypt)databases.pipeline— driver:aiosqlor, encrypted passworddatabases.sage— driver:mysql, plaintext password (shared rbac/permissions)session— Redis config:{"storage":"redis","redis_url":"redis://localhost:6379/1"}website.processors— must include dspy/ui processors from Sage config
build.sh Key Steps
- Create independent venv (
python3 -m venv py3) - Clone ALL shared packages to
pkgs/(apppublic, sqlor, ahserver, rbac, appbase, etc.) - Build bricks frontend (
pkgs/bricks/bricks/build.sh) - Symlink
bricks -> pkgs/bricks/dist - Clone business modules to
pkgs/(pipeline-sdlc, showcase) - Install all modules with
pip install . - Generate CRUD from models using
xls2ui
start.sh / stop.sh
Same pattern as Sage: source py3/bin/activate && python app/pipeline_app.py -p 9090 -w .
Cold Start 500
First request after restart may return 500 due to reuse_port and event loop warmup. Retry 2-3 times.
See also: references/pipeline-permissions.md for RBAC wildcard/trailing-slash/underscore path rules.
Common Pitfalls
- pyproject.toml declares non-existent PyPI packages (
sqlor-database-module,bricks-framework,ahserver) →pip install .fails - Creating separate rbac database (wrong — use single DB)
- Using dots in role IDs (conflicts with display format)
- Using getID() for convention roles (rbac hardcodes the IDs)
- Creating duplicate login/register files (rbac already provides these)
- Hardcoding DB credentials in build.sh (use interactive prompts)
- Missing symlink for wwwroot/bricks (needed for frontend)
.dspyfiles using JavaScripttrue/falseinstead of PythonTrue/False→ causesNameErrorat runtime. Fix:sed -i 's/: true$/: True/; s/: true,/: True,/; s/: false$/: False/; s/: false,/: False,/' *.dspy- Permission paths in DB don't match actual URL paths → check
init_permissions.pypath generation vsconfig.jsonwebsite.paths mapping - rbac
anonymousrole has no permissions → login page returns 401 even for unauthenticated access - Missing wwwroot symlinks for modules → all requests return 404 or "invalid path" errors
Multi-Process Deployment
See references/sage-multi-process.md for the complete Sage multi-process deployment pattern, including nproc-based worker scaling, per-worker logging, and nginx integration.