storage_mgr v0.1: shared storage management module
This commit is contained in:
parent
443ca4040b
commit
2efc52b737
22
json/storage_export_list.json
Normal file
22
json/storage_export_list.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
21
json/storage_mount_list.json
Normal file
21
json/storage_mount_list.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
22
json/storage_server_list.json
Normal file
22
json/storage_server_list.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
26
models/storage_export.json
Normal file
26
models/storage_export.json
Normal file
@ -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'"}
|
||||
]
|
||||
}
|
||||
27
models/storage_mount.json
Normal file
27
models/storage_mount.json
Normal file
@ -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'"}
|
||||
]
|
||||
}
|
||||
24
models/storage_server.json
Normal file
24
models/storage_server.json
Normal file
@ -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'"}
|
||||
]
|
||||
}
|
||||
70
scripts/load_path.py
Normal file
70
scripts/load_path.py
Normal file
@ -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.")
|
||||
8
setup.json
Normal file
8
setup.json
Normal file
@ -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"
|
||||
}
|
||||
188
storage_mgr/__init__.py
Normal file
188
storage_mgr/__init__.py
Normal file
@ -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}
|
||||
3
wwwroot/api/storage_mount_exec.dspy
Normal file
3
wwwroot/api/storage_mount_exec.dspy
Normal file
@ -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)}}
|
||||
3
wwwroot/api/storage_quota_set.dspy
Normal file
3
wwwroot/api/storage_quota_set.dspy
Normal file
@ -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)}}
|
||||
3
wwwroot/api/storage_umount_exec.dspy
Normal file
3
wwwroot/api/storage_umount_exec.dspy
Normal file
@ -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)}}
|
||||
Loading…
x
Reference in New Issue
Block a user