--- name: hermes-app-deploy title: Hermes Application Automated Deployment description: 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: ```bash 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: ```bash 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: ```bash 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: ```json { "summary": [{"name": "table_name", "title": "说明", "primary": "id", "catelog": "entity"}], "fields": [{"name": "id", "type": "str", "length": 32, ...}] } ``` Common issues that cause DDL generation failure: - `summary` is a dict instead of list → fix: convert to `[{"name": ..., "title": ..., "primary": "id"}]` - `summary` missing `primary` key → add `"primary": "id"` - Fields missing `id` field → every table must have an `id` field as primary key ## Permission Configuration ### perm_config.py Structure ```python # 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: ```python 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 ```python 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: ```python 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: ```python # 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: ```python 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): 1. **`**` wildcard expansion**: Scan `wwwroot/` for all `.ui`/`.dspy` files, then match `**` patterns against actual file paths. Register the expanded exact paths to the database. 2. **`/main` prefix duplication**: URLs come in with `/main` prefix (e.g., `/main/customer_management/...`) but `perm_config.py` defines paths without it (e.g., `/customer_management/...`). Register both variants to the DB. Example `init_permissions.py` workflow: ```python 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: ```nginx 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 ```bash # 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`: 1. Stop the app: `pkill -f integrated_crm_app.py` 2. Run init: `python app/init_permissions.py` 3. Start the app: `nohup python app/integrated_crm_app.py --port 8080 > /tmp/crm_app.log 2>&1 &` 4. 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 handler - `register.ui` / `register.dspy` — registration - `logout.dspy` — logout - `userinfo.ui` — user profile **Never create duplicate login files** — just symlink rbac's wwwroot. ## First User Setup After deployment, create the initial admin: 1. Register via `http://host:port/user/register.ui` 2. Assign admin_superuser role: ```sql INSERT INTO userroles (userid, roleid) VALUES ('', '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 `nproc` and launches one worker per core - Each worker gets a unique port: `base_port + worker_index` (base from `conf/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: ```python 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 by `build.sh` - `build.sh` also installs `bricks_for_python` via: `pip install git+https://git.opencomputing.cn/yumoqing/bricks-for-python` Example: ```toml [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 password - `databases.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 1. Create independent venv (`python3 -m venv py3`) 2. Clone ALL shared packages to `pkgs/` (apppublic, sqlor, ahserver, rbac, appbase, etc.) 3. Build bricks frontend (`pkgs/bricks/bricks/build.sh`) 4. Symlink `bricks -> pkgs/bricks/dist` 5. Clone business modules to `pkgs/` (pipeline-sdlc, showcase) 6. Install all modules with `pip install .` 7. 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) - `.dspy` files using JavaScript `true`/`false` instead of Python `True`/`False` → causes `NameError` at 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.py` path generation vs `config.json` website.paths mapping - rbac `anonymous` role 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.