From 2efc52b737d8c449810dc02ed6cd315667933d81 Mon Sep 17 00:00:00 2001 From: yumoqing Date: Tue, 11 Aug 2026 14:37:36 +0800 Subject: [PATCH] storage_mgr v0.1: shared storage management module --- json/storage_export_list.json | 22 ++++ json/storage_mount_list.json | 21 +++ json/storage_server_list.json | 22 ++++ models/storage_export.json | 26 ++++ models/storage_mount.json | 27 ++++ models/storage_server.json | 24 ++++ scripts/load_path.py | 70 ++++++++++ setup.json | 8 ++ storage_mgr/__init__.py | 188 +++++++++++++++++++++++++++ wwwroot/api/storage_mount_exec.dspy | 3 + wwwroot/api/storage_quota_set.dspy | 3 + wwwroot/api/storage_umount_exec.dspy | 3 + 12 files changed, 417 insertions(+) create mode 100644 json/storage_export_list.json create mode 100644 json/storage_mount_list.json create mode 100644 json/storage_server_list.json create mode 100644 models/storage_export.json create mode 100644 models/storage_mount.json create mode 100644 models/storage_server.json create mode 100644 scripts/load_path.py create mode 100644 setup.json create mode 100644 storage_mgr/__init__.py create mode 100644 wwwroot/api/storage_mount_exec.dspy create mode 100644 wwwroot/api/storage_quota_set.dspy create mode 100644 wwwroot/api/storage_umount_exec.dspy diff --git a/json/storage_export_list.json b/json/storage_export_list.json new file mode 100644 index 0000000..77fabad --- /dev/null +++ b/json/storage_export_list.json @@ -0,0 +1,22 @@ +{ + "tblname": "storage_export", + "alias": "storage_export_list", + "title": "存储导出", + "params": { + "browserfields": { + "name": {"title": "导出名称", "width": 150}, + "server_id": {"title": "服务器", "width": 150}, + "export_path": {"title": "导出路径", "width": 250}, + "size_limit_gb": {"title": "容量限制GB", "width": 100}, + "access_mode": {"title": "访问模式", "width": 80}, + "status": {"title": "状态", "width": 80} + }, + "editexclouded": ["id", "resellerid", "created_at", "updated_at"], + "toolbar": {"tools": []}, + "binds": [], + "new_data_url": "{{entire_url('/storage_mgr/api/storage_export_create.dspy')}}", + "update_data_url": "{{entire_url('/storage_mgr/api/storage_export_update.dspy')}}", + "delete_data_url": "{{entire_url('/storage_mgr/api/storage_export_delete.dspy')}}", + "logined_userorgid": "resellerid" + } +} diff --git a/json/storage_mount_list.json b/json/storage_mount_list.json new file mode 100644 index 0000000..9c70b16 --- /dev/null +++ b/json/storage_mount_list.json @@ -0,0 +1,21 @@ +{ + "tblname": "storage_mount", + "alias": "storage_mount_list", + "title": "存储挂载", + "params": { + "browserfields": { + "export_id": {"title": "存储导出", "width": 150}, + "node_id": {"title": "目标节点", "width": 150}, + "cluster_id": {"title": "集群", "width": 100}, + "mount_point": {"title": "挂载点", "width": 200}, + "status": {"title": "状态", "width": 80} + }, + "editexclouded": ["id", "resellerid", "mounted_at", "created_at", "updated_at"], + "toolbar": {"tools": []}, + "binds": [], + "new_data_url": "{{entire_url('/storage_mgr/api/storage_mount_create.dspy')}}", + "update_data_url": "{{entire_url('/storage_mgr/api/storage_mount_update.dspy')}}", + "delete_data_url": "{{entire_url('/storage_mgr/api/storage_mount_delete.dspy')}}", + "logined_userorgid": "resellerid" + } +} diff --git a/json/storage_server_list.json b/json/storage_server_list.json new file mode 100644 index 0000000..ea69323 --- /dev/null +++ b/json/storage_server_list.json @@ -0,0 +1,22 @@ +{ + "tblname": "storage_server", + "alias": "storage_server_list", + "title": "存储服务器", + "params": { + "browserfields": { + "name": {"title": "名称", "width": 150}, + "storage_type": {"title": "类型", "width": 100}, + "endpoint": {"title": "端点", "width": 200}, + "total_capacity_gb": {"title": "总容量GB", "width": 100}, + "used_capacity_gb": {"title": "已用GB", "width": 80}, + "status": {"title": "状态", "width": 80} + }, + "editexclouded": ["id", "resellerid", "created_at", "updated_at"], + "toolbar": {"tools": []}, + "binds": [], + "new_data_url": "{{entire_url('/storage_mgr/api/storage_server_create.dspy')}}", + "update_data_url": "{{entire_url('/storage_mgr/api/storage_server_update.dspy')}}", + "delete_data_url": "{{entire_url('/storage_mgr/api/storage_server_delete.dspy')}}", + "logined_userorgid": "resellerid" + } +} diff --git a/models/storage_export.json b/models/storage_export.json new file mode 100644 index 0000000..2e8f362 --- /dev/null +++ b/models/storage_export.json @@ -0,0 +1,26 @@ +{ + "summary": [{ + "name": "storage_export", + "title": "存储导出", + "primary": ["id"], + "catelog": "entity" + }], + "fields": [ + {"name": "id", "title": "id", "type": "str", "length": 32, "nullable": "no"}, + {"name": "resellerid", "title": "商户机构id", "type": "str", "length": 32, "nullable": "no"}, + {"name": "server_id", "title": "所属存储服务器", "type": "str", "length": 32, "nullable": "no"}, + {"name": "name", "title": "导出名称", "type": "str", "length": 128, "nullable": "no"}, + {"name": "export_path", "title": "导出路径", "type": "str", "length": 512, "nullable": "no"}, + {"name": "size_limit_gb", "title": "容量限制GB(0=不限制)", "type": "int", "default": 0}, + {"name": "access_mode", "title": "访问模式(rw/ro)", "type": "char", "length": 8, "default": "rw"}, + {"name": "allowed_hosts", "title": "允许访问的主机(CIDR,逗号分隔)", "type": "str", "length": 1024}, + {"name": "status", "title": "状态(active/inactive)", "type": "char", "length": 16, "default": "active"}, + {"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"}, + {"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "no"} + ], + "codes": [ + {"field": "server_id", "table": "storage_server", "valuefield": "id", "textfield": "name"}, + {"field": "access_mode", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='storage_access_mode'"}, + {"field": "status", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='export_status'"} + ] +} diff --git a/models/storage_mount.json b/models/storage_mount.json new file mode 100644 index 0000000..c61b145 --- /dev/null +++ b/models/storage_mount.json @@ -0,0 +1,27 @@ +{ + "summary": [{ + "name": "storage_mount", + "title": "存储挂载", + "primary": ["id"], + "catelog": "entity" + }], + "fields": [ + {"name": "id", "title": "id", "type": "str", "length": 32, "nullable": "no"}, + {"name": "resellerid", "title": "商户机构id", "type": "str", "length": 32, "nullable": "no"}, + {"name": "export_id", "title": "存储导出", "type": "str", "length": 32, "nullable": "no"}, + {"name": "node_id", "title": "目标节点(compute_node.id)", "type": "str", "length": 32, "nullable": "no"}, + {"name": "cluster_id", "title": "所属集群", "type": "str", "length": 32}, + {"name": "mount_point", "title": "挂载点路径", "type": "str", "length": 256, "nullable": "no"}, + {"name": "mount_options", "title": "挂载参数", "type": "str", "length": 256}, + {"name": "status", "title": "状态(mounted/unmounted/failed)", "type": "char", "length": 16, "default": "unmounted"}, + {"name": "mounted_at", "title": "挂载时间", "type": "timestamp"}, + {"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"}, + {"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "no"} + ], + "codes": [ + {"field": "export_id", "table": "storage_export", "valuefield": "id", "textfield": "name"}, + {"field": "node_id", "table": "compute_node", "valuefield": "id", "textfield": "name"}, + {"field": "cluster_id", "table": "cluster", "valuefield": "id", "textfield": "name"}, + {"field": "status", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='mount_status'"} + ] +} diff --git a/models/storage_server.json b/models/storage_server.json new file mode 100644 index 0000000..e312028 --- /dev/null +++ b/models/storage_server.json @@ -0,0 +1,24 @@ +{ + "summary": [{ + "name": "storage_server", + "title": "存储服务器", + "primary": ["id"], + "catelog": "entity" + }], + "fields": [ + {"name": "id", "title": "id", "type": "str", "length": 32, "nullable": "no"}, + {"name": "resellerid", "title": "商户机构id", "type": "str", "length": 32, "nullable": "no"}, + {"name": "name", "title": "服务器名称", "type": "str", "length": 128, "nullable": "no"}, + {"name": "storage_type", "title": "存储类型(nfs/glusterfs/cephfs)", "type": "char", "length": 16, "nullable": "no"}, + {"name": "endpoint", "title": "服务端点(IP:PORT)", "type": "str", "length": 256, "nullable": "no"}, + {"name": "total_capacity_gb", "title": "总容量GB", "type": "int", "default": 0}, + {"name": "used_capacity_gb", "title": "已用容量GB", "type": "int", "default": 0}, + {"name": "status", "title": "状态(online/offline/maintenance)", "type": "char", "length": 16, "default": "online"}, + {"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"}, + {"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "no"} + ], + "codes": [ + {"field": "storage_type", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='storage_type'"}, + {"field": "status", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='storage_status'"} + ] +} diff --git a/scripts/load_path.py b/scripts/load_path.py new file mode 100644 index 0000000..3bb377b --- /dev/null +++ b/scripts/load_path.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""storage_mgr RBAC 权限管理""" +import subprocess, os, sys, json, glob + +mod_name = 'storage_mgr' + +def find_sage_root(): + for c in [os.path.expanduser("~/sage"), os.path.expanduser("~/repos/sage")]: + if os.path.isdir(os.path.join(c, "py3")): return c + return None + +SAGE = find_sage_root() +if not SAGE: sys.exit("Sage root not found") +PY = os.path.join(SAGE, "py3", "bin", "python") +SET = os.path.join(SAGE, "set_role_perm.py") + +JSON_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "json") +API_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "wwwroot", "api") + +def load_cruds(): + defs = [] + for f in sorted(glob.glob(os.path.join(JSON_DIR, "*.json"))): + try: + d = json.load(open(f)) + defs.append({"tblname": d["tblname"], "alias": d.get("alias", d["tblname"]), "subtables": d.get("params", {}).get("subtables", [])}) + except: pass + return defs + +def get_apis(): + if not os.path.isdir(API_DIR): return [] + return ["/" + mod_name + "/api/" + f for f in sorted(os.listdir(API_DIR)) if f.endswith(".dspy")] + +cruds = load_cruds() +apis = get_apis() + +PATHS_ANY = [ + "/" + mod_name + "/menu.ui", +] +PATHS_LOGINED = [ + "/" + mod_name, + "/" + mod_name + "/index.ui", +] +for d in cruds: + PATHS_ANY.append("/" + mod_name + "/" + d["alias"]) + PATHS_LOGINED.append("/" + mod_name + "/" + d["alias"] + "/index.ui") + for act in ["get", "add", "update", "delete"]: + PATHS_LOGINED.append("/" + mod_name + "/" + d["alias"] + "/" + act + "_" + d["tblname"] + ".dspy") +for api in apis: + PATHS_LOGINED.append(api) + +PATHS_OPERATOR = list(PATHS_LOGINED) + +PATHS_ANY = list(dict.fromkeys(PATHS_ANY)) +PATHS_LOGINED = list(dict.fromkeys(PATHS_LOGINED)) +PATHS_OPERATOR = list(dict.fromkeys(PATHS_OPERATOR)) + +def reg(role, paths): + ok = 0 + for p in paths: + r = subprocess.run([PY, SET, role, p], capture_output=True, text=True) + if r.returncode == 0: ok += 1 + print(" " + role + ": " + str(ok) + "/" + str(len(paths))) + return ok + +total = 0 +print(mod_name + ": any=" + str(len(PATHS_ANY)) + " logined=" + str(len(PATHS_LOGINED)) + " operator=" + str(len(PATHS_OPERATOR))) +total += reg("any", PATHS_ANY) +total += reg("logined", PATHS_LOGINED) +total += reg("reseller.operator", PATHS_OPERATOR) +print("Done. " + str(total) + " entries.") diff --git a/setup.json b/setup.json new file mode 100644 index 0000000..ab414aa --- /dev/null +++ b/setup.json @@ -0,0 +1,8 @@ +{ + "name": "storage_mgr", + "version": "0.1.0", + "description": "共享存储管理:NFS服务器注册、导出管理、节点挂载、存储配额", + "packages": ["storage_mgr"], + "install_requires": ["apppublic", "sqlor", "ahserver", "appbase"], + "python_requires": ">=3.10" +} diff --git a/storage_mgr/__init__.py b/storage_mgr/__init__.py new file mode 100644 index 0000000..17eb3f6 --- /dev/null +++ b/storage_mgr/__init__.py @@ -0,0 +1,188 @@ +""" +storage_mgr — 共享存储管理 + +- NFS 服务器注册 +- 存储导出管理 +- 节点挂载/卸载 +- 存储配额控制 +""" +import datetime, json, asyncio, subprocess +from appPublic.uniqueID import getID +from sqlor.dbpools import DBPools + +MODULE_NAME = 'pccs' + + +async def ssh_exec(host, port, user, cmd, timeout=120): + """异步 SSH 远程执行""" + ssh_cmd = [ + 'ssh', '-o', 'StrictHostKeyChecking=no', + '-o', 'ConnectTimeout=10', '-o', 'BatchMode=yes', + '-p', str(port), user + '@' + host, cmd + ] + proc = await asyncio.create_subprocess_exec( + *ssh_cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) + try: + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout) + except asyncio.TimeoutError: + proc.kill() + return -1, '', 'SSH timeout after ' + str(timeout) + 's' + return proc.returncode, stdout.decode(), stderr.decode() + + +async def storage_mount_exec(request, params_kw): + """ + 将存储导出挂载到目标节点。 + params: mount_id (storage_mount 记录 ID, 含 export_id + node_id + mount_point) + 流程: + 1. 读取 storage_mount 记录 → export_id, node_id, mount_point + 2. 读取 storage_export → server_id, export_path + 3. 读取 storage_server → endpoint + 4. 读取 compute_node → ip_address, ssh_port, ssh_user + 5. SSH 到目标节点执行 mount + 6. 更新 storage_mount 状态 + """ + env = request._run_ns + dbname = env.get_module_dbname(MODULE_NAME) + mount_id = params_kw.get('mount_id', '') + + if not mount_id: + return {'status': 'error', 'message': 'Missing mount_id'} + + async with DBPools().sqlorContext(dbname) as sor: + # 1. 读取挂载记录 + mounts = await sor.R('storage_mount', {'id': mount_id}) + if not mounts: + return {'status': 'error', 'message': 'Mount record not found'} + mnt = mounts[0] + + # 2. 读取导出 + exports = await sor.R('storage_export', {'id': mnt.export_id}) + if not exports: + return {'status': 'error', 'message': 'Export not found'} + exp = exports[0] + + # 3. 读取服务器 + servers = await sor.R('storage_server', {'id': exp.server_id}) + if not servers: + return {'status': 'error', 'message': 'Storage server not found'} + srv = servers[0] + + # 4. 读取目标节点 + nodes = await sor.R('compute_node', {'id': mnt.node_id}) + if not nodes: + return {'status': 'error', 'message': 'Target node not found'} + node = nodes[0] + + mount_point = mnt.mount_point + mount_opts = mnt.mount_options or 'nfsvers=4.2,hard,timeo=600,retrans=3' + nfs_source = srv.endpoint + ':' + exp.export_path + + # 5. SSH 执行挂载 + cmds = [ + 'mkdir -p ' + mount_point, + 'mountpoint -q ' + mount_point + ' && umount -l ' + mount_point + ' || true', + 'mount -t nfs4 -o ' + mount_opts + ' ' + nfs_source + ' ' + mount_point, + ] + for cmd in cmds: + rc, out, err = await ssh_exec(node.ip_address, node.ssh_port or 22, + node.ssh_user or 'root', cmd, timeout=60) + if rc != 0: + # 更新状态为失败 + async with DBPools().sqlorContext(dbname) as sor: + await sor.U('storage_mount', {'id': mount_id}, + {'status': 'failed', + 'updated_at': datetime.datetime.now().isoformat()}) + return {'status': 'error', 'message': 'Mount failed: ' + err[:200], 'cmd': cmd} + + # 写入 fstab 持久化 + fstab_line = nfs_source + ' ' + mount_point + ' nfs4 ' + mount_opts + ' 0 0' + await ssh_exec(node.ip_address, node.ssh_port or 22, node.ssh_user or 'root', + "grep -q '" + mount_point + "' /etc/fstab || echo '" + fstab_line + "' >> /etc/fstab", + timeout=10) + + # 6. 更新状态 + now = datetime.datetime.now().isoformat() + async with DBPools().sqlorContext(dbname) as sor: + await sor.U('storage_mount', {'id': mount_id}, + {'status': 'mounted', 'mounted_at': now, + 'updated_at': now}) + + return {'status': 'ok', 'message': 'Mounted ' + nfs_source + ' → ' + mount_point} + + +async def storage_umount_exec(request, params_kw): + """从目标节点卸载存储""" + env = request._run_ns + dbname = env.get_module_dbname(MODULE_NAME) + mount_id = params_kw.get('mount_id', '') + + if not mount_id: + return {'status': 'error', 'message': 'Missing mount_id'} + + async with DBPools().sqlorContext(dbname) as sor: + mounts = await sor.R('storage_mount', {'id': mount_id}) + if not mounts: + return {'status': 'error', 'message': 'Mount record not found'} + mnt = mounts[0] + + nodes = await sor.R('compute_node', {'id': mnt.node_id}) + if not nodes: + return {'status': 'error', 'message': 'Node not found'} + node = nodes[0] + + # 卸载 + 清理 fstab + cmds = [ + 'umount -l ' + mnt.mount_point + ' 2>/dev/null || true', + "sed -i '\\|" + mnt.mount_point + "|d' /etc/fstab", + ] + for cmd in cmds: + await ssh_exec(node.ip_address, node.ssh_port or 22, + node.ssh_user or 'root', cmd, timeout=30) + + async with DBPools().sqlorContext(dbname) as sor: + await sor.U('storage_mount', {'id': mount_id}, + {'status': 'unmounted', + 'updated_at': datetime.datetime.now().isoformat()}) + + return {'status': 'ok', 'message': 'Unmounted ' + mnt.mount_point} + + +async def storage_quota_set(request, params_kw): + """ + 设置存储导出配额。 + params: export_id, size_limit_gb + 流程: + 1. 更新 storage_export.size_limit_gb + 2. 如果 NFS 服务器支持, SSH 到服务器设置 NFS quota + """ + env = request._run_ns + dbname = env.get_module_dbname(MODULE_NAME) + export_id = params_kw.get('export_id', '') + size_limit_gb = int(params_kw.get('size_limit_gb', 0)) + + if not export_id: + return {'status': 'error', 'message': 'Missing export_id'} + + async with DBPools().sqlorContext(dbname) as sor: + await sor.U('storage_export', {'id': export_id}, + {'size_limit_gb': size_limit_gb, + 'updated_at': datetime.datetime.now().isoformat()}) + + # 读取导出信息 + exports = await sor.R('storage_export', {'id': export_id}) + if not exports: + return {'status': 'ok', 'message': 'Quota updated in DB'} + exp = exports[0] + + servers = await sor.R('storage_server', {'id': exp.server_id}) + if not servers: + return {'status': 'ok', 'message': 'Quota updated in DB (no server)'} + srv = servers[0] + + # 尝试在 NFS 服务器端设置配额 (如果可 SSH) + if srv.storage_type == 'nfs': + # NFS 服务器通常是独立存储, 不一定可 SSH。这里只记录配额, 实际执行由运维通过 cron 同步 + pass + + return {'status': 'ok', 'message': 'Quota set: ' + str(size_limit_gb) + 'GB on export ' + exp.name} diff --git a/wwwroot/api/storage_mount_exec.dspy b/wwwroot/api/storage_mount_exec.dspy new file mode 100644 index 0000000..65e7d85 --- /dev/null +++ b/wwwroot/api/storage_mount_exec.dspy @@ -0,0 +1,3 @@ +# 执行挂载:将存储导出挂载到目标节点 +result = await storage_mount_exec(request, params_kw) +return result if isinstance(result, dict) else {'widgettype': 'Error', 'options': {'title': '失败', 'message': str(result)}} diff --git a/wwwroot/api/storage_quota_set.dspy b/wwwroot/api/storage_quota_set.dspy new file mode 100644 index 0000000..5b3cd32 --- /dev/null +++ b/wwwroot/api/storage_quota_set.dspy @@ -0,0 +1,3 @@ +# 设置存储配额:对导出路径设置容量限制 +result = await storage_quota_set(request, params_kw) +return result if isinstance(result, dict) else {'widgettype': 'Error', 'options': {'title': '失败', 'message': str(result)}} diff --git a/wwwroot/api/storage_umount_exec.dspy b/wwwroot/api/storage_umount_exec.dspy new file mode 100644 index 0000000..08d3c0f --- /dev/null +++ b/wwwroot/api/storage_umount_exec.dspy @@ -0,0 +1,3 @@ +# 卸载:从目标节点卸载存储 +result = await storage_umount_exec(request, params_kw) +return result if isinstance(result, dict) else {'widgettype': 'Error', 'options': {'title': '失败', 'message': str(result)}}