feat: menu/i18n/CRUD/permission/stats/model fixes
This commit is contained in:
parent
38e4330cd4
commit
4e37cb18c0
@ -0,0 +1,697 @@
|
|||||||
|
"""
|
||||||
|
image_mgr — 容器镜像管理
|
||||||
|
|
||||||
|
- 国内镜像源配置 (mirror_source): 阿里云、DaoCloud 等安全可触达节点
|
||||||
|
- 本地镜像仓库 (image_registry): Docker Registry v2 on K8s + NFS CSI
|
||||||
|
- 容器镜像管理 (container_image): 导入/同步/推送/列表/删除
|
||||||
|
- 客户自定义镜像支持
|
||||||
|
"""
|
||||||
|
import datetime, json, asyncio, subprocess
|
||||||
|
from appPublic.uniqueID import getID
|
||||||
|
from sqlor.dbpools import DBPools
|
||||||
|
|
||||||
|
MODULE_NAME = 'pccs'
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
# 预置的国内安全镜像源
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
PRESET_MIRRORS = [
|
||||||
|
{
|
||||||
|
'name': '阿里云容器镜像服务(杭州)',
|
||||||
|
'source_type': 'public_mirror',
|
||||||
|
'registry_url': 'registry.cn-hangzhou.aliyuncs.com',
|
||||||
|
'upstream_url': 'docker.io',
|
||||||
|
'region': 'cn',
|
||||||
|
'priority': 10,
|
||||||
|
'is_default': 1,
|
||||||
|
'note': 'registry.cn-hangzhou.aliyuncs.com/{namespace}/{repo}'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': '阿里云 Google容器镜像',
|
||||||
|
'source_type': 'public_mirror',
|
||||||
|
'registry_url': 'registry.cn-hangzhou.aliyuncs.com/google_containers',
|
||||||
|
'upstream_url': 'k8s.gcr.io',
|
||||||
|
'region': 'cn',
|
||||||
|
'priority': 20,
|
||||||
|
'note': 'K8s 官方镜像国内加速'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'DaoCloud 公共镜像',
|
||||||
|
'source_type': 'public_mirror',
|
||||||
|
'registry_url': 'docker.daocloud.io',
|
||||||
|
'upstream_url': 'docker.io',
|
||||||
|
'region': 'cn',
|
||||||
|
'priority': 15,
|
||||||
|
'note': 'DaoCloud 代理加速'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'DaoCloud GCR 镜像',
|
||||||
|
'source_type': 'public_mirror',
|
||||||
|
'registry_url': 'gcr.daocloud.io',
|
||||||
|
'upstream_url': 'gcr.io',
|
||||||
|
'region': 'cn',
|
||||||
|
'priority': 25,
|
||||||
|
'note': 'gcr.io 国内加速'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'DaoCloud Quay 镜像',
|
||||||
|
'source_type': 'public_mirror',
|
||||||
|
'registry_url': 'quay.daocloud.io',
|
||||||
|
'upstream_url': 'quay.io',
|
||||||
|
'region': 'cn',
|
||||||
|
'priority': 25,
|
||||||
|
'note': 'quay.io 国内加速'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'DaoCloud GHCR 镜像',
|
||||||
|
'source_type': 'public_mirror',
|
||||||
|
'registry_url': 'ghcr.daocloud.io',
|
||||||
|
'upstream_url': 'ghcr.io',
|
||||||
|
'region': 'cn',
|
||||||
|
'priority': 25,
|
||||||
|
'note': 'GitHub Container Registry 国内加速'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': '腾讯云容器镜像',
|
||||||
|
'source_type': 'public_mirror',
|
||||||
|
'registry_url': 'mirror.ccs.tencentyun.com',
|
||||||
|
'upstream_url': 'docker.io',
|
||||||
|
'region': 'cn',
|
||||||
|
'priority': 30,
|
||||||
|
'note': '腾讯云公共镜像加速'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Docker Hub (直连)',
|
||||||
|
'source_type': 'hub',
|
||||||
|
'registry_url': 'docker.io',
|
||||||
|
'upstream_url': '',
|
||||||
|
'region': 'us',
|
||||||
|
'priority': 100,
|
||||||
|
'note': 'Docker官方, 国内可能慢/不可达'
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
# SSH 工具
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async def ssh_exec(host, port, user, cmd, timeout=120):
|
||||||
|
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 _find_control_node(env, cluster_id):
|
||||||
|
"""查找集群活跃控制节点"""
|
||||||
|
dbname = env.get_module_dbname(MODULE_NAME)
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
recs = await sor.R('cluster_node',
|
||||||
|
{'cluster_id': cluster_id, 'role': 'control', 'status': 'active'})
|
||||||
|
if not recs:
|
||||||
|
return None
|
||||||
|
nodes = await sor.R('compute_node', {'id': recs[0].node_id})
|
||||||
|
return nodes[0] if nodes else None
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
# 镜像源管理
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async def mirror_seed(request, params_kw):
|
||||||
|
"""
|
||||||
|
种子预置镜像源到数据库。
|
||||||
|
调用后自动将 PRESET_MIRRORS 写入 mirror_source 表 (跳过已存在的).
|
||||||
|
"""
|
||||||
|
env = request._run_ns
|
||||||
|
dbname = env.get_module_dbname(MODULE_NAME)
|
||||||
|
now = datetime.datetime.now().isoformat()
|
||||||
|
added = 0
|
||||||
|
skipped = 0
|
||||||
|
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
for m in PRESET_MIRRORS:
|
||||||
|
# 检查是否已存在
|
||||||
|
existing = await sor.R('mirror_source',
|
||||||
|
{'registry_url': m['registry_url']})
|
||||||
|
if existing:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
await sor.C('mirror_source', {
|
||||||
|
'id': getID(),
|
||||||
|
'resellerid': '*',
|
||||||
|
'name': m['name'],
|
||||||
|
'source_type': m['source_type'],
|
||||||
|
'registry_url': m['registry_url'],
|
||||||
|
'upstream_url': m.get('upstream_url', ''),
|
||||||
|
'region': m.get('region', 'cn'),
|
||||||
|
'priority': m.get('priority', 100),
|
||||||
|
'is_default': m.get('is_default', 0),
|
||||||
|
'status': 'active',
|
||||||
|
'created_at': now,
|
||||||
|
'updated_at': now,
|
||||||
|
})
|
||||||
|
added += 1
|
||||||
|
|
||||||
|
return {'status': 'ok', 'message': 'Seeded ' + str(added) + ' mirrors, skipped ' + str(skipped)}
|
||||||
|
|
||||||
|
|
||||||
|
async def mirror_health_check(request, params_kw):
|
||||||
|
"""
|
||||||
|
检查镜像源是否可达。
|
||||||
|
尝试对该源执行 docker pull 一个轻量镜像 (如 busybox).
|
||||||
|
params: mirror_id (可选, 不传则检查所有 active 源)
|
||||||
|
"""
|
||||||
|
env = request._run_ns
|
||||||
|
dbname = env.get_module_dbname(MODULE_NAME)
|
||||||
|
mirror_id = params_kw.get('mirror_id', '')
|
||||||
|
cluster_id = params_kw.get('cluster_id', '')
|
||||||
|
|
||||||
|
if not cluster_id:
|
||||||
|
return {'status': 'error', 'message': 'Missing cluster_id (needed for SSH)'}
|
||||||
|
|
||||||
|
cnode = await _find_control_node(env, cluster_id)
|
||||||
|
if not cnode:
|
||||||
|
return {'status': 'error', 'message': 'No active control node found'}
|
||||||
|
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
if mirror_id:
|
||||||
|
mirrors = await sor.R('mirror_source', {'id': mirror_id})
|
||||||
|
else:
|
||||||
|
mirrors = await sor.R('mirror_source', {'status': 'active'})
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for m in mirrors:
|
||||||
|
test_image = m.registry_url + '/library/busybox:latest'
|
||||||
|
if m.source_type == 'public_mirror' and m.upstream_url:
|
||||||
|
# 对于 mirror 类型的源, pull 测试: <mirror_url>/library/busybox:latest
|
||||||
|
test_image = m.registry_url + '/library/busybox:latest'
|
||||||
|
else:
|
||||||
|
test_image = m.registry_url + '/busybox:latest'
|
||||||
|
|
||||||
|
rc, out, err = await ssh_exec(
|
||||||
|
cnode.ip_address, cnode.ssh_port or 22, cnode.ssh_user or 'root',
|
||||||
|
'docker pull ' + test_image + ' 2>&1 && docker rmi ' + test_image + ' 2>/dev/null',
|
||||||
|
timeout=60)
|
||||||
|
reachable = rc == 0
|
||||||
|
results.append({
|
||||||
|
'id': m.id, 'name': m.name, 'registry_url': m.registry_url,
|
||||||
|
'reachable': reachable, 'error': err[:200] if not reachable else ''
|
||||||
|
})
|
||||||
|
|
||||||
|
# 更新最后检查时间
|
||||||
|
await sor.U('mirror_source', {'id': m.id},
|
||||||
|
{'health_check_at': datetime.datetime.now().isoformat(),
|
||||||
|
'status': 'active' if reachable else 'error'})
|
||||||
|
|
||||||
|
return {'status': 'ok', 'results': results}
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
# 镜像仓库部署
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async def registry_deploy(request, params_kw):
|
||||||
|
"""
|
||||||
|
在 K8s 集群上部署本地 Docker Registry v2.
|
||||||
|
params: registry_id (image_registry 记录ID)
|
||||||
|
流程:
|
||||||
|
1. 读取 image_registry 记录
|
||||||
|
2. 读取集群控制节点
|
||||||
|
3. SSH → kubectl apply deployment + service + pvc
|
||||||
|
4. 生成 htpasswd 认证
|
||||||
|
5. 等待就绪 → 更新 endpoint
|
||||||
|
"""
|
||||||
|
env = request._run_ns
|
||||||
|
dbname = env.get_module_dbname(MODULE_NAME)
|
||||||
|
registry_id = params_kw.get('registry_id', '')
|
||||||
|
|
||||||
|
if not registry_id:
|
||||||
|
return {'status': 'error', 'message': 'Missing registry_id'}
|
||||||
|
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
regs = await sor.R('image_registry', {'id': registry_id})
|
||||||
|
if not regs:
|
||||||
|
return {'status': 'error', 'message': 'Registry not found'}
|
||||||
|
reg = regs[0]
|
||||||
|
|
||||||
|
cluster_id = reg.cluster_id
|
||||||
|
if not cluster_id:
|
||||||
|
return {'status': 'error', 'message': 'Registry not bound to a cluster'}
|
||||||
|
|
||||||
|
cnode = await _find_control_node(env, cluster_id)
|
||||||
|
if not cnode:
|
||||||
|
return {'status': 'error', 'message': 'No active control node in cluster ' + cluster_id}
|
||||||
|
|
||||||
|
# 生成密码 (如果未设置)
|
||||||
|
admin_user = reg.admin_user or 'admin'
|
||||||
|
admin_pass = reg.admin_password or 'pcc123456'
|
||||||
|
storage_size = reg.storage_size or '50Gi'
|
||||||
|
storage_class = reg.storage_class or 'pcc-nfs-sc'
|
||||||
|
namespace = 'pcc-registry'
|
||||||
|
|
||||||
|
steps = []
|
||||||
|
|
||||||
|
# 1. 创建 namespace
|
||||||
|
rc, _, _ = await ssh_exec(cnode.ip_address, cnode.ssh_port or 22, cnode.ssh_user or 'root',
|
||||||
|
'kubectl create namespace ' + namespace + ' 2>/dev/null || true')
|
||||||
|
steps.append('ns_created')
|
||||||
|
|
||||||
|
# 2. 创建 htpasswd secret
|
||||||
|
htpasswd_cmd = (
|
||||||
|
'docker run --rm httpd:2-alpine htpasswd -nbB ' + admin_user + ' ' + admin_pass
|
||||||
|
+ ' | base64 -w0'
|
||||||
|
)
|
||||||
|
rc, htpasswd_b64, _ = await ssh_exec(cnode.ip_address, cnode.ssh_port or 22,
|
||||||
|
cnode.ssh_user or 'root', htpasswd_cmd, timeout=60)
|
||||||
|
secret_yaml = (
|
||||||
|
'apiVersion: v1\n'
|
||||||
|
'kind: Secret\n'
|
||||||
|
'metadata:\n'
|
||||||
|
' name: registry-auth\n'
|
||||||
|
' namespace: ' + namespace + '\n'
|
||||||
|
'data:\n'
|
||||||
|
' htpasswd: ' + htpasswd_b64.strip() + '\n'
|
||||||
|
)
|
||||||
|
await ssh_exec(cnode.ip_address, cnode.ssh_port or 22, cnode.ssh_user or 'root',
|
||||||
|
"echo '" + secret_yaml + "' | kubectl apply -f -")
|
||||||
|
steps.append('secret_created')
|
||||||
|
|
||||||
|
# 3. 创建 PVC
|
||||||
|
pvc_yaml = (
|
||||||
|
'apiVersion: v1\n'
|
||||||
|
'kind: PersistentVolumeClaim\n'
|
||||||
|
'metadata:\n'
|
||||||
|
' name: registry-storage\n'
|
||||||
|
' namespace: ' + namespace + '\n'
|
||||||
|
'spec:\n'
|
||||||
|
' accessModes:\n'
|
||||||
|
' - ReadWriteMany\n'
|
||||||
|
' storageClassName: ' + storage_class + '\n'
|
||||||
|
' resources:\n'
|
||||||
|
' requests:\n'
|
||||||
|
' storage: ' + storage_size + '\n'
|
||||||
|
)
|
||||||
|
await ssh_exec(cnode.ip_address, cnode.ssh_port or 22, cnode.ssh_user or 'root',
|
||||||
|
"echo '" + pvc_yaml + "' | kubectl apply -f -")
|
||||||
|
steps.append('pvc_created')
|
||||||
|
|
||||||
|
# 4. 创建 Deployment
|
||||||
|
deploy_yaml = (
|
||||||
|
'apiVersion: apps/v1\n'
|
||||||
|
'kind: Deployment\n'
|
||||||
|
'metadata:\n'
|
||||||
|
' name: registry\n'
|
||||||
|
' namespace: ' + namespace + '\n'
|
||||||
|
'spec:\n'
|
||||||
|
' replicas: 1\n'
|
||||||
|
' selector:\n'
|
||||||
|
' matchLabels:\n'
|
||||||
|
' app: registry\n'
|
||||||
|
' template:\n'
|
||||||
|
' metadata:\n'
|
||||||
|
' labels:\n'
|
||||||
|
' app: registry\n'
|
||||||
|
' spec:\n'
|
||||||
|
' containers:\n'
|
||||||
|
' - name: registry\n'
|
||||||
|
' image: registry:2\n'
|
||||||
|
' ports:\n'
|
||||||
|
' - containerPort: 5000\n'
|
||||||
|
' env:\n'
|
||||||
|
' - name: REGISTRY_AUTH\n'
|
||||||
|
' value: htpasswd\n'
|
||||||
|
' - name: REGISTRY_AUTH_HTPASSWD_REALM\n'
|
||||||
|
' value: Registry Realm\n'
|
||||||
|
' - name: REGISTRY_AUTH_HTPASSWD_PATH\n'
|
||||||
|
' value: /auth/htpasswd\n'
|
||||||
|
' - name: REGISTRY_STORAGE_DELETE_ENABLED\n'
|
||||||
|
' value: "true"\n'
|
||||||
|
' volumeMounts:\n'
|
||||||
|
' - name: storage\n'
|
||||||
|
' mountPath: /var/lib/registry\n'
|
||||||
|
' - name: auth\n'
|
||||||
|
' mountPath: /auth\n'
|
||||||
|
' volumes:\n'
|
||||||
|
' - name: storage\n'
|
||||||
|
' persistentVolumeClaim:\n'
|
||||||
|
' claimName: registry-storage\n'
|
||||||
|
' - name: auth\n'
|
||||||
|
' secret:\n'
|
||||||
|
' secretName: registry-auth\n'
|
||||||
|
)
|
||||||
|
await ssh_exec(cnode.ip_address, cnode.ssh_port or 22, cnode.ssh_user or 'root',
|
||||||
|
"echo '" + deploy_yaml + "' | kubectl apply -f -")
|
||||||
|
steps.append('deploy_created')
|
||||||
|
|
||||||
|
# 5. 创建 Service (NodePort)
|
||||||
|
svc_yaml = (
|
||||||
|
'apiVersion: v1\n'
|
||||||
|
'kind: Service\n'
|
||||||
|
'metadata:\n'
|
||||||
|
' name: registry\n'
|
||||||
|
' namespace: ' + namespace + '\n'
|
||||||
|
'spec:\n'
|
||||||
|
' type: NodePort\n'
|
||||||
|
' selector:\n'
|
||||||
|
' app: registry\n'
|
||||||
|
' ports:\n'
|
||||||
|
' - port: 5000\n'
|
||||||
|
' targetPort: 5000\n'
|
||||||
|
' nodePort: 30500\n'
|
||||||
|
)
|
||||||
|
await ssh_exec(cnode.ip_address, cnode.ssh_port or 22, cnode.ssh_user or 'root',
|
||||||
|
"echo '" + svc_yaml + "' | kubectl apply -f -")
|
||||||
|
steps.append('svc_created')
|
||||||
|
|
||||||
|
# 6. 等待就绪
|
||||||
|
rc, _, _ = await ssh_exec(cnode.ip_address, cnode.ssh_port or 22, cnode.ssh_user or 'root',
|
||||||
|
'kubectl wait --for=condition=available deployment/registry -n '
|
||||||
|
+ namespace + ' --timeout=120s', timeout=150)
|
||||||
|
|
||||||
|
# 7. 获取 NodePort 地址 → endpoint
|
||||||
|
endpoint = cnode.ip_address + ':30500'
|
||||||
|
internal_endpoint = 'registry.' + namespace + '.svc.cluster.local:5000'
|
||||||
|
|
||||||
|
now = datetime.datetime.now().isoformat()
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
await sor.U('image_registry', {'id': registry_id}, {
|
||||||
|
'endpoint': endpoint,
|
||||||
|
'internal_endpoint': internal_endpoint,
|
||||||
|
'status': 'running',
|
||||||
|
'deploy_config': json.dumps({
|
||||||
|
'namespace': namespace,
|
||||||
|
'node_port': 30500,
|
||||||
|
'steps': steps,
|
||||||
|
}),
|
||||||
|
'updated_at': now,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'status': 'ok',
|
||||||
|
'message': 'Registry deployed at ' + endpoint,
|
||||||
|
'endpoint': endpoint,
|
||||||
|
'internal': internal_endpoint,
|
||||||
|
'admin_user': admin_user,
|
||||||
|
'steps': steps,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
# 镜像操作
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async def image_import(request, params_kw):
|
||||||
|
"""
|
||||||
|
从外部镜像源导入镜像到本地仓库。
|
||||||
|
params: registry_id, source_image, target_name, target_tag
|
||||||
|
流程:
|
||||||
|
1. SSH 到控制节点
|
||||||
|
2. docker pull <source_image> (通过配置的镜像加速/代理)
|
||||||
|
3. docker tag <source_image> <local_registry>/<target_name>:<target_tag>
|
||||||
|
4. docker login <local_registry>
|
||||||
|
5. docker push <local_registry>/<target_name>:<target_tag>
|
||||||
|
6. 写入 container_image 表
|
||||||
|
"""
|
||||||
|
env = request._run_ns
|
||||||
|
dbname = env.get_module_dbname(MODULE_NAME)
|
||||||
|
registry_id = params_kw.get('registry_id', '')
|
||||||
|
source_image = params_kw.get('source_image', '')
|
||||||
|
target_name = params_kw.get('target_name', '')
|
||||||
|
target_tag = params_kw.get('target_tag', 'latest')
|
||||||
|
image_type = params_kw.get('image_type', 'custom')
|
||||||
|
customer_id = params_kw.get('customer_id', '')
|
||||||
|
|
||||||
|
if not registry_id or not source_image:
|
||||||
|
return {'status': 'error', 'message': 'Missing registry_id or source_image'}
|
||||||
|
|
||||||
|
if not target_name:
|
||||||
|
# 从 source_image 提取镜像名
|
||||||
|
target_name = source_image.split('/')[-1].split(':')[0]
|
||||||
|
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
regs = await sor.R('image_registry', {'id': registry_id})
|
||||||
|
if not regs:
|
||||||
|
return {'status': 'error', 'message': 'Registry not found'}
|
||||||
|
reg = regs[0]
|
||||||
|
|
||||||
|
cnode = await _find_control_node(env, reg.cluster_id)
|
||||||
|
if not cnode:
|
||||||
|
return {'status': 'error', 'message': 'No control node found'}
|
||||||
|
|
||||||
|
local_registry = reg.internal_endpoint or reg.endpoint
|
||||||
|
target_full = local_registry + '/' + target_name + ':' + target_tag
|
||||||
|
|
||||||
|
steps = []
|
||||||
|
|
||||||
|
# 1. docker pull (from external source / mirror)
|
||||||
|
rc, out, err = await ssh_exec(cnode.ip_address, cnode.ssh_port or 22,
|
||||||
|
cnode.ssh_user or 'root',
|
||||||
|
'docker pull ' + source_image, timeout=300)
|
||||||
|
if rc != 0:
|
||||||
|
return {'status': 'error', 'message': 'Pull failed: ' + err[:300], 'steps': steps}
|
||||||
|
steps.append('pulled ' + source_image)
|
||||||
|
|
||||||
|
# 2. docker tag
|
||||||
|
rc, _, err = await ssh_exec(cnode.ip_address, cnode.ssh_port or 22,
|
||||||
|
cnode.ssh_user or 'root',
|
||||||
|
'docker tag ' + source_image + ' ' + target_full, timeout=30)
|
||||||
|
if rc != 0:
|
||||||
|
return {'status': 'error', 'message': 'Tag failed: ' + err[:200], 'steps': steps}
|
||||||
|
steps.append('tagged → ' + target_full)
|
||||||
|
|
||||||
|
# 3. docker login + push
|
||||||
|
login_cmd = ('docker login ' + local_registry
|
||||||
|
+ ' -u ' + (reg.admin_user or 'admin')
|
||||||
|
+ ' -p ' + (reg.admin_password or 'pcc123456'))
|
||||||
|
await ssh_exec(cnode.ip_address, cnode.ssh_port or 22, cnode.ssh_user or 'root',
|
||||||
|
login_cmd, timeout=30)
|
||||||
|
|
||||||
|
rc, out, err = await ssh_exec(cnode.ip_address, cnode.ssh_port or 22,
|
||||||
|
cnode.ssh_user or 'root',
|
||||||
|
'docker push ' + target_full, timeout=300)
|
||||||
|
if rc != 0:
|
||||||
|
return {'status': 'error', 'message': 'Push failed: ' + err[:300], 'steps': steps}
|
||||||
|
steps.append('pushed')
|
||||||
|
|
||||||
|
# 4. 获取镜像 digest 和 size
|
||||||
|
rc, inspect_out, _ = await ssh_exec(cnode.ip_address, cnode.ssh_port or 22,
|
||||||
|
cnode.ssh_user or 'root',
|
||||||
|
"docker inspect --format='{{.Id}} {{.Size}}' "
|
||||||
|
+ target_full, timeout=30)
|
||||||
|
digest = ''
|
||||||
|
size_bytes = 0
|
||||||
|
if rc == 0 and inspect_out.strip():
|
||||||
|
parts = inspect_out.strip().split()
|
||||||
|
digest = parts[0] if parts else ''
|
||||||
|
try:
|
||||||
|
size_bytes = int(parts[1]) if len(parts) > 1 else 0
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 5. 清理本地镜像 (可选, 节省控制节点磁盘)
|
||||||
|
await ssh_exec(cnode.ip_address, cnode.ssh_port or 22, cnode.ssh_user or 'root',
|
||||||
|
'docker rmi ' + source_image + ' ' + target_full + ' 2>/dev/null || true',
|
||||||
|
timeout=30)
|
||||||
|
|
||||||
|
# 6. 写入 DB
|
||||||
|
now = datetime.datetime.now().isoformat()
|
||||||
|
image_id = getID()
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
await sor.C('container_image', {
|
||||||
|
'id': image_id,
|
||||||
|
'resellerid': reg.resellerid or '*',
|
||||||
|
'registry_id': registry_id,
|
||||||
|
'image_name': target_name,
|
||||||
|
'image_tag': target_tag,
|
||||||
|
'full_name': target_name + ':' + target_tag,
|
||||||
|
'source_image': source_image,
|
||||||
|
'size_bytes': size_bytes,
|
||||||
|
'digest': digest,
|
||||||
|
'image_type': image_type,
|
||||||
|
'is_customer': 1 if customer_id else 0,
|
||||||
|
'customer_id': customer_id or '',
|
||||||
|
'sync_status': 'synced',
|
||||||
|
'sync_log': json.dumps({'steps': steps, 'digest': digest}),
|
||||||
|
'created_at': now,
|
||||||
|
'updated_at': now,
|
||||||
|
})
|
||||||
|
|
||||||
|
# 更新仓库统计
|
||||||
|
await sor.sqlExe(
|
||||||
|
'UPDATE image_registry SET image_count = image_count + 1, '
|
||||||
|
'total_size_gb = total_size_gb + ${sz}$, updated_at = ${now}$ WHERE id = ${rid}$',
|
||||||
|
{'sz': max(1, size_bytes // (1024 * 1024 * 1024)),
|
||||||
|
'now': now, 'rid': registry_id})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'status': 'ok',
|
||||||
|
'message': 'Imported ' + target_full,
|
||||||
|
'image_id': image_id,
|
||||||
|
'full_name': target_name + ':' + target_tag,
|
||||||
|
'size_bytes': size_bytes,
|
||||||
|
'steps': steps,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def image_sync(request, params_kw):
|
||||||
|
"""
|
||||||
|
同步镜像:从镜像源批量拉取 → 推送到本地仓库。
|
||||||
|
params: registry_id, mirror_id, image_list (逗号分隔的镜像名列表)
|
||||||
|
"""
|
||||||
|
env = request._run_ns
|
||||||
|
dbname = env.get_module_dbname(MODULE_NAME)
|
||||||
|
registry_id = params_kw.get('registry_id', '')
|
||||||
|
mirror_id = params_kw.get('mirror_id', '')
|
||||||
|
image_list = params_kw.get('image_list', '')
|
||||||
|
|
||||||
|
if not registry_id:
|
||||||
|
return {'status': 'error', 'message': 'Missing registry_id'}
|
||||||
|
|
||||||
|
images = [i.strip() for i in image_list.split(',') if i.strip()] if image_list else []
|
||||||
|
if not images:
|
||||||
|
# 默认同步一批常用镜像
|
||||||
|
images = [
|
||||||
|
'busybox:latest',
|
||||||
|
'alpine:latest',
|
||||||
|
'nginx:latest',
|
||||||
|
'redis:7-alpine',
|
||||||
|
'python:3.11-slim',
|
||||||
|
]
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for img in images:
|
||||||
|
# 构建 source image 路径
|
||||||
|
source = img
|
||||||
|
if mirror_id:
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
mirrors = await sor.R('mirror_source', {'id': mirror_id})
|
||||||
|
if mirrors:
|
||||||
|
m = mirrors[0]
|
||||||
|
if m.upstream_url and not img.startswith(m.registry_url):
|
||||||
|
source = m.registry_url + '/' + img
|
||||||
|
|
||||||
|
name = img.split(':')[0].replace('/', '_')
|
||||||
|
tag = img.split(':')[1] if ':' in img else 'latest'
|
||||||
|
|
||||||
|
result = await image_import(request, {
|
||||||
|
'registry_id': registry_id,
|
||||||
|
'source_image': source,
|
||||||
|
'target_name': name,
|
||||||
|
'target_tag': tag,
|
||||||
|
'image_type': 'base',
|
||||||
|
})
|
||||||
|
results.append({'image': img, 'result': result.get('status', 'unknown')})
|
||||||
|
|
||||||
|
return {'status': 'ok', 'synced': len(results), 'results': results}
|
||||||
|
|
||||||
|
|
||||||
|
async def image_push(request, params_kw):
|
||||||
|
"""
|
||||||
|
客户推送自定义镜像到本地仓库。
|
||||||
|
params: registry_id, image_name, image_tag, dockerfile_path (可选)
|
||||||
|
流程:
|
||||||
|
1. SSH 到客户提供的工作节点 (或有 docker 的环境)
|
||||||
|
2. docker build -t <target> . (如果有 Dockerfile)
|
||||||
|
3. docker push <target>
|
||||||
|
4. 记录到 container_image 表 (is_customer=1)
|
||||||
|
"""
|
||||||
|
env = request._run_ns
|
||||||
|
dbname = env.get_module_dbname(MODULE_NAME)
|
||||||
|
registry_id = params_kw.get('registry_id', '')
|
||||||
|
image_name = params_kw.get('image_name', '')
|
||||||
|
image_tag = params_kw.get('image_tag', 'latest')
|
||||||
|
customer_id = params_kw.get('customer_id', '')
|
||||||
|
node_id = params_kw.get('node_id', '') # 客户构建节点
|
||||||
|
|
||||||
|
if not registry_id or not image_name:
|
||||||
|
return {'status': 'error', 'message': 'Missing registry_id or image_name'}
|
||||||
|
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
regs = await sor.R('image_registry', {'id': registry_id})
|
||||||
|
if not regs:
|
||||||
|
return {'status': 'error', 'message': 'Registry not found'}
|
||||||
|
reg = regs[0]
|
||||||
|
|
||||||
|
# 确定构建节点: 优先使用客户指定 node, 否则用控制节点
|
||||||
|
build_node = None
|
||||||
|
if node_id:
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
nodes = await sor.R('compute_node', {'id': node_id})
|
||||||
|
if nodes:
|
||||||
|
build_node = nodes[0]
|
||||||
|
if not build_node:
|
||||||
|
build_node = await _find_control_node(env, reg.cluster_id)
|
||||||
|
if not build_node:
|
||||||
|
return {'status': 'error', 'message': 'No build node available'}
|
||||||
|
|
||||||
|
local_registry = reg.internal_endpoint or reg.endpoint
|
||||||
|
target_full = local_registry + '/' + image_name + ':' + image_tag
|
||||||
|
|
||||||
|
# docker login
|
||||||
|
login_cmd = ('docker login ' + local_registry
|
||||||
|
+ ' -u ' + (reg.admin_user or 'admin')
|
||||||
|
+ ' -p ' + (reg.admin_password or 'pcc123456'))
|
||||||
|
await ssh_exec(build_node.ip_address, build_node.ssh_port or 22,
|
||||||
|
build_node.ssh_user or 'root', login_cmd, timeout=30)
|
||||||
|
|
||||||
|
# docker push (假设客户已在节点上构建好镜像)
|
||||||
|
rc, out, err = await ssh_exec(build_node.ip_address, build_node.ssh_port or 22,
|
||||||
|
build_node.ssh_user or 'root',
|
||||||
|
'docker push ' + target_full, timeout=300)
|
||||||
|
if rc != 0:
|
||||||
|
return {'status': 'error', 'message': 'Push failed: ' + err[:300]}
|
||||||
|
|
||||||
|
# 记录
|
||||||
|
now = datetime.datetime.now().isoformat()
|
||||||
|
image_id = getID()
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
await sor.C('container_image', {
|
||||||
|
'id': image_id,
|
||||||
|
'resellerid': reg.resellerid or '*',
|
||||||
|
'registry_id': registry_id,
|
||||||
|
'image_name': image_name,
|
||||||
|
'image_tag': image_tag,
|
||||||
|
'full_name': image_name + ':' + image_tag,
|
||||||
|
'source_image': target_full,
|
||||||
|
'image_type': 'custom',
|
||||||
|
'is_customer': 1,
|
||||||
|
'customer_id': customer_id or '',
|
||||||
|
'sync_status': 'synced',
|
||||||
|
'created_at': now,
|
||||||
|
'updated_at': now,
|
||||||
|
})
|
||||||
|
await sor.sqlExe(
|
||||||
|
'UPDATE image_registry SET image_count = image_count + 1, '
|
||||||
|
'updated_at = ${now}$ WHERE id = ${rid}$',
|
||||||
|
{'now': now, 'rid': registry_id})
|
||||||
|
|
||||||
|
return {'status': 'ok', 'message': 'Pushed ' + target_full, 'image_id': image_id}
|
||||||
|
|
||||||
|
async def image_stats(request, params_kw):
|
||||||
|
"""获取镜像统计"""
|
||||||
|
env = request._run_ns
|
||||||
|
dbname = env.get_module_dbname('image_mgr')
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
mirrors = await sor.R('mirror_source', {})
|
||||||
|
registries = await sor.R('image_registry', {})
|
||||||
|
images = await sor.R('container_image', {})
|
||||||
|
return {'status': 'ok', 'data': {
|
||||||
|
'mirror_count': len(mirrors),
|
||||||
|
'active_mirrors': sum(1 for m in mirrors if getattr(m, 'status', '') == 'active'),
|
||||||
|
'registry_count': len(registries),
|
||||||
|
'running_registries': sum(1 for r in registries if getattr(r, 'status', '') == 'running'),
|
||||||
|
'image_count': len(images),
|
||||||
|
'custom_images': sum(1 for i in images if getattr(i, 'is_custom', '') == '1'),
|
||||||
|
}}
|
||||||
|
|
||||||
697
build/lib/build/lib/build/lib/build/lib/image_mgr/__init__.py
Normal file
697
build/lib/build/lib/build/lib/build/lib/image_mgr/__init__.py
Normal file
@ -0,0 +1,697 @@
|
|||||||
|
"""
|
||||||
|
image_mgr — 容器镜像管理
|
||||||
|
|
||||||
|
- 国内镜像源配置 (mirror_source): 阿里云、DaoCloud 等安全可触达节点
|
||||||
|
- 本地镜像仓库 (image_registry): Docker Registry v2 on K8s + NFS CSI
|
||||||
|
- 容器镜像管理 (container_image): 导入/同步/推送/列表/删除
|
||||||
|
- 客户自定义镜像支持
|
||||||
|
"""
|
||||||
|
import datetime, json, asyncio, subprocess
|
||||||
|
from appPublic.uniqueID import getID
|
||||||
|
from sqlor.dbpools import DBPools
|
||||||
|
|
||||||
|
MODULE_NAME = 'pccs'
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
# 预置的国内安全镜像源
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
PRESET_MIRRORS = [
|
||||||
|
{
|
||||||
|
'name': '阿里云容器镜像服务(杭州)',
|
||||||
|
'source_type': 'public_mirror',
|
||||||
|
'registry_url': 'registry.cn-hangzhou.aliyuncs.com',
|
||||||
|
'upstream_url': 'docker.io',
|
||||||
|
'region': 'cn',
|
||||||
|
'priority': 10,
|
||||||
|
'is_default': 1,
|
||||||
|
'note': 'registry.cn-hangzhou.aliyuncs.com/{namespace}/{repo}'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': '阿里云 Google容器镜像',
|
||||||
|
'source_type': 'public_mirror',
|
||||||
|
'registry_url': 'registry.cn-hangzhou.aliyuncs.com/google_containers',
|
||||||
|
'upstream_url': 'k8s.gcr.io',
|
||||||
|
'region': 'cn',
|
||||||
|
'priority': 20,
|
||||||
|
'note': 'K8s 官方镜像国内加速'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'DaoCloud 公共镜像',
|
||||||
|
'source_type': 'public_mirror',
|
||||||
|
'registry_url': 'docker.daocloud.io',
|
||||||
|
'upstream_url': 'docker.io',
|
||||||
|
'region': 'cn',
|
||||||
|
'priority': 15,
|
||||||
|
'note': 'DaoCloud 代理加速'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'DaoCloud GCR 镜像',
|
||||||
|
'source_type': 'public_mirror',
|
||||||
|
'registry_url': 'gcr.daocloud.io',
|
||||||
|
'upstream_url': 'gcr.io',
|
||||||
|
'region': 'cn',
|
||||||
|
'priority': 25,
|
||||||
|
'note': 'gcr.io 国内加速'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'DaoCloud Quay 镜像',
|
||||||
|
'source_type': 'public_mirror',
|
||||||
|
'registry_url': 'quay.daocloud.io',
|
||||||
|
'upstream_url': 'quay.io',
|
||||||
|
'region': 'cn',
|
||||||
|
'priority': 25,
|
||||||
|
'note': 'quay.io 国内加速'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'DaoCloud GHCR 镜像',
|
||||||
|
'source_type': 'public_mirror',
|
||||||
|
'registry_url': 'ghcr.daocloud.io',
|
||||||
|
'upstream_url': 'ghcr.io',
|
||||||
|
'region': 'cn',
|
||||||
|
'priority': 25,
|
||||||
|
'note': 'GitHub Container Registry 国内加速'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': '腾讯云容器镜像',
|
||||||
|
'source_type': 'public_mirror',
|
||||||
|
'registry_url': 'mirror.ccs.tencentyun.com',
|
||||||
|
'upstream_url': 'docker.io',
|
||||||
|
'region': 'cn',
|
||||||
|
'priority': 30,
|
||||||
|
'note': '腾讯云公共镜像加速'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Docker Hub (直连)',
|
||||||
|
'source_type': 'hub',
|
||||||
|
'registry_url': 'docker.io',
|
||||||
|
'upstream_url': '',
|
||||||
|
'region': 'us',
|
||||||
|
'priority': 100,
|
||||||
|
'note': 'Docker官方, 国内可能慢/不可达'
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
# SSH 工具
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async def ssh_exec(host, port, user, cmd, timeout=120):
|
||||||
|
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 _find_control_node(env, cluster_id):
|
||||||
|
"""查找集群活跃控制节点"""
|
||||||
|
dbname = env.get_module_dbname(MODULE_NAME)
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
recs = await sor.R('cluster_node',
|
||||||
|
{'cluster_id': cluster_id, 'role': 'control', 'status': 'active'})
|
||||||
|
if not recs:
|
||||||
|
return None
|
||||||
|
nodes = await sor.R('compute_node', {'id': recs[0].node_id})
|
||||||
|
return nodes[0] if nodes else None
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
# 镜像源管理
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async def mirror_seed(request, params_kw):
|
||||||
|
"""
|
||||||
|
种子预置镜像源到数据库。
|
||||||
|
调用后自动将 PRESET_MIRRORS 写入 mirror_source 表 (跳过已存在的).
|
||||||
|
"""
|
||||||
|
env = request._run_ns
|
||||||
|
dbname = env.get_module_dbname(MODULE_NAME)
|
||||||
|
now = datetime.datetime.now().isoformat()
|
||||||
|
added = 0
|
||||||
|
skipped = 0
|
||||||
|
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
for m in PRESET_MIRRORS:
|
||||||
|
# 检查是否已存在
|
||||||
|
existing = await sor.R('mirror_source',
|
||||||
|
{'registry_url': m['registry_url']})
|
||||||
|
if existing:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
await sor.C('mirror_source', {
|
||||||
|
'id': getID(),
|
||||||
|
'resellerid': '*',
|
||||||
|
'name': m['name'],
|
||||||
|
'source_type': m['source_type'],
|
||||||
|
'registry_url': m['registry_url'],
|
||||||
|
'upstream_url': m.get('upstream_url', ''),
|
||||||
|
'region': m.get('region', 'cn'),
|
||||||
|
'priority': m.get('priority', 100),
|
||||||
|
'is_default': m.get('is_default', 0),
|
||||||
|
'status': 'active',
|
||||||
|
'created_at': now,
|
||||||
|
'updated_at': now,
|
||||||
|
})
|
||||||
|
added += 1
|
||||||
|
|
||||||
|
return {'status': 'ok', 'message': 'Seeded ' + str(added) + ' mirrors, skipped ' + str(skipped)}
|
||||||
|
|
||||||
|
|
||||||
|
async def mirror_health_check(request, params_kw):
|
||||||
|
"""
|
||||||
|
检查镜像源是否可达。
|
||||||
|
尝试对该源执行 docker pull 一个轻量镜像 (如 busybox).
|
||||||
|
params: mirror_id (可选, 不传则检查所有 active 源)
|
||||||
|
"""
|
||||||
|
env = request._run_ns
|
||||||
|
dbname = env.get_module_dbname(MODULE_NAME)
|
||||||
|
mirror_id = params_kw.get('mirror_id', '')
|
||||||
|
cluster_id = params_kw.get('cluster_id', '')
|
||||||
|
|
||||||
|
if not cluster_id:
|
||||||
|
return {'status': 'error', 'message': 'Missing cluster_id (needed for SSH)'}
|
||||||
|
|
||||||
|
cnode = await _find_control_node(env, cluster_id)
|
||||||
|
if not cnode:
|
||||||
|
return {'status': 'error', 'message': 'No active control node found'}
|
||||||
|
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
if mirror_id:
|
||||||
|
mirrors = await sor.R('mirror_source', {'id': mirror_id})
|
||||||
|
else:
|
||||||
|
mirrors = await sor.R('mirror_source', {'status': 'active'})
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for m in mirrors:
|
||||||
|
test_image = m.registry_url + '/library/busybox:latest'
|
||||||
|
if m.source_type == 'public_mirror' and m.upstream_url:
|
||||||
|
# 对于 mirror 类型的源, pull 测试: <mirror_url>/library/busybox:latest
|
||||||
|
test_image = m.registry_url + '/library/busybox:latest'
|
||||||
|
else:
|
||||||
|
test_image = m.registry_url + '/busybox:latest'
|
||||||
|
|
||||||
|
rc, out, err = await ssh_exec(
|
||||||
|
cnode.ip_address, cnode.ssh_port or 22, cnode.ssh_user or 'root',
|
||||||
|
'docker pull ' + test_image + ' 2>&1 && docker rmi ' + test_image + ' 2>/dev/null',
|
||||||
|
timeout=60)
|
||||||
|
reachable = rc == 0
|
||||||
|
results.append({
|
||||||
|
'id': m.id, 'name': m.name, 'registry_url': m.registry_url,
|
||||||
|
'reachable': reachable, 'error': err[:200] if not reachable else ''
|
||||||
|
})
|
||||||
|
|
||||||
|
# 更新最后检查时间
|
||||||
|
await sor.U('mirror_source', {'id': m.id},
|
||||||
|
{'health_check_at': datetime.datetime.now().isoformat(),
|
||||||
|
'status': 'active' if reachable else 'error'})
|
||||||
|
|
||||||
|
return {'status': 'ok', 'results': results}
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
# 镜像仓库部署
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async def registry_deploy(request, params_kw):
|
||||||
|
"""
|
||||||
|
在 K8s 集群上部署本地 Docker Registry v2.
|
||||||
|
params: registry_id (image_registry 记录ID)
|
||||||
|
流程:
|
||||||
|
1. 读取 image_registry 记录
|
||||||
|
2. 读取集群控制节点
|
||||||
|
3. SSH → kubectl apply deployment + service + pvc
|
||||||
|
4. 生成 htpasswd 认证
|
||||||
|
5. 等待就绪 → 更新 endpoint
|
||||||
|
"""
|
||||||
|
env = request._run_ns
|
||||||
|
dbname = env.get_module_dbname(MODULE_NAME)
|
||||||
|
registry_id = params_kw.get('registry_id', '')
|
||||||
|
|
||||||
|
if not registry_id:
|
||||||
|
return {'status': 'error', 'message': 'Missing registry_id'}
|
||||||
|
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
regs = await sor.R('image_registry', {'id': registry_id})
|
||||||
|
if not regs:
|
||||||
|
return {'status': 'error', 'message': 'Registry not found'}
|
||||||
|
reg = regs[0]
|
||||||
|
|
||||||
|
cluster_id = reg.cluster_id
|
||||||
|
if not cluster_id:
|
||||||
|
return {'status': 'error', 'message': 'Registry not bound to a cluster'}
|
||||||
|
|
||||||
|
cnode = await _find_control_node(env, cluster_id)
|
||||||
|
if not cnode:
|
||||||
|
return {'status': 'error', 'message': 'No active control node in cluster ' + cluster_id}
|
||||||
|
|
||||||
|
# 生成密码 (如果未设置)
|
||||||
|
admin_user = reg.admin_user or 'admin'
|
||||||
|
admin_pass = reg.admin_password or 'pcc123456'
|
||||||
|
storage_size = reg.storage_size or '50Gi'
|
||||||
|
storage_class = reg.storage_class or 'pcc-nfs-sc'
|
||||||
|
namespace = 'pcc-registry'
|
||||||
|
|
||||||
|
steps = []
|
||||||
|
|
||||||
|
# 1. 创建 namespace
|
||||||
|
rc, _, _ = await ssh_exec(cnode.ip_address, cnode.ssh_port or 22, cnode.ssh_user or 'root',
|
||||||
|
'kubectl create namespace ' + namespace + ' 2>/dev/null || true')
|
||||||
|
steps.append('ns_created')
|
||||||
|
|
||||||
|
# 2. 创建 htpasswd secret
|
||||||
|
htpasswd_cmd = (
|
||||||
|
'docker run --rm httpd:2-alpine htpasswd -nbB ' + admin_user + ' ' + admin_pass
|
||||||
|
+ ' | base64 -w0'
|
||||||
|
)
|
||||||
|
rc, htpasswd_b64, _ = await ssh_exec(cnode.ip_address, cnode.ssh_port or 22,
|
||||||
|
cnode.ssh_user or 'root', htpasswd_cmd, timeout=60)
|
||||||
|
secret_yaml = (
|
||||||
|
'apiVersion: v1\n'
|
||||||
|
'kind: Secret\n'
|
||||||
|
'metadata:\n'
|
||||||
|
' name: registry-auth\n'
|
||||||
|
' namespace: ' + namespace + '\n'
|
||||||
|
'data:\n'
|
||||||
|
' htpasswd: ' + htpasswd_b64.strip() + '\n'
|
||||||
|
)
|
||||||
|
await ssh_exec(cnode.ip_address, cnode.ssh_port or 22, cnode.ssh_user or 'root',
|
||||||
|
"echo '" + secret_yaml + "' | kubectl apply -f -")
|
||||||
|
steps.append('secret_created')
|
||||||
|
|
||||||
|
# 3. 创建 PVC
|
||||||
|
pvc_yaml = (
|
||||||
|
'apiVersion: v1\n'
|
||||||
|
'kind: PersistentVolumeClaim\n'
|
||||||
|
'metadata:\n'
|
||||||
|
' name: registry-storage\n'
|
||||||
|
' namespace: ' + namespace + '\n'
|
||||||
|
'spec:\n'
|
||||||
|
' accessModes:\n'
|
||||||
|
' - ReadWriteMany\n'
|
||||||
|
' storageClassName: ' + storage_class + '\n'
|
||||||
|
' resources:\n'
|
||||||
|
' requests:\n'
|
||||||
|
' storage: ' + storage_size + '\n'
|
||||||
|
)
|
||||||
|
await ssh_exec(cnode.ip_address, cnode.ssh_port or 22, cnode.ssh_user or 'root',
|
||||||
|
"echo '" + pvc_yaml + "' | kubectl apply -f -")
|
||||||
|
steps.append('pvc_created')
|
||||||
|
|
||||||
|
# 4. 创建 Deployment
|
||||||
|
deploy_yaml = (
|
||||||
|
'apiVersion: apps/v1\n'
|
||||||
|
'kind: Deployment\n'
|
||||||
|
'metadata:\n'
|
||||||
|
' name: registry\n'
|
||||||
|
' namespace: ' + namespace + '\n'
|
||||||
|
'spec:\n'
|
||||||
|
' replicas: 1\n'
|
||||||
|
' selector:\n'
|
||||||
|
' matchLabels:\n'
|
||||||
|
' app: registry\n'
|
||||||
|
' template:\n'
|
||||||
|
' metadata:\n'
|
||||||
|
' labels:\n'
|
||||||
|
' app: registry\n'
|
||||||
|
' spec:\n'
|
||||||
|
' containers:\n'
|
||||||
|
' - name: registry\n'
|
||||||
|
' image: registry:2\n'
|
||||||
|
' ports:\n'
|
||||||
|
' - containerPort: 5000\n'
|
||||||
|
' env:\n'
|
||||||
|
' - name: REGISTRY_AUTH\n'
|
||||||
|
' value: htpasswd\n'
|
||||||
|
' - name: REGISTRY_AUTH_HTPASSWD_REALM\n'
|
||||||
|
' value: Registry Realm\n'
|
||||||
|
' - name: REGISTRY_AUTH_HTPASSWD_PATH\n'
|
||||||
|
' value: /auth/htpasswd\n'
|
||||||
|
' - name: REGISTRY_STORAGE_DELETE_ENABLED\n'
|
||||||
|
' value: "true"\n'
|
||||||
|
' volumeMounts:\n'
|
||||||
|
' - name: storage\n'
|
||||||
|
' mountPath: /var/lib/registry\n'
|
||||||
|
' - name: auth\n'
|
||||||
|
' mountPath: /auth\n'
|
||||||
|
' volumes:\n'
|
||||||
|
' - name: storage\n'
|
||||||
|
' persistentVolumeClaim:\n'
|
||||||
|
' claimName: registry-storage\n'
|
||||||
|
' - name: auth\n'
|
||||||
|
' secret:\n'
|
||||||
|
' secretName: registry-auth\n'
|
||||||
|
)
|
||||||
|
await ssh_exec(cnode.ip_address, cnode.ssh_port or 22, cnode.ssh_user or 'root',
|
||||||
|
"echo '" + deploy_yaml + "' | kubectl apply -f -")
|
||||||
|
steps.append('deploy_created')
|
||||||
|
|
||||||
|
# 5. 创建 Service (NodePort)
|
||||||
|
svc_yaml = (
|
||||||
|
'apiVersion: v1\n'
|
||||||
|
'kind: Service\n'
|
||||||
|
'metadata:\n'
|
||||||
|
' name: registry\n'
|
||||||
|
' namespace: ' + namespace + '\n'
|
||||||
|
'spec:\n'
|
||||||
|
' type: NodePort\n'
|
||||||
|
' selector:\n'
|
||||||
|
' app: registry\n'
|
||||||
|
' ports:\n'
|
||||||
|
' - port: 5000\n'
|
||||||
|
' targetPort: 5000\n'
|
||||||
|
' nodePort: 30500\n'
|
||||||
|
)
|
||||||
|
await ssh_exec(cnode.ip_address, cnode.ssh_port or 22, cnode.ssh_user or 'root',
|
||||||
|
"echo '" + svc_yaml + "' | kubectl apply -f -")
|
||||||
|
steps.append('svc_created')
|
||||||
|
|
||||||
|
# 6. 等待就绪
|
||||||
|
rc, _, _ = await ssh_exec(cnode.ip_address, cnode.ssh_port or 22, cnode.ssh_user or 'root',
|
||||||
|
'kubectl wait --for=condition=available deployment/registry -n '
|
||||||
|
+ namespace + ' --timeout=120s', timeout=150)
|
||||||
|
|
||||||
|
# 7. 获取 NodePort 地址 → endpoint
|
||||||
|
endpoint = cnode.ip_address + ':30500'
|
||||||
|
internal_endpoint = 'registry.' + namespace + '.svc.cluster.local:5000'
|
||||||
|
|
||||||
|
now = datetime.datetime.now().isoformat()
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
await sor.U('image_registry', {'id': registry_id}, {
|
||||||
|
'endpoint': endpoint,
|
||||||
|
'internal_endpoint': internal_endpoint,
|
||||||
|
'status': 'running',
|
||||||
|
'deploy_config': json.dumps({
|
||||||
|
'namespace': namespace,
|
||||||
|
'node_port': 30500,
|
||||||
|
'steps': steps,
|
||||||
|
}),
|
||||||
|
'updated_at': now,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'status': 'ok',
|
||||||
|
'message': 'Registry deployed at ' + endpoint,
|
||||||
|
'endpoint': endpoint,
|
||||||
|
'internal': internal_endpoint,
|
||||||
|
'admin_user': admin_user,
|
||||||
|
'steps': steps,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
# 镜像操作
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async def image_import(request, params_kw):
|
||||||
|
"""
|
||||||
|
从外部镜像源导入镜像到本地仓库。
|
||||||
|
params: registry_id, source_image, target_name, target_tag
|
||||||
|
流程:
|
||||||
|
1. SSH 到控制节点
|
||||||
|
2. docker pull <source_image> (通过配置的镜像加速/代理)
|
||||||
|
3. docker tag <source_image> <local_registry>/<target_name>:<target_tag>
|
||||||
|
4. docker login <local_registry>
|
||||||
|
5. docker push <local_registry>/<target_name>:<target_tag>
|
||||||
|
6. 写入 container_image 表
|
||||||
|
"""
|
||||||
|
env = request._run_ns
|
||||||
|
dbname = env.get_module_dbname(MODULE_NAME)
|
||||||
|
registry_id = params_kw.get('registry_id', '')
|
||||||
|
source_image = params_kw.get('source_image', '')
|
||||||
|
target_name = params_kw.get('target_name', '')
|
||||||
|
target_tag = params_kw.get('target_tag', 'latest')
|
||||||
|
image_type = params_kw.get('image_type', 'custom')
|
||||||
|
customer_id = params_kw.get('customer_id', '')
|
||||||
|
|
||||||
|
if not registry_id or not source_image:
|
||||||
|
return {'status': 'error', 'message': 'Missing registry_id or source_image'}
|
||||||
|
|
||||||
|
if not target_name:
|
||||||
|
# 从 source_image 提取镜像名
|
||||||
|
target_name = source_image.split('/')[-1].split(':')[0]
|
||||||
|
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
regs = await sor.R('image_registry', {'id': registry_id})
|
||||||
|
if not regs:
|
||||||
|
return {'status': 'error', 'message': 'Registry not found'}
|
||||||
|
reg = regs[0]
|
||||||
|
|
||||||
|
cnode = await _find_control_node(env, reg.cluster_id)
|
||||||
|
if not cnode:
|
||||||
|
return {'status': 'error', 'message': 'No control node found'}
|
||||||
|
|
||||||
|
local_registry = reg.internal_endpoint or reg.endpoint
|
||||||
|
target_full = local_registry + '/' + target_name + ':' + target_tag
|
||||||
|
|
||||||
|
steps = []
|
||||||
|
|
||||||
|
# 1. docker pull (from external source / mirror)
|
||||||
|
rc, out, err = await ssh_exec(cnode.ip_address, cnode.ssh_port or 22,
|
||||||
|
cnode.ssh_user or 'root',
|
||||||
|
'docker pull ' + source_image, timeout=300)
|
||||||
|
if rc != 0:
|
||||||
|
return {'status': 'error', 'message': 'Pull failed: ' + err[:300], 'steps': steps}
|
||||||
|
steps.append('pulled ' + source_image)
|
||||||
|
|
||||||
|
# 2. docker tag
|
||||||
|
rc, _, err = await ssh_exec(cnode.ip_address, cnode.ssh_port or 22,
|
||||||
|
cnode.ssh_user or 'root',
|
||||||
|
'docker tag ' + source_image + ' ' + target_full, timeout=30)
|
||||||
|
if rc != 0:
|
||||||
|
return {'status': 'error', 'message': 'Tag failed: ' + err[:200], 'steps': steps}
|
||||||
|
steps.append('tagged → ' + target_full)
|
||||||
|
|
||||||
|
# 3. docker login + push
|
||||||
|
login_cmd = ('docker login ' + local_registry
|
||||||
|
+ ' -u ' + (reg.admin_user or 'admin')
|
||||||
|
+ ' -p ' + (reg.admin_password or 'pcc123456'))
|
||||||
|
await ssh_exec(cnode.ip_address, cnode.ssh_port or 22, cnode.ssh_user or 'root',
|
||||||
|
login_cmd, timeout=30)
|
||||||
|
|
||||||
|
rc, out, err = await ssh_exec(cnode.ip_address, cnode.ssh_port or 22,
|
||||||
|
cnode.ssh_user or 'root',
|
||||||
|
'docker push ' + target_full, timeout=300)
|
||||||
|
if rc != 0:
|
||||||
|
return {'status': 'error', 'message': 'Push failed: ' + err[:300], 'steps': steps}
|
||||||
|
steps.append('pushed')
|
||||||
|
|
||||||
|
# 4. 获取镜像 digest 和 size
|
||||||
|
rc, inspect_out, _ = await ssh_exec(cnode.ip_address, cnode.ssh_port or 22,
|
||||||
|
cnode.ssh_user or 'root',
|
||||||
|
"docker inspect --format='{{.Id}} {{.Size}}' "
|
||||||
|
+ target_full, timeout=30)
|
||||||
|
digest = ''
|
||||||
|
size_bytes = 0
|
||||||
|
if rc == 0 and inspect_out.strip():
|
||||||
|
parts = inspect_out.strip().split()
|
||||||
|
digest = parts[0] if parts else ''
|
||||||
|
try:
|
||||||
|
size_bytes = int(parts[1]) if len(parts) > 1 else 0
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 5. 清理本地镜像 (可选, 节省控制节点磁盘)
|
||||||
|
await ssh_exec(cnode.ip_address, cnode.ssh_port or 22, cnode.ssh_user or 'root',
|
||||||
|
'docker rmi ' + source_image + ' ' + target_full + ' 2>/dev/null || true',
|
||||||
|
timeout=30)
|
||||||
|
|
||||||
|
# 6. 写入 DB
|
||||||
|
now = datetime.datetime.now().isoformat()
|
||||||
|
image_id = getID()
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
await sor.C('container_image', {
|
||||||
|
'id': image_id,
|
||||||
|
'resellerid': reg.resellerid or '*',
|
||||||
|
'registry_id': registry_id,
|
||||||
|
'image_name': target_name,
|
||||||
|
'image_tag': target_tag,
|
||||||
|
'full_name': target_name + ':' + target_tag,
|
||||||
|
'source_image': source_image,
|
||||||
|
'size_bytes': size_bytes,
|
||||||
|
'digest': digest,
|
||||||
|
'image_type': image_type,
|
||||||
|
'is_customer': 1 if customer_id else 0,
|
||||||
|
'customer_id': customer_id or '',
|
||||||
|
'sync_status': 'synced',
|
||||||
|
'sync_log': json.dumps({'steps': steps, 'digest': digest}),
|
||||||
|
'created_at': now,
|
||||||
|
'updated_at': now,
|
||||||
|
})
|
||||||
|
|
||||||
|
# 更新仓库统计
|
||||||
|
await sor.sqlExe(
|
||||||
|
'UPDATE image_registry SET image_count = image_count + 1, '
|
||||||
|
'total_size_gb = total_size_gb + ${sz}$, updated_at = ${now}$ WHERE id = ${rid}$',
|
||||||
|
{'sz': max(1, size_bytes // (1024 * 1024 * 1024)),
|
||||||
|
'now': now, 'rid': registry_id})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'status': 'ok',
|
||||||
|
'message': 'Imported ' + target_full,
|
||||||
|
'image_id': image_id,
|
||||||
|
'full_name': target_name + ':' + target_tag,
|
||||||
|
'size_bytes': size_bytes,
|
||||||
|
'steps': steps,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def image_sync(request, params_kw):
|
||||||
|
"""
|
||||||
|
同步镜像:从镜像源批量拉取 → 推送到本地仓库。
|
||||||
|
params: registry_id, mirror_id, image_list (逗号分隔的镜像名列表)
|
||||||
|
"""
|
||||||
|
env = request._run_ns
|
||||||
|
dbname = env.get_module_dbname(MODULE_NAME)
|
||||||
|
registry_id = params_kw.get('registry_id', '')
|
||||||
|
mirror_id = params_kw.get('mirror_id', '')
|
||||||
|
image_list = params_kw.get('image_list', '')
|
||||||
|
|
||||||
|
if not registry_id:
|
||||||
|
return {'status': 'error', 'message': 'Missing registry_id'}
|
||||||
|
|
||||||
|
images = [i.strip() for i in image_list.split(',') if i.strip()] if image_list else []
|
||||||
|
if not images:
|
||||||
|
# 默认同步一批常用镜像
|
||||||
|
images = [
|
||||||
|
'busybox:latest',
|
||||||
|
'alpine:latest',
|
||||||
|
'nginx:latest',
|
||||||
|
'redis:7-alpine',
|
||||||
|
'python:3.11-slim',
|
||||||
|
]
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for img in images:
|
||||||
|
# 构建 source image 路径
|
||||||
|
source = img
|
||||||
|
if mirror_id:
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
mirrors = await sor.R('mirror_source', {'id': mirror_id})
|
||||||
|
if mirrors:
|
||||||
|
m = mirrors[0]
|
||||||
|
if m.upstream_url and not img.startswith(m.registry_url):
|
||||||
|
source = m.registry_url + '/' + img
|
||||||
|
|
||||||
|
name = img.split(':')[0].replace('/', '_')
|
||||||
|
tag = img.split(':')[1] if ':' in img else 'latest'
|
||||||
|
|
||||||
|
result = await image_import(request, {
|
||||||
|
'registry_id': registry_id,
|
||||||
|
'source_image': source,
|
||||||
|
'target_name': name,
|
||||||
|
'target_tag': tag,
|
||||||
|
'image_type': 'base',
|
||||||
|
})
|
||||||
|
results.append({'image': img, 'result': result.get('status', 'unknown')})
|
||||||
|
|
||||||
|
return {'status': 'ok', 'synced': len(results), 'results': results}
|
||||||
|
|
||||||
|
|
||||||
|
async def image_push(request, params_kw):
|
||||||
|
"""
|
||||||
|
客户推送自定义镜像到本地仓库。
|
||||||
|
params: registry_id, image_name, image_tag, dockerfile_path (可选)
|
||||||
|
流程:
|
||||||
|
1. SSH 到客户提供的工作节点 (或有 docker 的环境)
|
||||||
|
2. docker build -t <target> . (如果有 Dockerfile)
|
||||||
|
3. docker push <target>
|
||||||
|
4. 记录到 container_image 表 (is_customer=1)
|
||||||
|
"""
|
||||||
|
env = request._run_ns
|
||||||
|
dbname = env.get_module_dbname(MODULE_NAME)
|
||||||
|
registry_id = params_kw.get('registry_id', '')
|
||||||
|
image_name = params_kw.get('image_name', '')
|
||||||
|
image_tag = params_kw.get('image_tag', 'latest')
|
||||||
|
customer_id = params_kw.get('customer_id', '')
|
||||||
|
node_id = params_kw.get('node_id', '') # 客户构建节点
|
||||||
|
|
||||||
|
if not registry_id or not image_name:
|
||||||
|
return {'status': 'error', 'message': 'Missing registry_id or image_name'}
|
||||||
|
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
regs = await sor.R('image_registry', {'id': registry_id})
|
||||||
|
if not regs:
|
||||||
|
return {'status': 'error', 'message': 'Registry not found'}
|
||||||
|
reg = regs[0]
|
||||||
|
|
||||||
|
# 确定构建节点: 优先使用客户指定 node, 否则用控制节点
|
||||||
|
build_node = None
|
||||||
|
if node_id:
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
nodes = await sor.R('compute_node', {'id': node_id})
|
||||||
|
if nodes:
|
||||||
|
build_node = nodes[0]
|
||||||
|
if not build_node:
|
||||||
|
build_node = await _find_control_node(env, reg.cluster_id)
|
||||||
|
if not build_node:
|
||||||
|
return {'status': 'error', 'message': 'No build node available'}
|
||||||
|
|
||||||
|
local_registry = reg.internal_endpoint or reg.endpoint
|
||||||
|
target_full = local_registry + '/' + image_name + ':' + image_tag
|
||||||
|
|
||||||
|
# docker login
|
||||||
|
login_cmd = ('docker login ' + local_registry
|
||||||
|
+ ' -u ' + (reg.admin_user or 'admin')
|
||||||
|
+ ' -p ' + (reg.admin_password or 'pcc123456'))
|
||||||
|
await ssh_exec(build_node.ip_address, build_node.ssh_port or 22,
|
||||||
|
build_node.ssh_user or 'root', login_cmd, timeout=30)
|
||||||
|
|
||||||
|
# docker push (假设客户已在节点上构建好镜像)
|
||||||
|
rc, out, err = await ssh_exec(build_node.ip_address, build_node.ssh_port or 22,
|
||||||
|
build_node.ssh_user or 'root',
|
||||||
|
'docker push ' + target_full, timeout=300)
|
||||||
|
if rc != 0:
|
||||||
|
return {'status': 'error', 'message': 'Push failed: ' + err[:300]}
|
||||||
|
|
||||||
|
# 记录
|
||||||
|
now = datetime.datetime.now().isoformat()
|
||||||
|
image_id = getID()
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
await sor.C('container_image', {
|
||||||
|
'id': image_id,
|
||||||
|
'resellerid': reg.resellerid or '*',
|
||||||
|
'registry_id': registry_id,
|
||||||
|
'image_name': image_name,
|
||||||
|
'image_tag': image_tag,
|
||||||
|
'full_name': image_name + ':' + image_tag,
|
||||||
|
'source_image': target_full,
|
||||||
|
'image_type': 'custom',
|
||||||
|
'is_customer': 1,
|
||||||
|
'customer_id': customer_id or '',
|
||||||
|
'sync_status': 'synced',
|
||||||
|
'created_at': now,
|
||||||
|
'updated_at': now,
|
||||||
|
})
|
||||||
|
await sor.sqlExe(
|
||||||
|
'UPDATE image_registry SET image_count = image_count + 1, '
|
||||||
|
'updated_at = ${now}$ WHERE id = ${rid}$',
|
||||||
|
{'now': now, 'rid': registry_id})
|
||||||
|
|
||||||
|
return {'status': 'ok', 'message': 'Pushed ' + target_full, 'image_id': image_id}
|
||||||
|
|
||||||
|
async def image_stats(request, params_kw):
|
||||||
|
"""获取镜像统计"""
|
||||||
|
env = request._run_ns
|
||||||
|
dbname = env.get_module_dbname('image_mgr')
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
mirrors = await sor.R('mirror_source', {})
|
||||||
|
registries = await sor.R('image_registry', {})
|
||||||
|
images = await sor.R('container_image', {})
|
||||||
|
return {'status': 'ok', 'data': {
|
||||||
|
'mirror_count': len(mirrors),
|
||||||
|
'active_mirrors': sum(1 for m in mirrors if getattr(m, 'status', '') == 'active'),
|
||||||
|
'registry_count': len(registries),
|
||||||
|
'running_registries': sum(1 for r in registries if getattr(r, 'status', '') == 'running'),
|
||||||
|
'image_count': len(images),
|
||||||
|
'custom_images': sum(1 for i in images if getattr(i, 'is_custom', '') == '1'),
|
||||||
|
}}
|
||||||
|
|
||||||
70
build/lib/build/lib/build/lib/build/lib/scripts/load_path.py
Normal file
70
build/lib/build/lib/build/lib/build/lib/scripts/load_path.py
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""image_mgr RBAC 权限管理"""
|
||||||
|
import subprocess, os, sys, json, glob
|
||||||
|
|
||||||
|
mod_name = 'image_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.")
|
||||||
697
build/lib/build/lib/build/lib/image_mgr/__init__.py
Normal file
697
build/lib/build/lib/build/lib/image_mgr/__init__.py
Normal file
@ -0,0 +1,697 @@
|
|||||||
|
"""
|
||||||
|
image_mgr — 容器镜像管理
|
||||||
|
|
||||||
|
- 国内镜像源配置 (mirror_source): 阿里云、DaoCloud 等安全可触达节点
|
||||||
|
- 本地镜像仓库 (image_registry): Docker Registry v2 on K8s + NFS CSI
|
||||||
|
- 容器镜像管理 (container_image): 导入/同步/推送/列表/删除
|
||||||
|
- 客户自定义镜像支持
|
||||||
|
"""
|
||||||
|
import datetime, json, asyncio, subprocess
|
||||||
|
from appPublic.uniqueID import getID
|
||||||
|
from sqlor.dbpools import DBPools
|
||||||
|
|
||||||
|
MODULE_NAME = 'pccs'
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
# 预置的国内安全镜像源
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
PRESET_MIRRORS = [
|
||||||
|
{
|
||||||
|
'name': '阿里云容器镜像服务(杭州)',
|
||||||
|
'source_type': 'public_mirror',
|
||||||
|
'registry_url': 'registry.cn-hangzhou.aliyuncs.com',
|
||||||
|
'upstream_url': 'docker.io',
|
||||||
|
'region': 'cn',
|
||||||
|
'priority': 10,
|
||||||
|
'is_default': 1,
|
||||||
|
'note': 'registry.cn-hangzhou.aliyuncs.com/{namespace}/{repo}'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': '阿里云 Google容器镜像',
|
||||||
|
'source_type': 'public_mirror',
|
||||||
|
'registry_url': 'registry.cn-hangzhou.aliyuncs.com/google_containers',
|
||||||
|
'upstream_url': 'k8s.gcr.io',
|
||||||
|
'region': 'cn',
|
||||||
|
'priority': 20,
|
||||||
|
'note': 'K8s 官方镜像国内加速'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'DaoCloud 公共镜像',
|
||||||
|
'source_type': 'public_mirror',
|
||||||
|
'registry_url': 'docker.daocloud.io',
|
||||||
|
'upstream_url': 'docker.io',
|
||||||
|
'region': 'cn',
|
||||||
|
'priority': 15,
|
||||||
|
'note': 'DaoCloud 代理加速'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'DaoCloud GCR 镜像',
|
||||||
|
'source_type': 'public_mirror',
|
||||||
|
'registry_url': 'gcr.daocloud.io',
|
||||||
|
'upstream_url': 'gcr.io',
|
||||||
|
'region': 'cn',
|
||||||
|
'priority': 25,
|
||||||
|
'note': 'gcr.io 国内加速'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'DaoCloud Quay 镜像',
|
||||||
|
'source_type': 'public_mirror',
|
||||||
|
'registry_url': 'quay.daocloud.io',
|
||||||
|
'upstream_url': 'quay.io',
|
||||||
|
'region': 'cn',
|
||||||
|
'priority': 25,
|
||||||
|
'note': 'quay.io 国内加速'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'DaoCloud GHCR 镜像',
|
||||||
|
'source_type': 'public_mirror',
|
||||||
|
'registry_url': 'ghcr.daocloud.io',
|
||||||
|
'upstream_url': 'ghcr.io',
|
||||||
|
'region': 'cn',
|
||||||
|
'priority': 25,
|
||||||
|
'note': 'GitHub Container Registry 国内加速'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': '腾讯云容器镜像',
|
||||||
|
'source_type': 'public_mirror',
|
||||||
|
'registry_url': 'mirror.ccs.tencentyun.com',
|
||||||
|
'upstream_url': 'docker.io',
|
||||||
|
'region': 'cn',
|
||||||
|
'priority': 30,
|
||||||
|
'note': '腾讯云公共镜像加速'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Docker Hub (直连)',
|
||||||
|
'source_type': 'hub',
|
||||||
|
'registry_url': 'docker.io',
|
||||||
|
'upstream_url': '',
|
||||||
|
'region': 'us',
|
||||||
|
'priority': 100,
|
||||||
|
'note': 'Docker官方, 国内可能慢/不可达'
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
# SSH 工具
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async def ssh_exec(host, port, user, cmd, timeout=120):
|
||||||
|
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 _find_control_node(env, cluster_id):
|
||||||
|
"""查找集群活跃控制节点"""
|
||||||
|
dbname = env.get_module_dbname(MODULE_NAME)
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
recs = await sor.R('cluster_node',
|
||||||
|
{'cluster_id': cluster_id, 'role': 'control', 'status': 'active'})
|
||||||
|
if not recs:
|
||||||
|
return None
|
||||||
|
nodes = await sor.R('compute_node', {'id': recs[0].node_id})
|
||||||
|
return nodes[0] if nodes else None
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
# 镜像源管理
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async def mirror_seed(request, params_kw):
|
||||||
|
"""
|
||||||
|
种子预置镜像源到数据库。
|
||||||
|
调用后自动将 PRESET_MIRRORS 写入 mirror_source 表 (跳过已存在的).
|
||||||
|
"""
|
||||||
|
env = request._run_ns
|
||||||
|
dbname = env.get_module_dbname(MODULE_NAME)
|
||||||
|
now = datetime.datetime.now().isoformat()
|
||||||
|
added = 0
|
||||||
|
skipped = 0
|
||||||
|
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
for m in PRESET_MIRRORS:
|
||||||
|
# 检查是否已存在
|
||||||
|
existing = await sor.R('mirror_source',
|
||||||
|
{'registry_url': m['registry_url']})
|
||||||
|
if existing:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
await sor.C('mirror_source', {
|
||||||
|
'id': getID(),
|
||||||
|
'resellerid': '*',
|
||||||
|
'name': m['name'],
|
||||||
|
'source_type': m['source_type'],
|
||||||
|
'registry_url': m['registry_url'],
|
||||||
|
'upstream_url': m.get('upstream_url', ''),
|
||||||
|
'region': m.get('region', 'cn'),
|
||||||
|
'priority': m.get('priority', 100),
|
||||||
|
'is_default': m.get('is_default', 0),
|
||||||
|
'status': 'active',
|
||||||
|
'created_at': now,
|
||||||
|
'updated_at': now,
|
||||||
|
})
|
||||||
|
added += 1
|
||||||
|
|
||||||
|
return {'status': 'ok', 'message': 'Seeded ' + str(added) + ' mirrors, skipped ' + str(skipped)}
|
||||||
|
|
||||||
|
|
||||||
|
async def mirror_health_check(request, params_kw):
|
||||||
|
"""
|
||||||
|
检查镜像源是否可达。
|
||||||
|
尝试对该源执行 docker pull 一个轻量镜像 (如 busybox).
|
||||||
|
params: mirror_id (可选, 不传则检查所有 active 源)
|
||||||
|
"""
|
||||||
|
env = request._run_ns
|
||||||
|
dbname = env.get_module_dbname(MODULE_NAME)
|
||||||
|
mirror_id = params_kw.get('mirror_id', '')
|
||||||
|
cluster_id = params_kw.get('cluster_id', '')
|
||||||
|
|
||||||
|
if not cluster_id:
|
||||||
|
return {'status': 'error', 'message': 'Missing cluster_id (needed for SSH)'}
|
||||||
|
|
||||||
|
cnode = await _find_control_node(env, cluster_id)
|
||||||
|
if not cnode:
|
||||||
|
return {'status': 'error', 'message': 'No active control node found'}
|
||||||
|
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
if mirror_id:
|
||||||
|
mirrors = await sor.R('mirror_source', {'id': mirror_id})
|
||||||
|
else:
|
||||||
|
mirrors = await sor.R('mirror_source', {'status': 'active'})
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for m in mirrors:
|
||||||
|
test_image = m.registry_url + '/library/busybox:latest'
|
||||||
|
if m.source_type == 'public_mirror' and m.upstream_url:
|
||||||
|
# 对于 mirror 类型的源, pull 测试: <mirror_url>/library/busybox:latest
|
||||||
|
test_image = m.registry_url + '/library/busybox:latest'
|
||||||
|
else:
|
||||||
|
test_image = m.registry_url + '/busybox:latest'
|
||||||
|
|
||||||
|
rc, out, err = await ssh_exec(
|
||||||
|
cnode.ip_address, cnode.ssh_port or 22, cnode.ssh_user or 'root',
|
||||||
|
'docker pull ' + test_image + ' 2>&1 && docker rmi ' + test_image + ' 2>/dev/null',
|
||||||
|
timeout=60)
|
||||||
|
reachable = rc == 0
|
||||||
|
results.append({
|
||||||
|
'id': m.id, 'name': m.name, 'registry_url': m.registry_url,
|
||||||
|
'reachable': reachable, 'error': err[:200] if not reachable else ''
|
||||||
|
})
|
||||||
|
|
||||||
|
# 更新最后检查时间
|
||||||
|
await sor.U('mirror_source', {'id': m.id},
|
||||||
|
{'health_check_at': datetime.datetime.now().isoformat(),
|
||||||
|
'status': 'active' if reachable else 'error'})
|
||||||
|
|
||||||
|
return {'status': 'ok', 'results': results}
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
# 镜像仓库部署
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async def registry_deploy(request, params_kw):
|
||||||
|
"""
|
||||||
|
在 K8s 集群上部署本地 Docker Registry v2.
|
||||||
|
params: registry_id (image_registry 记录ID)
|
||||||
|
流程:
|
||||||
|
1. 读取 image_registry 记录
|
||||||
|
2. 读取集群控制节点
|
||||||
|
3. SSH → kubectl apply deployment + service + pvc
|
||||||
|
4. 生成 htpasswd 认证
|
||||||
|
5. 等待就绪 → 更新 endpoint
|
||||||
|
"""
|
||||||
|
env = request._run_ns
|
||||||
|
dbname = env.get_module_dbname(MODULE_NAME)
|
||||||
|
registry_id = params_kw.get('registry_id', '')
|
||||||
|
|
||||||
|
if not registry_id:
|
||||||
|
return {'status': 'error', 'message': 'Missing registry_id'}
|
||||||
|
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
regs = await sor.R('image_registry', {'id': registry_id})
|
||||||
|
if not regs:
|
||||||
|
return {'status': 'error', 'message': 'Registry not found'}
|
||||||
|
reg = regs[0]
|
||||||
|
|
||||||
|
cluster_id = reg.cluster_id
|
||||||
|
if not cluster_id:
|
||||||
|
return {'status': 'error', 'message': 'Registry not bound to a cluster'}
|
||||||
|
|
||||||
|
cnode = await _find_control_node(env, cluster_id)
|
||||||
|
if not cnode:
|
||||||
|
return {'status': 'error', 'message': 'No active control node in cluster ' + cluster_id}
|
||||||
|
|
||||||
|
# 生成密码 (如果未设置)
|
||||||
|
admin_user = reg.admin_user or 'admin'
|
||||||
|
admin_pass = reg.admin_password or 'pcc123456'
|
||||||
|
storage_size = reg.storage_size or '50Gi'
|
||||||
|
storage_class = reg.storage_class or 'pcc-nfs-sc'
|
||||||
|
namespace = 'pcc-registry'
|
||||||
|
|
||||||
|
steps = []
|
||||||
|
|
||||||
|
# 1. 创建 namespace
|
||||||
|
rc, _, _ = await ssh_exec(cnode.ip_address, cnode.ssh_port or 22, cnode.ssh_user or 'root',
|
||||||
|
'kubectl create namespace ' + namespace + ' 2>/dev/null || true')
|
||||||
|
steps.append('ns_created')
|
||||||
|
|
||||||
|
# 2. 创建 htpasswd secret
|
||||||
|
htpasswd_cmd = (
|
||||||
|
'docker run --rm httpd:2-alpine htpasswd -nbB ' + admin_user + ' ' + admin_pass
|
||||||
|
+ ' | base64 -w0'
|
||||||
|
)
|
||||||
|
rc, htpasswd_b64, _ = await ssh_exec(cnode.ip_address, cnode.ssh_port or 22,
|
||||||
|
cnode.ssh_user or 'root', htpasswd_cmd, timeout=60)
|
||||||
|
secret_yaml = (
|
||||||
|
'apiVersion: v1\n'
|
||||||
|
'kind: Secret\n'
|
||||||
|
'metadata:\n'
|
||||||
|
' name: registry-auth\n'
|
||||||
|
' namespace: ' + namespace + '\n'
|
||||||
|
'data:\n'
|
||||||
|
' htpasswd: ' + htpasswd_b64.strip() + '\n'
|
||||||
|
)
|
||||||
|
await ssh_exec(cnode.ip_address, cnode.ssh_port or 22, cnode.ssh_user or 'root',
|
||||||
|
"echo '" + secret_yaml + "' | kubectl apply -f -")
|
||||||
|
steps.append('secret_created')
|
||||||
|
|
||||||
|
# 3. 创建 PVC
|
||||||
|
pvc_yaml = (
|
||||||
|
'apiVersion: v1\n'
|
||||||
|
'kind: PersistentVolumeClaim\n'
|
||||||
|
'metadata:\n'
|
||||||
|
' name: registry-storage\n'
|
||||||
|
' namespace: ' + namespace + '\n'
|
||||||
|
'spec:\n'
|
||||||
|
' accessModes:\n'
|
||||||
|
' - ReadWriteMany\n'
|
||||||
|
' storageClassName: ' + storage_class + '\n'
|
||||||
|
' resources:\n'
|
||||||
|
' requests:\n'
|
||||||
|
' storage: ' + storage_size + '\n'
|
||||||
|
)
|
||||||
|
await ssh_exec(cnode.ip_address, cnode.ssh_port or 22, cnode.ssh_user or 'root',
|
||||||
|
"echo '" + pvc_yaml + "' | kubectl apply -f -")
|
||||||
|
steps.append('pvc_created')
|
||||||
|
|
||||||
|
# 4. 创建 Deployment
|
||||||
|
deploy_yaml = (
|
||||||
|
'apiVersion: apps/v1\n'
|
||||||
|
'kind: Deployment\n'
|
||||||
|
'metadata:\n'
|
||||||
|
' name: registry\n'
|
||||||
|
' namespace: ' + namespace + '\n'
|
||||||
|
'spec:\n'
|
||||||
|
' replicas: 1\n'
|
||||||
|
' selector:\n'
|
||||||
|
' matchLabels:\n'
|
||||||
|
' app: registry\n'
|
||||||
|
' template:\n'
|
||||||
|
' metadata:\n'
|
||||||
|
' labels:\n'
|
||||||
|
' app: registry\n'
|
||||||
|
' spec:\n'
|
||||||
|
' containers:\n'
|
||||||
|
' - name: registry\n'
|
||||||
|
' image: registry:2\n'
|
||||||
|
' ports:\n'
|
||||||
|
' - containerPort: 5000\n'
|
||||||
|
' env:\n'
|
||||||
|
' - name: REGISTRY_AUTH\n'
|
||||||
|
' value: htpasswd\n'
|
||||||
|
' - name: REGISTRY_AUTH_HTPASSWD_REALM\n'
|
||||||
|
' value: Registry Realm\n'
|
||||||
|
' - name: REGISTRY_AUTH_HTPASSWD_PATH\n'
|
||||||
|
' value: /auth/htpasswd\n'
|
||||||
|
' - name: REGISTRY_STORAGE_DELETE_ENABLED\n'
|
||||||
|
' value: "true"\n'
|
||||||
|
' volumeMounts:\n'
|
||||||
|
' - name: storage\n'
|
||||||
|
' mountPath: /var/lib/registry\n'
|
||||||
|
' - name: auth\n'
|
||||||
|
' mountPath: /auth\n'
|
||||||
|
' volumes:\n'
|
||||||
|
' - name: storage\n'
|
||||||
|
' persistentVolumeClaim:\n'
|
||||||
|
' claimName: registry-storage\n'
|
||||||
|
' - name: auth\n'
|
||||||
|
' secret:\n'
|
||||||
|
' secretName: registry-auth\n'
|
||||||
|
)
|
||||||
|
await ssh_exec(cnode.ip_address, cnode.ssh_port or 22, cnode.ssh_user or 'root',
|
||||||
|
"echo '" + deploy_yaml + "' | kubectl apply -f -")
|
||||||
|
steps.append('deploy_created')
|
||||||
|
|
||||||
|
# 5. 创建 Service (NodePort)
|
||||||
|
svc_yaml = (
|
||||||
|
'apiVersion: v1\n'
|
||||||
|
'kind: Service\n'
|
||||||
|
'metadata:\n'
|
||||||
|
' name: registry\n'
|
||||||
|
' namespace: ' + namespace + '\n'
|
||||||
|
'spec:\n'
|
||||||
|
' type: NodePort\n'
|
||||||
|
' selector:\n'
|
||||||
|
' app: registry\n'
|
||||||
|
' ports:\n'
|
||||||
|
' - port: 5000\n'
|
||||||
|
' targetPort: 5000\n'
|
||||||
|
' nodePort: 30500\n'
|
||||||
|
)
|
||||||
|
await ssh_exec(cnode.ip_address, cnode.ssh_port or 22, cnode.ssh_user or 'root',
|
||||||
|
"echo '" + svc_yaml + "' | kubectl apply -f -")
|
||||||
|
steps.append('svc_created')
|
||||||
|
|
||||||
|
# 6. 等待就绪
|
||||||
|
rc, _, _ = await ssh_exec(cnode.ip_address, cnode.ssh_port or 22, cnode.ssh_user or 'root',
|
||||||
|
'kubectl wait --for=condition=available deployment/registry -n '
|
||||||
|
+ namespace + ' --timeout=120s', timeout=150)
|
||||||
|
|
||||||
|
# 7. 获取 NodePort 地址 → endpoint
|
||||||
|
endpoint = cnode.ip_address + ':30500'
|
||||||
|
internal_endpoint = 'registry.' + namespace + '.svc.cluster.local:5000'
|
||||||
|
|
||||||
|
now = datetime.datetime.now().isoformat()
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
await sor.U('image_registry', {'id': registry_id}, {
|
||||||
|
'endpoint': endpoint,
|
||||||
|
'internal_endpoint': internal_endpoint,
|
||||||
|
'status': 'running',
|
||||||
|
'deploy_config': json.dumps({
|
||||||
|
'namespace': namespace,
|
||||||
|
'node_port': 30500,
|
||||||
|
'steps': steps,
|
||||||
|
}),
|
||||||
|
'updated_at': now,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'status': 'ok',
|
||||||
|
'message': 'Registry deployed at ' + endpoint,
|
||||||
|
'endpoint': endpoint,
|
||||||
|
'internal': internal_endpoint,
|
||||||
|
'admin_user': admin_user,
|
||||||
|
'steps': steps,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
# 镜像操作
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async def image_import(request, params_kw):
|
||||||
|
"""
|
||||||
|
从外部镜像源导入镜像到本地仓库。
|
||||||
|
params: registry_id, source_image, target_name, target_tag
|
||||||
|
流程:
|
||||||
|
1. SSH 到控制节点
|
||||||
|
2. docker pull <source_image> (通过配置的镜像加速/代理)
|
||||||
|
3. docker tag <source_image> <local_registry>/<target_name>:<target_tag>
|
||||||
|
4. docker login <local_registry>
|
||||||
|
5. docker push <local_registry>/<target_name>:<target_tag>
|
||||||
|
6. 写入 container_image 表
|
||||||
|
"""
|
||||||
|
env = request._run_ns
|
||||||
|
dbname = env.get_module_dbname(MODULE_NAME)
|
||||||
|
registry_id = params_kw.get('registry_id', '')
|
||||||
|
source_image = params_kw.get('source_image', '')
|
||||||
|
target_name = params_kw.get('target_name', '')
|
||||||
|
target_tag = params_kw.get('target_tag', 'latest')
|
||||||
|
image_type = params_kw.get('image_type', 'custom')
|
||||||
|
customer_id = params_kw.get('customer_id', '')
|
||||||
|
|
||||||
|
if not registry_id or not source_image:
|
||||||
|
return {'status': 'error', 'message': 'Missing registry_id or source_image'}
|
||||||
|
|
||||||
|
if not target_name:
|
||||||
|
# 从 source_image 提取镜像名
|
||||||
|
target_name = source_image.split('/')[-1].split(':')[0]
|
||||||
|
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
regs = await sor.R('image_registry', {'id': registry_id})
|
||||||
|
if not regs:
|
||||||
|
return {'status': 'error', 'message': 'Registry not found'}
|
||||||
|
reg = regs[0]
|
||||||
|
|
||||||
|
cnode = await _find_control_node(env, reg.cluster_id)
|
||||||
|
if not cnode:
|
||||||
|
return {'status': 'error', 'message': 'No control node found'}
|
||||||
|
|
||||||
|
local_registry = reg.internal_endpoint or reg.endpoint
|
||||||
|
target_full = local_registry + '/' + target_name + ':' + target_tag
|
||||||
|
|
||||||
|
steps = []
|
||||||
|
|
||||||
|
# 1. docker pull (from external source / mirror)
|
||||||
|
rc, out, err = await ssh_exec(cnode.ip_address, cnode.ssh_port or 22,
|
||||||
|
cnode.ssh_user or 'root',
|
||||||
|
'docker pull ' + source_image, timeout=300)
|
||||||
|
if rc != 0:
|
||||||
|
return {'status': 'error', 'message': 'Pull failed: ' + err[:300], 'steps': steps}
|
||||||
|
steps.append('pulled ' + source_image)
|
||||||
|
|
||||||
|
# 2. docker tag
|
||||||
|
rc, _, err = await ssh_exec(cnode.ip_address, cnode.ssh_port or 22,
|
||||||
|
cnode.ssh_user or 'root',
|
||||||
|
'docker tag ' + source_image + ' ' + target_full, timeout=30)
|
||||||
|
if rc != 0:
|
||||||
|
return {'status': 'error', 'message': 'Tag failed: ' + err[:200], 'steps': steps}
|
||||||
|
steps.append('tagged → ' + target_full)
|
||||||
|
|
||||||
|
# 3. docker login + push
|
||||||
|
login_cmd = ('docker login ' + local_registry
|
||||||
|
+ ' -u ' + (reg.admin_user or 'admin')
|
||||||
|
+ ' -p ' + (reg.admin_password or 'pcc123456'))
|
||||||
|
await ssh_exec(cnode.ip_address, cnode.ssh_port or 22, cnode.ssh_user or 'root',
|
||||||
|
login_cmd, timeout=30)
|
||||||
|
|
||||||
|
rc, out, err = await ssh_exec(cnode.ip_address, cnode.ssh_port or 22,
|
||||||
|
cnode.ssh_user or 'root',
|
||||||
|
'docker push ' + target_full, timeout=300)
|
||||||
|
if rc != 0:
|
||||||
|
return {'status': 'error', 'message': 'Push failed: ' + err[:300], 'steps': steps}
|
||||||
|
steps.append('pushed')
|
||||||
|
|
||||||
|
# 4. 获取镜像 digest 和 size
|
||||||
|
rc, inspect_out, _ = await ssh_exec(cnode.ip_address, cnode.ssh_port or 22,
|
||||||
|
cnode.ssh_user or 'root',
|
||||||
|
"docker inspect --format='{{.Id}} {{.Size}}' "
|
||||||
|
+ target_full, timeout=30)
|
||||||
|
digest = ''
|
||||||
|
size_bytes = 0
|
||||||
|
if rc == 0 and inspect_out.strip():
|
||||||
|
parts = inspect_out.strip().split()
|
||||||
|
digest = parts[0] if parts else ''
|
||||||
|
try:
|
||||||
|
size_bytes = int(parts[1]) if len(parts) > 1 else 0
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 5. 清理本地镜像 (可选, 节省控制节点磁盘)
|
||||||
|
await ssh_exec(cnode.ip_address, cnode.ssh_port or 22, cnode.ssh_user or 'root',
|
||||||
|
'docker rmi ' + source_image + ' ' + target_full + ' 2>/dev/null || true',
|
||||||
|
timeout=30)
|
||||||
|
|
||||||
|
# 6. 写入 DB
|
||||||
|
now = datetime.datetime.now().isoformat()
|
||||||
|
image_id = getID()
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
await sor.C('container_image', {
|
||||||
|
'id': image_id,
|
||||||
|
'resellerid': reg.resellerid or '*',
|
||||||
|
'registry_id': registry_id,
|
||||||
|
'image_name': target_name,
|
||||||
|
'image_tag': target_tag,
|
||||||
|
'full_name': target_name + ':' + target_tag,
|
||||||
|
'source_image': source_image,
|
||||||
|
'size_bytes': size_bytes,
|
||||||
|
'digest': digest,
|
||||||
|
'image_type': image_type,
|
||||||
|
'is_customer': 1 if customer_id else 0,
|
||||||
|
'customer_id': customer_id or '',
|
||||||
|
'sync_status': 'synced',
|
||||||
|
'sync_log': json.dumps({'steps': steps, 'digest': digest}),
|
||||||
|
'created_at': now,
|
||||||
|
'updated_at': now,
|
||||||
|
})
|
||||||
|
|
||||||
|
# 更新仓库统计
|
||||||
|
await sor.sqlExe(
|
||||||
|
'UPDATE image_registry SET image_count = image_count + 1, '
|
||||||
|
'total_size_gb = total_size_gb + ${sz}$, updated_at = ${now}$ WHERE id = ${rid}$',
|
||||||
|
{'sz': max(1, size_bytes // (1024 * 1024 * 1024)),
|
||||||
|
'now': now, 'rid': registry_id})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'status': 'ok',
|
||||||
|
'message': 'Imported ' + target_full,
|
||||||
|
'image_id': image_id,
|
||||||
|
'full_name': target_name + ':' + target_tag,
|
||||||
|
'size_bytes': size_bytes,
|
||||||
|
'steps': steps,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def image_sync(request, params_kw):
|
||||||
|
"""
|
||||||
|
同步镜像:从镜像源批量拉取 → 推送到本地仓库。
|
||||||
|
params: registry_id, mirror_id, image_list (逗号分隔的镜像名列表)
|
||||||
|
"""
|
||||||
|
env = request._run_ns
|
||||||
|
dbname = env.get_module_dbname(MODULE_NAME)
|
||||||
|
registry_id = params_kw.get('registry_id', '')
|
||||||
|
mirror_id = params_kw.get('mirror_id', '')
|
||||||
|
image_list = params_kw.get('image_list', '')
|
||||||
|
|
||||||
|
if not registry_id:
|
||||||
|
return {'status': 'error', 'message': 'Missing registry_id'}
|
||||||
|
|
||||||
|
images = [i.strip() for i in image_list.split(',') if i.strip()] if image_list else []
|
||||||
|
if not images:
|
||||||
|
# 默认同步一批常用镜像
|
||||||
|
images = [
|
||||||
|
'busybox:latest',
|
||||||
|
'alpine:latest',
|
||||||
|
'nginx:latest',
|
||||||
|
'redis:7-alpine',
|
||||||
|
'python:3.11-slim',
|
||||||
|
]
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for img in images:
|
||||||
|
# 构建 source image 路径
|
||||||
|
source = img
|
||||||
|
if mirror_id:
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
mirrors = await sor.R('mirror_source', {'id': mirror_id})
|
||||||
|
if mirrors:
|
||||||
|
m = mirrors[0]
|
||||||
|
if m.upstream_url and not img.startswith(m.registry_url):
|
||||||
|
source = m.registry_url + '/' + img
|
||||||
|
|
||||||
|
name = img.split(':')[0].replace('/', '_')
|
||||||
|
tag = img.split(':')[1] if ':' in img else 'latest'
|
||||||
|
|
||||||
|
result = await image_import(request, {
|
||||||
|
'registry_id': registry_id,
|
||||||
|
'source_image': source,
|
||||||
|
'target_name': name,
|
||||||
|
'target_tag': tag,
|
||||||
|
'image_type': 'base',
|
||||||
|
})
|
||||||
|
results.append({'image': img, 'result': result.get('status', 'unknown')})
|
||||||
|
|
||||||
|
return {'status': 'ok', 'synced': len(results), 'results': results}
|
||||||
|
|
||||||
|
|
||||||
|
async def image_push(request, params_kw):
|
||||||
|
"""
|
||||||
|
客户推送自定义镜像到本地仓库。
|
||||||
|
params: registry_id, image_name, image_tag, dockerfile_path (可选)
|
||||||
|
流程:
|
||||||
|
1. SSH 到客户提供的工作节点 (或有 docker 的环境)
|
||||||
|
2. docker build -t <target> . (如果有 Dockerfile)
|
||||||
|
3. docker push <target>
|
||||||
|
4. 记录到 container_image 表 (is_customer=1)
|
||||||
|
"""
|
||||||
|
env = request._run_ns
|
||||||
|
dbname = env.get_module_dbname(MODULE_NAME)
|
||||||
|
registry_id = params_kw.get('registry_id', '')
|
||||||
|
image_name = params_kw.get('image_name', '')
|
||||||
|
image_tag = params_kw.get('image_tag', 'latest')
|
||||||
|
customer_id = params_kw.get('customer_id', '')
|
||||||
|
node_id = params_kw.get('node_id', '') # 客户构建节点
|
||||||
|
|
||||||
|
if not registry_id or not image_name:
|
||||||
|
return {'status': 'error', 'message': 'Missing registry_id or image_name'}
|
||||||
|
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
regs = await sor.R('image_registry', {'id': registry_id})
|
||||||
|
if not regs:
|
||||||
|
return {'status': 'error', 'message': 'Registry not found'}
|
||||||
|
reg = regs[0]
|
||||||
|
|
||||||
|
# 确定构建节点: 优先使用客户指定 node, 否则用控制节点
|
||||||
|
build_node = None
|
||||||
|
if node_id:
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
nodes = await sor.R('compute_node', {'id': node_id})
|
||||||
|
if nodes:
|
||||||
|
build_node = nodes[0]
|
||||||
|
if not build_node:
|
||||||
|
build_node = await _find_control_node(env, reg.cluster_id)
|
||||||
|
if not build_node:
|
||||||
|
return {'status': 'error', 'message': 'No build node available'}
|
||||||
|
|
||||||
|
local_registry = reg.internal_endpoint or reg.endpoint
|
||||||
|
target_full = local_registry + '/' + image_name + ':' + image_tag
|
||||||
|
|
||||||
|
# docker login
|
||||||
|
login_cmd = ('docker login ' + local_registry
|
||||||
|
+ ' -u ' + (reg.admin_user or 'admin')
|
||||||
|
+ ' -p ' + (reg.admin_password or 'pcc123456'))
|
||||||
|
await ssh_exec(build_node.ip_address, build_node.ssh_port or 22,
|
||||||
|
build_node.ssh_user or 'root', login_cmd, timeout=30)
|
||||||
|
|
||||||
|
# docker push (假设客户已在节点上构建好镜像)
|
||||||
|
rc, out, err = await ssh_exec(build_node.ip_address, build_node.ssh_port or 22,
|
||||||
|
build_node.ssh_user or 'root',
|
||||||
|
'docker push ' + target_full, timeout=300)
|
||||||
|
if rc != 0:
|
||||||
|
return {'status': 'error', 'message': 'Push failed: ' + err[:300]}
|
||||||
|
|
||||||
|
# 记录
|
||||||
|
now = datetime.datetime.now().isoformat()
|
||||||
|
image_id = getID()
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
await sor.C('container_image', {
|
||||||
|
'id': image_id,
|
||||||
|
'resellerid': reg.resellerid or '*',
|
||||||
|
'registry_id': registry_id,
|
||||||
|
'image_name': image_name,
|
||||||
|
'image_tag': image_tag,
|
||||||
|
'full_name': image_name + ':' + image_tag,
|
||||||
|
'source_image': target_full,
|
||||||
|
'image_type': 'custom',
|
||||||
|
'is_customer': 1,
|
||||||
|
'customer_id': customer_id or '',
|
||||||
|
'sync_status': 'synced',
|
||||||
|
'created_at': now,
|
||||||
|
'updated_at': now,
|
||||||
|
})
|
||||||
|
await sor.sqlExe(
|
||||||
|
'UPDATE image_registry SET image_count = image_count + 1, '
|
||||||
|
'updated_at = ${now}$ WHERE id = ${rid}$',
|
||||||
|
{'now': now, 'rid': registry_id})
|
||||||
|
|
||||||
|
return {'status': 'ok', 'message': 'Pushed ' + target_full, 'image_id': image_id}
|
||||||
|
|
||||||
|
async def image_stats(request, params_kw):
|
||||||
|
"""获取镜像统计"""
|
||||||
|
env = request._run_ns
|
||||||
|
dbname = env.get_module_dbname('image_mgr')
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
mirrors = await sor.R('mirror_source', {})
|
||||||
|
registries = await sor.R('image_registry', {})
|
||||||
|
images = await sor.R('container_image', {})
|
||||||
|
return {'status': 'ok', 'data': {
|
||||||
|
'mirror_count': len(mirrors),
|
||||||
|
'active_mirrors': sum(1 for m in mirrors if getattr(m, 'status', '') == 'active'),
|
||||||
|
'registry_count': len(registries),
|
||||||
|
'running_registries': sum(1 for r in registries if getattr(r, 'status', '') == 'running'),
|
||||||
|
'image_count': len(images),
|
||||||
|
'custom_images': sum(1 for i in images if getattr(i, 'is_custom', '') == '1'),
|
||||||
|
}}
|
||||||
|
|
||||||
70
build/lib/build/lib/build/lib/scripts/load_path.py
Normal file
70
build/lib/build/lib/build/lib/scripts/load_path.py
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""image_mgr RBAC 权限管理"""
|
||||||
|
import subprocess, os, sys, json, glob
|
||||||
|
|
||||||
|
mod_name = 'image_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.")
|
||||||
697
build/lib/build/lib/image_mgr/__init__.py
Normal file
697
build/lib/build/lib/image_mgr/__init__.py
Normal file
@ -0,0 +1,697 @@
|
|||||||
|
"""
|
||||||
|
image_mgr — 容器镜像管理
|
||||||
|
|
||||||
|
- 国内镜像源配置 (mirror_source): 阿里云、DaoCloud 等安全可触达节点
|
||||||
|
- 本地镜像仓库 (image_registry): Docker Registry v2 on K8s + NFS CSI
|
||||||
|
- 容器镜像管理 (container_image): 导入/同步/推送/列表/删除
|
||||||
|
- 客户自定义镜像支持
|
||||||
|
"""
|
||||||
|
import datetime, json, asyncio, subprocess
|
||||||
|
from appPublic.uniqueID import getID
|
||||||
|
from sqlor.dbpools import DBPools
|
||||||
|
|
||||||
|
MODULE_NAME = 'pccs'
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
# 预置的国内安全镜像源
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
PRESET_MIRRORS = [
|
||||||
|
{
|
||||||
|
'name': '阿里云容器镜像服务(杭州)',
|
||||||
|
'source_type': 'public_mirror',
|
||||||
|
'registry_url': 'registry.cn-hangzhou.aliyuncs.com',
|
||||||
|
'upstream_url': 'docker.io',
|
||||||
|
'region': 'cn',
|
||||||
|
'priority': 10,
|
||||||
|
'is_default': 1,
|
||||||
|
'note': 'registry.cn-hangzhou.aliyuncs.com/{namespace}/{repo}'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': '阿里云 Google容器镜像',
|
||||||
|
'source_type': 'public_mirror',
|
||||||
|
'registry_url': 'registry.cn-hangzhou.aliyuncs.com/google_containers',
|
||||||
|
'upstream_url': 'k8s.gcr.io',
|
||||||
|
'region': 'cn',
|
||||||
|
'priority': 20,
|
||||||
|
'note': 'K8s 官方镜像国内加速'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'DaoCloud 公共镜像',
|
||||||
|
'source_type': 'public_mirror',
|
||||||
|
'registry_url': 'docker.daocloud.io',
|
||||||
|
'upstream_url': 'docker.io',
|
||||||
|
'region': 'cn',
|
||||||
|
'priority': 15,
|
||||||
|
'note': 'DaoCloud 代理加速'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'DaoCloud GCR 镜像',
|
||||||
|
'source_type': 'public_mirror',
|
||||||
|
'registry_url': 'gcr.daocloud.io',
|
||||||
|
'upstream_url': 'gcr.io',
|
||||||
|
'region': 'cn',
|
||||||
|
'priority': 25,
|
||||||
|
'note': 'gcr.io 国内加速'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'DaoCloud Quay 镜像',
|
||||||
|
'source_type': 'public_mirror',
|
||||||
|
'registry_url': 'quay.daocloud.io',
|
||||||
|
'upstream_url': 'quay.io',
|
||||||
|
'region': 'cn',
|
||||||
|
'priority': 25,
|
||||||
|
'note': 'quay.io 国内加速'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'DaoCloud GHCR 镜像',
|
||||||
|
'source_type': 'public_mirror',
|
||||||
|
'registry_url': 'ghcr.daocloud.io',
|
||||||
|
'upstream_url': 'ghcr.io',
|
||||||
|
'region': 'cn',
|
||||||
|
'priority': 25,
|
||||||
|
'note': 'GitHub Container Registry 国内加速'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': '腾讯云容器镜像',
|
||||||
|
'source_type': 'public_mirror',
|
||||||
|
'registry_url': 'mirror.ccs.tencentyun.com',
|
||||||
|
'upstream_url': 'docker.io',
|
||||||
|
'region': 'cn',
|
||||||
|
'priority': 30,
|
||||||
|
'note': '腾讯云公共镜像加速'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Docker Hub (直连)',
|
||||||
|
'source_type': 'hub',
|
||||||
|
'registry_url': 'docker.io',
|
||||||
|
'upstream_url': '',
|
||||||
|
'region': 'us',
|
||||||
|
'priority': 100,
|
||||||
|
'note': 'Docker官方, 国内可能慢/不可达'
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
# SSH 工具
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async def ssh_exec(host, port, user, cmd, timeout=120):
|
||||||
|
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 _find_control_node(env, cluster_id):
|
||||||
|
"""查找集群活跃控制节点"""
|
||||||
|
dbname = env.get_module_dbname(MODULE_NAME)
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
recs = await sor.R('cluster_node',
|
||||||
|
{'cluster_id': cluster_id, 'role': 'control', 'status': 'active'})
|
||||||
|
if not recs:
|
||||||
|
return None
|
||||||
|
nodes = await sor.R('compute_node', {'id': recs[0].node_id})
|
||||||
|
return nodes[0] if nodes else None
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
# 镜像源管理
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async def mirror_seed(request, params_kw):
|
||||||
|
"""
|
||||||
|
种子预置镜像源到数据库。
|
||||||
|
调用后自动将 PRESET_MIRRORS 写入 mirror_source 表 (跳过已存在的).
|
||||||
|
"""
|
||||||
|
env = request._run_ns
|
||||||
|
dbname = env.get_module_dbname(MODULE_NAME)
|
||||||
|
now = datetime.datetime.now().isoformat()
|
||||||
|
added = 0
|
||||||
|
skipped = 0
|
||||||
|
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
for m in PRESET_MIRRORS:
|
||||||
|
# 检查是否已存在
|
||||||
|
existing = await sor.R('mirror_source',
|
||||||
|
{'registry_url': m['registry_url']})
|
||||||
|
if existing:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
await sor.C('mirror_source', {
|
||||||
|
'id': getID(),
|
||||||
|
'resellerid': '*',
|
||||||
|
'name': m['name'],
|
||||||
|
'source_type': m['source_type'],
|
||||||
|
'registry_url': m['registry_url'],
|
||||||
|
'upstream_url': m.get('upstream_url', ''),
|
||||||
|
'region': m.get('region', 'cn'),
|
||||||
|
'priority': m.get('priority', 100),
|
||||||
|
'is_default': m.get('is_default', 0),
|
||||||
|
'status': 'active',
|
||||||
|
'created_at': now,
|
||||||
|
'updated_at': now,
|
||||||
|
})
|
||||||
|
added += 1
|
||||||
|
|
||||||
|
return {'status': 'ok', 'message': 'Seeded ' + str(added) + ' mirrors, skipped ' + str(skipped)}
|
||||||
|
|
||||||
|
|
||||||
|
async def mirror_health_check(request, params_kw):
|
||||||
|
"""
|
||||||
|
检查镜像源是否可达。
|
||||||
|
尝试对该源执行 docker pull 一个轻量镜像 (如 busybox).
|
||||||
|
params: mirror_id (可选, 不传则检查所有 active 源)
|
||||||
|
"""
|
||||||
|
env = request._run_ns
|
||||||
|
dbname = env.get_module_dbname(MODULE_NAME)
|
||||||
|
mirror_id = params_kw.get('mirror_id', '')
|
||||||
|
cluster_id = params_kw.get('cluster_id', '')
|
||||||
|
|
||||||
|
if not cluster_id:
|
||||||
|
return {'status': 'error', 'message': 'Missing cluster_id (needed for SSH)'}
|
||||||
|
|
||||||
|
cnode = await _find_control_node(env, cluster_id)
|
||||||
|
if not cnode:
|
||||||
|
return {'status': 'error', 'message': 'No active control node found'}
|
||||||
|
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
if mirror_id:
|
||||||
|
mirrors = await sor.R('mirror_source', {'id': mirror_id})
|
||||||
|
else:
|
||||||
|
mirrors = await sor.R('mirror_source', {'status': 'active'})
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for m in mirrors:
|
||||||
|
test_image = m.registry_url + '/library/busybox:latest'
|
||||||
|
if m.source_type == 'public_mirror' and m.upstream_url:
|
||||||
|
# 对于 mirror 类型的源, pull 测试: <mirror_url>/library/busybox:latest
|
||||||
|
test_image = m.registry_url + '/library/busybox:latest'
|
||||||
|
else:
|
||||||
|
test_image = m.registry_url + '/busybox:latest'
|
||||||
|
|
||||||
|
rc, out, err = await ssh_exec(
|
||||||
|
cnode.ip_address, cnode.ssh_port or 22, cnode.ssh_user or 'root',
|
||||||
|
'docker pull ' + test_image + ' 2>&1 && docker rmi ' + test_image + ' 2>/dev/null',
|
||||||
|
timeout=60)
|
||||||
|
reachable = rc == 0
|
||||||
|
results.append({
|
||||||
|
'id': m.id, 'name': m.name, 'registry_url': m.registry_url,
|
||||||
|
'reachable': reachable, 'error': err[:200] if not reachable else ''
|
||||||
|
})
|
||||||
|
|
||||||
|
# 更新最后检查时间
|
||||||
|
await sor.U('mirror_source', {'id': m.id},
|
||||||
|
{'health_check_at': datetime.datetime.now().isoformat(),
|
||||||
|
'status': 'active' if reachable else 'error'})
|
||||||
|
|
||||||
|
return {'status': 'ok', 'results': results}
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
# 镜像仓库部署
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async def registry_deploy(request, params_kw):
|
||||||
|
"""
|
||||||
|
在 K8s 集群上部署本地 Docker Registry v2.
|
||||||
|
params: registry_id (image_registry 记录ID)
|
||||||
|
流程:
|
||||||
|
1. 读取 image_registry 记录
|
||||||
|
2. 读取集群控制节点
|
||||||
|
3. SSH → kubectl apply deployment + service + pvc
|
||||||
|
4. 生成 htpasswd 认证
|
||||||
|
5. 等待就绪 → 更新 endpoint
|
||||||
|
"""
|
||||||
|
env = request._run_ns
|
||||||
|
dbname = env.get_module_dbname(MODULE_NAME)
|
||||||
|
registry_id = params_kw.get('registry_id', '')
|
||||||
|
|
||||||
|
if not registry_id:
|
||||||
|
return {'status': 'error', 'message': 'Missing registry_id'}
|
||||||
|
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
regs = await sor.R('image_registry', {'id': registry_id})
|
||||||
|
if not regs:
|
||||||
|
return {'status': 'error', 'message': 'Registry not found'}
|
||||||
|
reg = regs[0]
|
||||||
|
|
||||||
|
cluster_id = reg.cluster_id
|
||||||
|
if not cluster_id:
|
||||||
|
return {'status': 'error', 'message': 'Registry not bound to a cluster'}
|
||||||
|
|
||||||
|
cnode = await _find_control_node(env, cluster_id)
|
||||||
|
if not cnode:
|
||||||
|
return {'status': 'error', 'message': 'No active control node in cluster ' + cluster_id}
|
||||||
|
|
||||||
|
# 生成密码 (如果未设置)
|
||||||
|
admin_user = reg.admin_user or 'admin'
|
||||||
|
admin_pass = reg.admin_password or 'pcc123456'
|
||||||
|
storage_size = reg.storage_size or '50Gi'
|
||||||
|
storage_class = reg.storage_class or 'pcc-nfs-sc'
|
||||||
|
namespace = 'pcc-registry'
|
||||||
|
|
||||||
|
steps = []
|
||||||
|
|
||||||
|
# 1. 创建 namespace
|
||||||
|
rc, _, _ = await ssh_exec(cnode.ip_address, cnode.ssh_port or 22, cnode.ssh_user or 'root',
|
||||||
|
'kubectl create namespace ' + namespace + ' 2>/dev/null || true')
|
||||||
|
steps.append('ns_created')
|
||||||
|
|
||||||
|
# 2. 创建 htpasswd secret
|
||||||
|
htpasswd_cmd = (
|
||||||
|
'docker run --rm httpd:2-alpine htpasswd -nbB ' + admin_user + ' ' + admin_pass
|
||||||
|
+ ' | base64 -w0'
|
||||||
|
)
|
||||||
|
rc, htpasswd_b64, _ = await ssh_exec(cnode.ip_address, cnode.ssh_port or 22,
|
||||||
|
cnode.ssh_user or 'root', htpasswd_cmd, timeout=60)
|
||||||
|
secret_yaml = (
|
||||||
|
'apiVersion: v1\n'
|
||||||
|
'kind: Secret\n'
|
||||||
|
'metadata:\n'
|
||||||
|
' name: registry-auth\n'
|
||||||
|
' namespace: ' + namespace + '\n'
|
||||||
|
'data:\n'
|
||||||
|
' htpasswd: ' + htpasswd_b64.strip() + '\n'
|
||||||
|
)
|
||||||
|
await ssh_exec(cnode.ip_address, cnode.ssh_port or 22, cnode.ssh_user or 'root',
|
||||||
|
"echo '" + secret_yaml + "' | kubectl apply -f -")
|
||||||
|
steps.append('secret_created')
|
||||||
|
|
||||||
|
# 3. 创建 PVC
|
||||||
|
pvc_yaml = (
|
||||||
|
'apiVersion: v1\n'
|
||||||
|
'kind: PersistentVolumeClaim\n'
|
||||||
|
'metadata:\n'
|
||||||
|
' name: registry-storage\n'
|
||||||
|
' namespace: ' + namespace + '\n'
|
||||||
|
'spec:\n'
|
||||||
|
' accessModes:\n'
|
||||||
|
' - ReadWriteMany\n'
|
||||||
|
' storageClassName: ' + storage_class + '\n'
|
||||||
|
' resources:\n'
|
||||||
|
' requests:\n'
|
||||||
|
' storage: ' + storage_size + '\n'
|
||||||
|
)
|
||||||
|
await ssh_exec(cnode.ip_address, cnode.ssh_port or 22, cnode.ssh_user or 'root',
|
||||||
|
"echo '" + pvc_yaml + "' | kubectl apply -f -")
|
||||||
|
steps.append('pvc_created')
|
||||||
|
|
||||||
|
# 4. 创建 Deployment
|
||||||
|
deploy_yaml = (
|
||||||
|
'apiVersion: apps/v1\n'
|
||||||
|
'kind: Deployment\n'
|
||||||
|
'metadata:\n'
|
||||||
|
' name: registry\n'
|
||||||
|
' namespace: ' + namespace + '\n'
|
||||||
|
'spec:\n'
|
||||||
|
' replicas: 1\n'
|
||||||
|
' selector:\n'
|
||||||
|
' matchLabels:\n'
|
||||||
|
' app: registry\n'
|
||||||
|
' template:\n'
|
||||||
|
' metadata:\n'
|
||||||
|
' labels:\n'
|
||||||
|
' app: registry\n'
|
||||||
|
' spec:\n'
|
||||||
|
' containers:\n'
|
||||||
|
' - name: registry\n'
|
||||||
|
' image: registry:2\n'
|
||||||
|
' ports:\n'
|
||||||
|
' - containerPort: 5000\n'
|
||||||
|
' env:\n'
|
||||||
|
' - name: REGISTRY_AUTH\n'
|
||||||
|
' value: htpasswd\n'
|
||||||
|
' - name: REGISTRY_AUTH_HTPASSWD_REALM\n'
|
||||||
|
' value: Registry Realm\n'
|
||||||
|
' - name: REGISTRY_AUTH_HTPASSWD_PATH\n'
|
||||||
|
' value: /auth/htpasswd\n'
|
||||||
|
' - name: REGISTRY_STORAGE_DELETE_ENABLED\n'
|
||||||
|
' value: "true"\n'
|
||||||
|
' volumeMounts:\n'
|
||||||
|
' - name: storage\n'
|
||||||
|
' mountPath: /var/lib/registry\n'
|
||||||
|
' - name: auth\n'
|
||||||
|
' mountPath: /auth\n'
|
||||||
|
' volumes:\n'
|
||||||
|
' - name: storage\n'
|
||||||
|
' persistentVolumeClaim:\n'
|
||||||
|
' claimName: registry-storage\n'
|
||||||
|
' - name: auth\n'
|
||||||
|
' secret:\n'
|
||||||
|
' secretName: registry-auth\n'
|
||||||
|
)
|
||||||
|
await ssh_exec(cnode.ip_address, cnode.ssh_port or 22, cnode.ssh_user or 'root',
|
||||||
|
"echo '" + deploy_yaml + "' | kubectl apply -f -")
|
||||||
|
steps.append('deploy_created')
|
||||||
|
|
||||||
|
# 5. 创建 Service (NodePort)
|
||||||
|
svc_yaml = (
|
||||||
|
'apiVersion: v1\n'
|
||||||
|
'kind: Service\n'
|
||||||
|
'metadata:\n'
|
||||||
|
' name: registry\n'
|
||||||
|
' namespace: ' + namespace + '\n'
|
||||||
|
'spec:\n'
|
||||||
|
' type: NodePort\n'
|
||||||
|
' selector:\n'
|
||||||
|
' app: registry\n'
|
||||||
|
' ports:\n'
|
||||||
|
' - port: 5000\n'
|
||||||
|
' targetPort: 5000\n'
|
||||||
|
' nodePort: 30500\n'
|
||||||
|
)
|
||||||
|
await ssh_exec(cnode.ip_address, cnode.ssh_port or 22, cnode.ssh_user or 'root',
|
||||||
|
"echo '" + svc_yaml + "' | kubectl apply -f -")
|
||||||
|
steps.append('svc_created')
|
||||||
|
|
||||||
|
# 6. 等待就绪
|
||||||
|
rc, _, _ = await ssh_exec(cnode.ip_address, cnode.ssh_port or 22, cnode.ssh_user or 'root',
|
||||||
|
'kubectl wait --for=condition=available deployment/registry -n '
|
||||||
|
+ namespace + ' --timeout=120s', timeout=150)
|
||||||
|
|
||||||
|
# 7. 获取 NodePort 地址 → endpoint
|
||||||
|
endpoint = cnode.ip_address + ':30500'
|
||||||
|
internal_endpoint = 'registry.' + namespace + '.svc.cluster.local:5000'
|
||||||
|
|
||||||
|
now = datetime.datetime.now().isoformat()
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
await sor.U('image_registry', {'id': registry_id}, {
|
||||||
|
'endpoint': endpoint,
|
||||||
|
'internal_endpoint': internal_endpoint,
|
||||||
|
'status': 'running',
|
||||||
|
'deploy_config': json.dumps({
|
||||||
|
'namespace': namespace,
|
||||||
|
'node_port': 30500,
|
||||||
|
'steps': steps,
|
||||||
|
}),
|
||||||
|
'updated_at': now,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'status': 'ok',
|
||||||
|
'message': 'Registry deployed at ' + endpoint,
|
||||||
|
'endpoint': endpoint,
|
||||||
|
'internal': internal_endpoint,
|
||||||
|
'admin_user': admin_user,
|
||||||
|
'steps': steps,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
# 镜像操作
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async def image_import(request, params_kw):
|
||||||
|
"""
|
||||||
|
从外部镜像源导入镜像到本地仓库。
|
||||||
|
params: registry_id, source_image, target_name, target_tag
|
||||||
|
流程:
|
||||||
|
1. SSH 到控制节点
|
||||||
|
2. docker pull <source_image> (通过配置的镜像加速/代理)
|
||||||
|
3. docker tag <source_image> <local_registry>/<target_name>:<target_tag>
|
||||||
|
4. docker login <local_registry>
|
||||||
|
5. docker push <local_registry>/<target_name>:<target_tag>
|
||||||
|
6. 写入 container_image 表
|
||||||
|
"""
|
||||||
|
env = request._run_ns
|
||||||
|
dbname = env.get_module_dbname(MODULE_NAME)
|
||||||
|
registry_id = params_kw.get('registry_id', '')
|
||||||
|
source_image = params_kw.get('source_image', '')
|
||||||
|
target_name = params_kw.get('target_name', '')
|
||||||
|
target_tag = params_kw.get('target_tag', 'latest')
|
||||||
|
image_type = params_kw.get('image_type', 'custom')
|
||||||
|
customer_id = params_kw.get('customer_id', '')
|
||||||
|
|
||||||
|
if not registry_id or not source_image:
|
||||||
|
return {'status': 'error', 'message': 'Missing registry_id or source_image'}
|
||||||
|
|
||||||
|
if not target_name:
|
||||||
|
# 从 source_image 提取镜像名
|
||||||
|
target_name = source_image.split('/')[-1].split(':')[0]
|
||||||
|
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
regs = await sor.R('image_registry', {'id': registry_id})
|
||||||
|
if not regs:
|
||||||
|
return {'status': 'error', 'message': 'Registry not found'}
|
||||||
|
reg = regs[0]
|
||||||
|
|
||||||
|
cnode = await _find_control_node(env, reg.cluster_id)
|
||||||
|
if not cnode:
|
||||||
|
return {'status': 'error', 'message': 'No control node found'}
|
||||||
|
|
||||||
|
local_registry = reg.internal_endpoint or reg.endpoint
|
||||||
|
target_full = local_registry + '/' + target_name + ':' + target_tag
|
||||||
|
|
||||||
|
steps = []
|
||||||
|
|
||||||
|
# 1. docker pull (from external source / mirror)
|
||||||
|
rc, out, err = await ssh_exec(cnode.ip_address, cnode.ssh_port or 22,
|
||||||
|
cnode.ssh_user or 'root',
|
||||||
|
'docker pull ' + source_image, timeout=300)
|
||||||
|
if rc != 0:
|
||||||
|
return {'status': 'error', 'message': 'Pull failed: ' + err[:300], 'steps': steps}
|
||||||
|
steps.append('pulled ' + source_image)
|
||||||
|
|
||||||
|
# 2. docker tag
|
||||||
|
rc, _, err = await ssh_exec(cnode.ip_address, cnode.ssh_port or 22,
|
||||||
|
cnode.ssh_user or 'root',
|
||||||
|
'docker tag ' + source_image + ' ' + target_full, timeout=30)
|
||||||
|
if rc != 0:
|
||||||
|
return {'status': 'error', 'message': 'Tag failed: ' + err[:200], 'steps': steps}
|
||||||
|
steps.append('tagged → ' + target_full)
|
||||||
|
|
||||||
|
# 3. docker login + push
|
||||||
|
login_cmd = ('docker login ' + local_registry
|
||||||
|
+ ' -u ' + (reg.admin_user or 'admin')
|
||||||
|
+ ' -p ' + (reg.admin_password or 'pcc123456'))
|
||||||
|
await ssh_exec(cnode.ip_address, cnode.ssh_port or 22, cnode.ssh_user or 'root',
|
||||||
|
login_cmd, timeout=30)
|
||||||
|
|
||||||
|
rc, out, err = await ssh_exec(cnode.ip_address, cnode.ssh_port or 22,
|
||||||
|
cnode.ssh_user or 'root',
|
||||||
|
'docker push ' + target_full, timeout=300)
|
||||||
|
if rc != 0:
|
||||||
|
return {'status': 'error', 'message': 'Push failed: ' + err[:300], 'steps': steps}
|
||||||
|
steps.append('pushed')
|
||||||
|
|
||||||
|
# 4. 获取镜像 digest 和 size
|
||||||
|
rc, inspect_out, _ = await ssh_exec(cnode.ip_address, cnode.ssh_port or 22,
|
||||||
|
cnode.ssh_user or 'root',
|
||||||
|
"docker inspect --format='{{.Id}} {{.Size}}' "
|
||||||
|
+ target_full, timeout=30)
|
||||||
|
digest = ''
|
||||||
|
size_bytes = 0
|
||||||
|
if rc == 0 and inspect_out.strip():
|
||||||
|
parts = inspect_out.strip().split()
|
||||||
|
digest = parts[0] if parts else ''
|
||||||
|
try:
|
||||||
|
size_bytes = int(parts[1]) if len(parts) > 1 else 0
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 5. 清理本地镜像 (可选, 节省控制节点磁盘)
|
||||||
|
await ssh_exec(cnode.ip_address, cnode.ssh_port or 22, cnode.ssh_user or 'root',
|
||||||
|
'docker rmi ' + source_image + ' ' + target_full + ' 2>/dev/null || true',
|
||||||
|
timeout=30)
|
||||||
|
|
||||||
|
# 6. 写入 DB
|
||||||
|
now = datetime.datetime.now().isoformat()
|
||||||
|
image_id = getID()
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
await sor.C('container_image', {
|
||||||
|
'id': image_id,
|
||||||
|
'resellerid': reg.resellerid or '*',
|
||||||
|
'registry_id': registry_id,
|
||||||
|
'image_name': target_name,
|
||||||
|
'image_tag': target_tag,
|
||||||
|
'full_name': target_name + ':' + target_tag,
|
||||||
|
'source_image': source_image,
|
||||||
|
'size_bytes': size_bytes,
|
||||||
|
'digest': digest,
|
||||||
|
'image_type': image_type,
|
||||||
|
'is_customer': 1 if customer_id else 0,
|
||||||
|
'customer_id': customer_id or '',
|
||||||
|
'sync_status': 'synced',
|
||||||
|
'sync_log': json.dumps({'steps': steps, 'digest': digest}),
|
||||||
|
'created_at': now,
|
||||||
|
'updated_at': now,
|
||||||
|
})
|
||||||
|
|
||||||
|
# 更新仓库统计
|
||||||
|
await sor.sqlExe(
|
||||||
|
'UPDATE image_registry SET image_count = image_count + 1, '
|
||||||
|
'total_size_gb = total_size_gb + ${sz}$, updated_at = ${now}$ WHERE id = ${rid}$',
|
||||||
|
{'sz': max(1, size_bytes // (1024 * 1024 * 1024)),
|
||||||
|
'now': now, 'rid': registry_id})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'status': 'ok',
|
||||||
|
'message': 'Imported ' + target_full,
|
||||||
|
'image_id': image_id,
|
||||||
|
'full_name': target_name + ':' + target_tag,
|
||||||
|
'size_bytes': size_bytes,
|
||||||
|
'steps': steps,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def image_sync(request, params_kw):
|
||||||
|
"""
|
||||||
|
同步镜像:从镜像源批量拉取 → 推送到本地仓库。
|
||||||
|
params: registry_id, mirror_id, image_list (逗号分隔的镜像名列表)
|
||||||
|
"""
|
||||||
|
env = request._run_ns
|
||||||
|
dbname = env.get_module_dbname(MODULE_NAME)
|
||||||
|
registry_id = params_kw.get('registry_id', '')
|
||||||
|
mirror_id = params_kw.get('mirror_id', '')
|
||||||
|
image_list = params_kw.get('image_list', '')
|
||||||
|
|
||||||
|
if not registry_id:
|
||||||
|
return {'status': 'error', 'message': 'Missing registry_id'}
|
||||||
|
|
||||||
|
images = [i.strip() for i in image_list.split(',') if i.strip()] if image_list else []
|
||||||
|
if not images:
|
||||||
|
# 默认同步一批常用镜像
|
||||||
|
images = [
|
||||||
|
'busybox:latest',
|
||||||
|
'alpine:latest',
|
||||||
|
'nginx:latest',
|
||||||
|
'redis:7-alpine',
|
||||||
|
'python:3.11-slim',
|
||||||
|
]
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for img in images:
|
||||||
|
# 构建 source image 路径
|
||||||
|
source = img
|
||||||
|
if mirror_id:
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
mirrors = await sor.R('mirror_source', {'id': mirror_id})
|
||||||
|
if mirrors:
|
||||||
|
m = mirrors[0]
|
||||||
|
if m.upstream_url and not img.startswith(m.registry_url):
|
||||||
|
source = m.registry_url + '/' + img
|
||||||
|
|
||||||
|
name = img.split(':')[0].replace('/', '_')
|
||||||
|
tag = img.split(':')[1] if ':' in img else 'latest'
|
||||||
|
|
||||||
|
result = await image_import(request, {
|
||||||
|
'registry_id': registry_id,
|
||||||
|
'source_image': source,
|
||||||
|
'target_name': name,
|
||||||
|
'target_tag': tag,
|
||||||
|
'image_type': 'base',
|
||||||
|
})
|
||||||
|
results.append({'image': img, 'result': result.get('status', 'unknown')})
|
||||||
|
|
||||||
|
return {'status': 'ok', 'synced': len(results), 'results': results}
|
||||||
|
|
||||||
|
|
||||||
|
async def image_push(request, params_kw):
|
||||||
|
"""
|
||||||
|
客户推送自定义镜像到本地仓库。
|
||||||
|
params: registry_id, image_name, image_tag, dockerfile_path (可选)
|
||||||
|
流程:
|
||||||
|
1. SSH 到客户提供的工作节点 (或有 docker 的环境)
|
||||||
|
2. docker build -t <target> . (如果有 Dockerfile)
|
||||||
|
3. docker push <target>
|
||||||
|
4. 记录到 container_image 表 (is_customer=1)
|
||||||
|
"""
|
||||||
|
env = request._run_ns
|
||||||
|
dbname = env.get_module_dbname(MODULE_NAME)
|
||||||
|
registry_id = params_kw.get('registry_id', '')
|
||||||
|
image_name = params_kw.get('image_name', '')
|
||||||
|
image_tag = params_kw.get('image_tag', 'latest')
|
||||||
|
customer_id = params_kw.get('customer_id', '')
|
||||||
|
node_id = params_kw.get('node_id', '') # 客户构建节点
|
||||||
|
|
||||||
|
if not registry_id or not image_name:
|
||||||
|
return {'status': 'error', 'message': 'Missing registry_id or image_name'}
|
||||||
|
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
regs = await sor.R('image_registry', {'id': registry_id})
|
||||||
|
if not regs:
|
||||||
|
return {'status': 'error', 'message': 'Registry not found'}
|
||||||
|
reg = regs[0]
|
||||||
|
|
||||||
|
# 确定构建节点: 优先使用客户指定 node, 否则用控制节点
|
||||||
|
build_node = None
|
||||||
|
if node_id:
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
nodes = await sor.R('compute_node', {'id': node_id})
|
||||||
|
if nodes:
|
||||||
|
build_node = nodes[0]
|
||||||
|
if not build_node:
|
||||||
|
build_node = await _find_control_node(env, reg.cluster_id)
|
||||||
|
if not build_node:
|
||||||
|
return {'status': 'error', 'message': 'No build node available'}
|
||||||
|
|
||||||
|
local_registry = reg.internal_endpoint or reg.endpoint
|
||||||
|
target_full = local_registry + '/' + image_name + ':' + image_tag
|
||||||
|
|
||||||
|
# docker login
|
||||||
|
login_cmd = ('docker login ' + local_registry
|
||||||
|
+ ' -u ' + (reg.admin_user or 'admin')
|
||||||
|
+ ' -p ' + (reg.admin_password or 'pcc123456'))
|
||||||
|
await ssh_exec(build_node.ip_address, build_node.ssh_port or 22,
|
||||||
|
build_node.ssh_user or 'root', login_cmd, timeout=30)
|
||||||
|
|
||||||
|
# docker push (假设客户已在节点上构建好镜像)
|
||||||
|
rc, out, err = await ssh_exec(build_node.ip_address, build_node.ssh_port or 22,
|
||||||
|
build_node.ssh_user or 'root',
|
||||||
|
'docker push ' + target_full, timeout=300)
|
||||||
|
if rc != 0:
|
||||||
|
return {'status': 'error', 'message': 'Push failed: ' + err[:300]}
|
||||||
|
|
||||||
|
# 记录
|
||||||
|
now = datetime.datetime.now().isoformat()
|
||||||
|
image_id = getID()
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
await sor.C('container_image', {
|
||||||
|
'id': image_id,
|
||||||
|
'resellerid': reg.resellerid or '*',
|
||||||
|
'registry_id': registry_id,
|
||||||
|
'image_name': image_name,
|
||||||
|
'image_tag': image_tag,
|
||||||
|
'full_name': image_name + ':' + image_tag,
|
||||||
|
'source_image': target_full,
|
||||||
|
'image_type': 'custom',
|
||||||
|
'is_customer': 1,
|
||||||
|
'customer_id': customer_id or '',
|
||||||
|
'sync_status': 'synced',
|
||||||
|
'created_at': now,
|
||||||
|
'updated_at': now,
|
||||||
|
})
|
||||||
|
await sor.sqlExe(
|
||||||
|
'UPDATE image_registry SET image_count = image_count + 1, '
|
||||||
|
'updated_at = ${now}$ WHERE id = ${rid}$',
|
||||||
|
{'now': now, 'rid': registry_id})
|
||||||
|
|
||||||
|
return {'status': 'ok', 'message': 'Pushed ' + target_full, 'image_id': image_id}
|
||||||
|
|
||||||
|
async def image_stats(request, params_kw):
|
||||||
|
"""获取镜像统计"""
|
||||||
|
env = request._run_ns
|
||||||
|
dbname = env.get_module_dbname('image_mgr')
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
mirrors = await sor.R('mirror_source', {})
|
||||||
|
registries = await sor.R('image_registry', {})
|
||||||
|
images = await sor.R('container_image', {})
|
||||||
|
return {'status': 'ok', 'data': {
|
||||||
|
'mirror_count': len(mirrors),
|
||||||
|
'active_mirrors': sum(1 for m in mirrors if getattr(m, 'status', '') == 'active'),
|
||||||
|
'registry_count': len(registries),
|
||||||
|
'running_registries': sum(1 for r in registries if getattr(r, 'status', '') == 'running'),
|
||||||
|
'image_count': len(images),
|
||||||
|
'custom_images': sum(1 for i in images if getattr(i, 'is_custom', '') == '1'),
|
||||||
|
}}
|
||||||
|
|
||||||
70
build/lib/build/lib/scripts/load_path.py
Normal file
70
build/lib/build/lib/scripts/load_path.py
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""image_mgr RBAC 权限管理"""
|
||||||
|
import subprocess, os, sys, json, glob
|
||||||
|
|
||||||
|
mod_name = 'image_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.")
|
||||||
697
build/lib/image_mgr/__init__.py
Normal file
697
build/lib/image_mgr/__init__.py
Normal file
@ -0,0 +1,697 @@
|
|||||||
|
"""
|
||||||
|
image_mgr — 容器镜像管理
|
||||||
|
|
||||||
|
- 国内镜像源配置 (mirror_source): 阿里云、DaoCloud 等安全可触达节点
|
||||||
|
- 本地镜像仓库 (image_registry): Docker Registry v2 on K8s + NFS CSI
|
||||||
|
- 容器镜像管理 (container_image): 导入/同步/推送/列表/删除
|
||||||
|
- 客户自定义镜像支持
|
||||||
|
"""
|
||||||
|
import datetime, json, asyncio, subprocess
|
||||||
|
from appPublic.uniqueID import getID
|
||||||
|
from sqlor.dbpools import DBPools
|
||||||
|
|
||||||
|
MODULE_NAME = 'pccs'
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
# 预置的国内安全镜像源
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
PRESET_MIRRORS = [
|
||||||
|
{
|
||||||
|
'name': '阿里云容器镜像服务(杭州)',
|
||||||
|
'source_type': 'public_mirror',
|
||||||
|
'registry_url': 'registry.cn-hangzhou.aliyuncs.com',
|
||||||
|
'upstream_url': 'docker.io',
|
||||||
|
'region': 'cn',
|
||||||
|
'priority': 10,
|
||||||
|
'is_default': 1,
|
||||||
|
'note': 'registry.cn-hangzhou.aliyuncs.com/{namespace}/{repo}'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': '阿里云 Google容器镜像',
|
||||||
|
'source_type': 'public_mirror',
|
||||||
|
'registry_url': 'registry.cn-hangzhou.aliyuncs.com/google_containers',
|
||||||
|
'upstream_url': 'k8s.gcr.io',
|
||||||
|
'region': 'cn',
|
||||||
|
'priority': 20,
|
||||||
|
'note': 'K8s 官方镜像国内加速'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'DaoCloud 公共镜像',
|
||||||
|
'source_type': 'public_mirror',
|
||||||
|
'registry_url': 'docker.daocloud.io',
|
||||||
|
'upstream_url': 'docker.io',
|
||||||
|
'region': 'cn',
|
||||||
|
'priority': 15,
|
||||||
|
'note': 'DaoCloud 代理加速'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'DaoCloud GCR 镜像',
|
||||||
|
'source_type': 'public_mirror',
|
||||||
|
'registry_url': 'gcr.daocloud.io',
|
||||||
|
'upstream_url': 'gcr.io',
|
||||||
|
'region': 'cn',
|
||||||
|
'priority': 25,
|
||||||
|
'note': 'gcr.io 国内加速'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'DaoCloud Quay 镜像',
|
||||||
|
'source_type': 'public_mirror',
|
||||||
|
'registry_url': 'quay.daocloud.io',
|
||||||
|
'upstream_url': 'quay.io',
|
||||||
|
'region': 'cn',
|
||||||
|
'priority': 25,
|
||||||
|
'note': 'quay.io 国内加速'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'DaoCloud GHCR 镜像',
|
||||||
|
'source_type': 'public_mirror',
|
||||||
|
'registry_url': 'ghcr.daocloud.io',
|
||||||
|
'upstream_url': 'ghcr.io',
|
||||||
|
'region': 'cn',
|
||||||
|
'priority': 25,
|
||||||
|
'note': 'GitHub Container Registry 国内加速'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': '腾讯云容器镜像',
|
||||||
|
'source_type': 'public_mirror',
|
||||||
|
'registry_url': 'mirror.ccs.tencentyun.com',
|
||||||
|
'upstream_url': 'docker.io',
|
||||||
|
'region': 'cn',
|
||||||
|
'priority': 30,
|
||||||
|
'note': '腾讯云公共镜像加速'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Docker Hub (直连)',
|
||||||
|
'source_type': 'hub',
|
||||||
|
'registry_url': 'docker.io',
|
||||||
|
'upstream_url': '',
|
||||||
|
'region': 'us',
|
||||||
|
'priority': 100,
|
||||||
|
'note': 'Docker官方, 国内可能慢/不可达'
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
# SSH 工具
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async def ssh_exec(host, port, user, cmd, timeout=120):
|
||||||
|
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 _find_control_node(env, cluster_id):
|
||||||
|
"""查找集群活跃控制节点"""
|
||||||
|
dbname = env.get_module_dbname(MODULE_NAME)
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
recs = await sor.R('cluster_node',
|
||||||
|
{'cluster_id': cluster_id, 'role': 'control', 'status': 'active'})
|
||||||
|
if not recs:
|
||||||
|
return None
|
||||||
|
nodes = await sor.R('compute_node', {'id': recs[0].node_id})
|
||||||
|
return nodes[0] if nodes else None
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
# 镜像源管理
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async def mirror_seed(request, params_kw):
|
||||||
|
"""
|
||||||
|
种子预置镜像源到数据库。
|
||||||
|
调用后自动将 PRESET_MIRRORS 写入 mirror_source 表 (跳过已存在的).
|
||||||
|
"""
|
||||||
|
env = request._run_ns
|
||||||
|
dbname = env.get_module_dbname(MODULE_NAME)
|
||||||
|
now = datetime.datetime.now().isoformat()
|
||||||
|
added = 0
|
||||||
|
skipped = 0
|
||||||
|
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
for m in PRESET_MIRRORS:
|
||||||
|
# 检查是否已存在
|
||||||
|
existing = await sor.R('mirror_source',
|
||||||
|
{'registry_url': m['registry_url']})
|
||||||
|
if existing:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
await sor.C('mirror_source', {
|
||||||
|
'id': getID(),
|
||||||
|
'resellerid': '*',
|
||||||
|
'name': m['name'],
|
||||||
|
'source_type': m['source_type'],
|
||||||
|
'registry_url': m['registry_url'],
|
||||||
|
'upstream_url': m.get('upstream_url', ''),
|
||||||
|
'region': m.get('region', 'cn'),
|
||||||
|
'priority': m.get('priority', 100),
|
||||||
|
'is_default': m.get('is_default', 0),
|
||||||
|
'status': 'active',
|
||||||
|
'created_at': now,
|
||||||
|
'updated_at': now,
|
||||||
|
})
|
||||||
|
added += 1
|
||||||
|
|
||||||
|
return {'status': 'ok', 'message': 'Seeded ' + str(added) + ' mirrors, skipped ' + str(skipped)}
|
||||||
|
|
||||||
|
|
||||||
|
async def mirror_health_check(request, params_kw):
|
||||||
|
"""
|
||||||
|
检查镜像源是否可达。
|
||||||
|
尝试对该源执行 docker pull 一个轻量镜像 (如 busybox).
|
||||||
|
params: mirror_id (可选, 不传则检查所有 active 源)
|
||||||
|
"""
|
||||||
|
env = request._run_ns
|
||||||
|
dbname = env.get_module_dbname(MODULE_NAME)
|
||||||
|
mirror_id = params_kw.get('mirror_id', '')
|
||||||
|
cluster_id = params_kw.get('cluster_id', '')
|
||||||
|
|
||||||
|
if not cluster_id:
|
||||||
|
return {'status': 'error', 'message': 'Missing cluster_id (needed for SSH)'}
|
||||||
|
|
||||||
|
cnode = await _find_control_node(env, cluster_id)
|
||||||
|
if not cnode:
|
||||||
|
return {'status': 'error', 'message': 'No active control node found'}
|
||||||
|
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
if mirror_id:
|
||||||
|
mirrors = await sor.R('mirror_source', {'id': mirror_id})
|
||||||
|
else:
|
||||||
|
mirrors = await sor.R('mirror_source', {'status': 'active'})
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for m in mirrors:
|
||||||
|
test_image = m.registry_url + '/library/busybox:latest'
|
||||||
|
if m.source_type == 'public_mirror' and m.upstream_url:
|
||||||
|
# 对于 mirror 类型的源, pull 测试: <mirror_url>/library/busybox:latest
|
||||||
|
test_image = m.registry_url + '/library/busybox:latest'
|
||||||
|
else:
|
||||||
|
test_image = m.registry_url + '/busybox:latest'
|
||||||
|
|
||||||
|
rc, out, err = await ssh_exec(
|
||||||
|
cnode.ip_address, cnode.ssh_port or 22, cnode.ssh_user or 'root',
|
||||||
|
'docker pull ' + test_image + ' 2>&1 && docker rmi ' + test_image + ' 2>/dev/null',
|
||||||
|
timeout=60)
|
||||||
|
reachable = rc == 0
|
||||||
|
results.append({
|
||||||
|
'id': m.id, 'name': m.name, 'registry_url': m.registry_url,
|
||||||
|
'reachable': reachable, 'error': err[:200] if not reachable else ''
|
||||||
|
})
|
||||||
|
|
||||||
|
# 更新最后检查时间
|
||||||
|
await sor.U('mirror_source', {'id': m.id},
|
||||||
|
{'health_check_at': datetime.datetime.now().isoformat(),
|
||||||
|
'status': 'active' if reachable else 'error'})
|
||||||
|
|
||||||
|
return {'status': 'ok', 'results': results}
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
# 镜像仓库部署
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async def registry_deploy(request, params_kw):
|
||||||
|
"""
|
||||||
|
在 K8s 集群上部署本地 Docker Registry v2.
|
||||||
|
params: registry_id (image_registry 记录ID)
|
||||||
|
流程:
|
||||||
|
1. 读取 image_registry 记录
|
||||||
|
2. 读取集群控制节点
|
||||||
|
3. SSH → kubectl apply deployment + service + pvc
|
||||||
|
4. 生成 htpasswd 认证
|
||||||
|
5. 等待就绪 → 更新 endpoint
|
||||||
|
"""
|
||||||
|
env = request._run_ns
|
||||||
|
dbname = env.get_module_dbname(MODULE_NAME)
|
||||||
|
registry_id = params_kw.get('registry_id', '')
|
||||||
|
|
||||||
|
if not registry_id:
|
||||||
|
return {'status': 'error', 'message': 'Missing registry_id'}
|
||||||
|
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
regs = await sor.R('image_registry', {'id': registry_id})
|
||||||
|
if not regs:
|
||||||
|
return {'status': 'error', 'message': 'Registry not found'}
|
||||||
|
reg = regs[0]
|
||||||
|
|
||||||
|
cluster_id = reg.cluster_id
|
||||||
|
if not cluster_id:
|
||||||
|
return {'status': 'error', 'message': 'Registry not bound to a cluster'}
|
||||||
|
|
||||||
|
cnode = await _find_control_node(env, cluster_id)
|
||||||
|
if not cnode:
|
||||||
|
return {'status': 'error', 'message': 'No active control node in cluster ' + cluster_id}
|
||||||
|
|
||||||
|
# 生成密码 (如果未设置)
|
||||||
|
admin_user = reg.admin_user or 'admin'
|
||||||
|
admin_pass = reg.admin_password or 'pcc123456'
|
||||||
|
storage_size = reg.storage_size or '50Gi'
|
||||||
|
storage_class = reg.storage_class or 'pcc-nfs-sc'
|
||||||
|
namespace = 'pcc-registry'
|
||||||
|
|
||||||
|
steps = []
|
||||||
|
|
||||||
|
# 1. 创建 namespace
|
||||||
|
rc, _, _ = await ssh_exec(cnode.ip_address, cnode.ssh_port or 22, cnode.ssh_user or 'root',
|
||||||
|
'kubectl create namespace ' + namespace + ' 2>/dev/null || true')
|
||||||
|
steps.append('ns_created')
|
||||||
|
|
||||||
|
# 2. 创建 htpasswd secret
|
||||||
|
htpasswd_cmd = (
|
||||||
|
'docker run --rm httpd:2-alpine htpasswd -nbB ' + admin_user + ' ' + admin_pass
|
||||||
|
+ ' | base64 -w0'
|
||||||
|
)
|
||||||
|
rc, htpasswd_b64, _ = await ssh_exec(cnode.ip_address, cnode.ssh_port or 22,
|
||||||
|
cnode.ssh_user or 'root', htpasswd_cmd, timeout=60)
|
||||||
|
secret_yaml = (
|
||||||
|
'apiVersion: v1\n'
|
||||||
|
'kind: Secret\n'
|
||||||
|
'metadata:\n'
|
||||||
|
' name: registry-auth\n'
|
||||||
|
' namespace: ' + namespace + '\n'
|
||||||
|
'data:\n'
|
||||||
|
' htpasswd: ' + htpasswd_b64.strip() + '\n'
|
||||||
|
)
|
||||||
|
await ssh_exec(cnode.ip_address, cnode.ssh_port or 22, cnode.ssh_user or 'root',
|
||||||
|
"echo '" + secret_yaml + "' | kubectl apply -f -")
|
||||||
|
steps.append('secret_created')
|
||||||
|
|
||||||
|
# 3. 创建 PVC
|
||||||
|
pvc_yaml = (
|
||||||
|
'apiVersion: v1\n'
|
||||||
|
'kind: PersistentVolumeClaim\n'
|
||||||
|
'metadata:\n'
|
||||||
|
' name: registry-storage\n'
|
||||||
|
' namespace: ' + namespace + '\n'
|
||||||
|
'spec:\n'
|
||||||
|
' accessModes:\n'
|
||||||
|
' - ReadWriteMany\n'
|
||||||
|
' storageClassName: ' + storage_class + '\n'
|
||||||
|
' resources:\n'
|
||||||
|
' requests:\n'
|
||||||
|
' storage: ' + storage_size + '\n'
|
||||||
|
)
|
||||||
|
await ssh_exec(cnode.ip_address, cnode.ssh_port or 22, cnode.ssh_user or 'root',
|
||||||
|
"echo '" + pvc_yaml + "' | kubectl apply -f -")
|
||||||
|
steps.append('pvc_created')
|
||||||
|
|
||||||
|
# 4. 创建 Deployment
|
||||||
|
deploy_yaml = (
|
||||||
|
'apiVersion: apps/v1\n'
|
||||||
|
'kind: Deployment\n'
|
||||||
|
'metadata:\n'
|
||||||
|
' name: registry\n'
|
||||||
|
' namespace: ' + namespace + '\n'
|
||||||
|
'spec:\n'
|
||||||
|
' replicas: 1\n'
|
||||||
|
' selector:\n'
|
||||||
|
' matchLabels:\n'
|
||||||
|
' app: registry\n'
|
||||||
|
' template:\n'
|
||||||
|
' metadata:\n'
|
||||||
|
' labels:\n'
|
||||||
|
' app: registry\n'
|
||||||
|
' spec:\n'
|
||||||
|
' containers:\n'
|
||||||
|
' - name: registry\n'
|
||||||
|
' image: registry:2\n'
|
||||||
|
' ports:\n'
|
||||||
|
' - containerPort: 5000\n'
|
||||||
|
' env:\n'
|
||||||
|
' - name: REGISTRY_AUTH\n'
|
||||||
|
' value: htpasswd\n'
|
||||||
|
' - name: REGISTRY_AUTH_HTPASSWD_REALM\n'
|
||||||
|
' value: Registry Realm\n'
|
||||||
|
' - name: REGISTRY_AUTH_HTPASSWD_PATH\n'
|
||||||
|
' value: /auth/htpasswd\n'
|
||||||
|
' - name: REGISTRY_STORAGE_DELETE_ENABLED\n'
|
||||||
|
' value: "true"\n'
|
||||||
|
' volumeMounts:\n'
|
||||||
|
' - name: storage\n'
|
||||||
|
' mountPath: /var/lib/registry\n'
|
||||||
|
' - name: auth\n'
|
||||||
|
' mountPath: /auth\n'
|
||||||
|
' volumes:\n'
|
||||||
|
' - name: storage\n'
|
||||||
|
' persistentVolumeClaim:\n'
|
||||||
|
' claimName: registry-storage\n'
|
||||||
|
' - name: auth\n'
|
||||||
|
' secret:\n'
|
||||||
|
' secretName: registry-auth\n'
|
||||||
|
)
|
||||||
|
await ssh_exec(cnode.ip_address, cnode.ssh_port or 22, cnode.ssh_user or 'root',
|
||||||
|
"echo '" + deploy_yaml + "' | kubectl apply -f -")
|
||||||
|
steps.append('deploy_created')
|
||||||
|
|
||||||
|
# 5. 创建 Service (NodePort)
|
||||||
|
svc_yaml = (
|
||||||
|
'apiVersion: v1\n'
|
||||||
|
'kind: Service\n'
|
||||||
|
'metadata:\n'
|
||||||
|
' name: registry\n'
|
||||||
|
' namespace: ' + namespace + '\n'
|
||||||
|
'spec:\n'
|
||||||
|
' type: NodePort\n'
|
||||||
|
' selector:\n'
|
||||||
|
' app: registry\n'
|
||||||
|
' ports:\n'
|
||||||
|
' - port: 5000\n'
|
||||||
|
' targetPort: 5000\n'
|
||||||
|
' nodePort: 30500\n'
|
||||||
|
)
|
||||||
|
await ssh_exec(cnode.ip_address, cnode.ssh_port or 22, cnode.ssh_user or 'root',
|
||||||
|
"echo '" + svc_yaml + "' | kubectl apply -f -")
|
||||||
|
steps.append('svc_created')
|
||||||
|
|
||||||
|
# 6. 等待就绪
|
||||||
|
rc, _, _ = await ssh_exec(cnode.ip_address, cnode.ssh_port or 22, cnode.ssh_user or 'root',
|
||||||
|
'kubectl wait --for=condition=available deployment/registry -n '
|
||||||
|
+ namespace + ' --timeout=120s', timeout=150)
|
||||||
|
|
||||||
|
# 7. 获取 NodePort 地址 → endpoint
|
||||||
|
endpoint = cnode.ip_address + ':30500'
|
||||||
|
internal_endpoint = 'registry.' + namespace + '.svc.cluster.local:5000'
|
||||||
|
|
||||||
|
now = datetime.datetime.now().isoformat()
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
await sor.U('image_registry', {'id': registry_id}, {
|
||||||
|
'endpoint': endpoint,
|
||||||
|
'internal_endpoint': internal_endpoint,
|
||||||
|
'status': 'running',
|
||||||
|
'deploy_config': json.dumps({
|
||||||
|
'namespace': namespace,
|
||||||
|
'node_port': 30500,
|
||||||
|
'steps': steps,
|
||||||
|
}),
|
||||||
|
'updated_at': now,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'status': 'ok',
|
||||||
|
'message': 'Registry deployed at ' + endpoint,
|
||||||
|
'endpoint': endpoint,
|
||||||
|
'internal': internal_endpoint,
|
||||||
|
'admin_user': admin_user,
|
||||||
|
'steps': steps,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
# 镜像操作
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async def image_import(request, params_kw):
|
||||||
|
"""
|
||||||
|
从外部镜像源导入镜像到本地仓库。
|
||||||
|
params: registry_id, source_image, target_name, target_tag
|
||||||
|
流程:
|
||||||
|
1. SSH 到控制节点
|
||||||
|
2. docker pull <source_image> (通过配置的镜像加速/代理)
|
||||||
|
3. docker tag <source_image> <local_registry>/<target_name>:<target_tag>
|
||||||
|
4. docker login <local_registry>
|
||||||
|
5. docker push <local_registry>/<target_name>:<target_tag>
|
||||||
|
6. 写入 container_image 表
|
||||||
|
"""
|
||||||
|
env = request._run_ns
|
||||||
|
dbname = env.get_module_dbname(MODULE_NAME)
|
||||||
|
registry_id = params_kw.get('registry_id', '')
|
||||||
|
source_image = params_kw.get('source_image', '')
|
||||||
|
target_name = params_kw.get('target_name', '')
|
||||||
|
target_tag = params_kw.get('target_tag', 'latest')
|
||||||
|
image_type = params_kw.get('image_type', 'custom')
|
||||||
|
customer_id = params_kw.get('customer_id', '')
|
||||||
|
|
||||||
|
if not registry_id or not source_image:
|
||||||
|
return {'status': 'error', 'message': 'Missing registry_id or source_image'}
|
||||||
|
|
||||||
|
if not target_name:
|
||||||
|
# 从 source_image 提取镜像名
|
||||||
|
target_name = source_image.split('/')[-1].split(':')[0]
|
||||||
|
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
regs = await sor.R('image_registry', {'id': registry_id})
|
||||||
|
if not regs:
|
||||||
|
return {'status': 'error', 'message': 'Registry not found'}
|
||||||
|
reg = regs[0]
|
||||||
|
|
||||||
|
cnode = await _find_control_node(env, reg.cluster_id)
|
||||||
|
if not cnode:
|
||||||
|
return {'status': 'error', 'message': 'No control node found'}
|
||||||
|
|
||||||
|
local_registry = reg.internal_endpoint or reg.endpoint
|
||||||
|
target_full = local_registry + '/' + target_name + ':' + target_tag
|
||||||
|
|
||||||
|
steps = []
|
||||||
|
|
||||||
|
# 1. docker pull (from external source / mirror)
|
||||||
|
rc, out, err = await ssh_exec(cnode.ip_address, cnode.ssh_port or 22,
|
||||||
|
cnode.ssh_user or 'root',
|
||||||
|
'docker pull ' + source_image, timeout=300)
|
||||||
|
if rc != 0:
|
||||||
|
return {'status': 'error', 'message': 'Pull failed: ' + err[:300], 'steps': steps}
|
||||||
|
steps.append('pulled ' + source_image)
|
||||||
|
|
||||||
|
# 2. docker tag
|
||||||
|
rc, _, err = await ssh_exec(cnode.ip_address, cnode.ssh_port or 22,
|
||||||
|
cnode.ssh_user or 'root',
|
||||||
|
'docker tag ' + source_image + ' ' + target_full, timeout=30)
|
||||||
|
if rc != 0:
|
||||||
|
return {'status': 'error', 'message': 'Tag failed: ' + err[:200], 'steps': steps}
|
||||||
|
steps.append('tagged → ' + target_full)
|
||||||
|
|
||||||
|
# 3. docker login + push
|
||||||
|
login_cmd = ('docker login ' + local_registry
|
||||||
|
+ ' -u ' + (reg.admin_user or 'admin')
|
||||||
|
+ ' -p ' + (reg.admin_password or 'pcc123456'))
|
||||||
|
await ssh_exec(cnode.ip_address, cnode.ssh_port or 22, cnode.ssh_user or 'root',
|
||||||
|
login_cmd, timeout=30)
|
||||||
|
|
||||||
|
rc, out, err = await ssh_exec(cnode.ip_address, cnode.ssh_port or 22,
|
||||||
|
cnode.ssh_user or 'root',
|
||||||
|
'docker push ' + target_full, timeout=300)
|
||||||
|
if rc != 0:
|
||||||
|
return {'status': 'error', 'message': 'Push failed: ' + err[:300], 'steps': steps}
|
||||||
|
steps.append('pushed')
|
||||||
|
|
||||||
|
# 4. 获取镜像 digest 和 size
|
||||||
|
rc, inspect_out, _ = await ssh_exec(cnode.ip_address, cnode.ssh_port or 22,
|
||||||
|
cnode.ssh_user or 'root',
|
||||||
|
"docker inspect --format='{{.Id}} {{.Size}}' "
|
||||||
|
+ target_full, timeout=30)
|
||||||
|
digest = ''
|
||||||
|
size_bytes = 0
|
||||||
|
if rc == 0 and inspect_out.strip():
|
||||||
|
parts = inspect_out.strip().split()
|
||||||
|
digest = parts[0] if parts else ''
|
||||||
|
try:
|
||||||
|
size_bytes = int(parts[1]) if len(parts) > 1 else 0
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 5. 清理本地镜像 (可选, 节省控制节点磁盘)
|
||||||
|
await ssh_exec(cnode.ip_address, cnode.ssh_port or 22, cnode.ssh_user or 'root',
|
||||||
|
'docker rmi ' + source_image + ' ' + target_full + ' 2>/dev/null || true',
|
||||||
|
timeout=30)
|
||||||
|
|
||||||
|
# 6. 写入 DB
|
||||||
|
now = datetime.datetime.now().isoformat()
|
||||||
|
image_id = getID()
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
await sor.C('container_image', {
|
||||||
|
'id': image_id,
|
||||||
|
'resellerid': reg.resellerid or '*',
|
||||||
|
'registry_id': registry_id,
|
||||||
|
'image_name': target_name,
|
||||||
|
'image_tag': target_tag,
|
||||||
|
'full_name': target_name + ':' + target_tag,
|
||||||
|
'source_image': source_image,
|
||||||
|
'size_bytes': size_bytes,
|
||||||
|
'digest': digest,
|
||||||
|
'image_type': image_type,
|
||||||
|
'is_customer': 1 if customer_id else 0,
|
||||||
|
'customer_id': customer_id or '',
|
||||||
|
'sync_status': 'synced',
|
||||||
|
'sync_log': json.dumps({'steps': steps, 'digest': digest}),
|
||||||
|
'created_at': now,
|
||||||
|
'updated_at': now,
|
||||||
|
})
|
||||||
|
|
||||||
|
# 更新仓库统计
|
||||||
|
await sor.sqlExe(
|
||||||
|
'UPDATE image_registry SET image_count = image_count + 1, '
|
||||||
|
'total_size_gb = total_size_gb + ${sz}$, updated_at = ${now}$ WHERE id = ${rid}$',
|
||||||
|
{'sz': max(1, size_bytes // (1024 * 1024 * 1024)),
|
||||||
|
'now': now, 'rid': registry_id})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'status': 'ok',
|
||||||
|
'message': 'Imported ' + target_full,
|
||||||
|
'image_id': image_id,
|
||||||
|
'full_name': target_name + ':' + target_tag,
|
||||||
|
'size_bytes': size_bytes,
|
||||||
|
'steps': steps,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def image_sync(request, params_kw):
|
||||||
|
"""
|
||||||
|
同步镜像:从镜像源批量拉取 → 推送到本地仓库。
|
||||||
|
params: registry_id, mirror_id, image_list (逗号分隔的镜像名列表)
|
||||||
|
"""
|
||||||
|
env = request._run_ns
|
||||||
|
dbname = env.get_module_dbname(MODULE_NAME)
|
||||||
|
registry_id = params_kw.get('registry_id', '')
|
||||||
|
mirror_id = params_kw.get('mirror_id', '')
|
||||||
|
image_list = params_kw.get('image_list', '')
|
||||||
|
|
||||||
|
if not registry_id:
|
||||||
|
return {'status': 'error', 'message': 'Missing registry_id'}
|
||||||
|
|
||||||
|
images = [i.strip() for i in image_list.split(',') if i.strip()] if image_list else []
|
||||||
|
if not images:
|
||||||
|
# 默认同步一批常用镜像
|
||||||
|
images = [
|
||||||
|
'busybox:latest',
|
||||||
|
'alpine:latest',
|
||||||
|
'nginx:latest',
|
||||||
|
'redis:7-alpine',
|
||||||
|
'python:3.11-slim',
|
||||||
|
]
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for img in images:
|
||||||
|
# 构建 source image 路径
|
||||||
|
source = img
|
||||||
|
if mirror_id:
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
mirrors = await sor.R('mirror_source', {'id': mirror_id})
|
||||||
|
if mirrors:
|
||||||
|
m = mirrors[0]
|
||||||
|
if m.upstream_url and not img.startswith(m.registry_url):
|
||||||
|
source = m.registry_url + '/' + img
|
||||||
|
|
||||||
|
name = img.split(':')[0].replace('/', '_')
|
||||||
|
tag = img.split(':')[1] if ':' in img else 'latest'
|
||||||
|
|
||||||
|
result = await image_import(request, {
|
||||||
|
'registry_id': registry_id,
|
||||||
|
'source_image': source,
|
||||||
|
'target_name': name,
|
||||||
|
'target_tag': tag,
|
||||||
|
'image_type': 'base',
|
||||||
|
})
|
||||||
|
results.append({'image': img, 'result': result.get('status', 'unknown')})
|
||||||
|
|
||||||
|
return {'status': 'ok', 'synced': len(results), 'results': results}
|
||||||
|
|
||||||
|
|
||||||
|
async def image_push(request, params_kw):
|
||||||
|
"""
|
||||||
|
客户推送自定义镜像到本地仓库。
|
||||||
|
params: registry_id, image_name, image_tag, dockerfile_path (可选)
|
||||||
|
流程:
|
||||||
|
1. SSH 到客户提供的工作节点 (或有 docker 的环境)
|
||||||
|
2. docker build -t <target> . (如果有 Dockerfile)
|
||||||
|
3. docker push <target>
|
||||||
|
4. 记录到 container_image 表 (is_customer=1)
|
||||||
|
"""
|
||||||
|
env = request._run_ns
|
||||||
|
dbname = env.get_module_dbname(MODULE_NAME)
|
||||||
|
registry_id = params_kw.get('registry_id', '')
|
||||||
|
image_name = params_kw.get('image_name', '')
|
||||||
|
image_tag = params_kw.get('image_tag', 'latest')
|
||||||
|
customer_id = params_kw.get('customer_id', '')
|
||||||
|
node_id = params_kw.get('node_id', '') # 客户构建节点
|
||||||
|
|
||||||
|
if not registry_id or not image_name:
|
||||||
|
return {'status': 'error', 'message': 'Missing registry_id or image_name'}
|
||||||
|
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
regs = await sor.R('image_registry', {'id': registry_id})
|
||||||
|
if not regs:
|
||||||
|
return {'status': 'error', 'message': 'Registry not found'}
|
||||||
|
reg = regs[0]
|
||||||
|
|
||||||
|
# 确定构建节点: 优先使用客户指定 node, 否则用控制节点
|
||||||
|
build_node = None
|
||||||
|
if node_id:
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
nodes = await sor.R('compute_node', {'id': node_id})
|
||||||
|
if nodes:
|
||||||
|
build_node = nodes[0]
|
||||||
|
if not build_node:
|
||||||
|
build_node = await _find_control_node(env, reg.cluster_id)
|
||||||
|
if not build_node:
|
||||||
|
return {'status': 'error', 'message': 'No build node available'}
|
||||||
|
|
||||||
|
local_registry = reg.internal_endpoint or reg.endpoint
|
||||||
|
target_full = local_registry + '/' + image_name + ':' + image_tag
|
||||||
|
|
||||||
|
# docker login
|
||||||
|
login_cmd = ('docker login ' + local_registry
|
||||||
|
+ ' -u ' + (reg.admin_user or 'admin')
|
||||||
|
+ ' -p ' + (reg.admin_password or 'pcc123456'))
|
||||||
|
await ssh_exec(build_node.ip_address, build_node.ssh_port or 22,
|
||||||
|
build_node.ssh_user or 'root', login_cmd, timeout=30)
|
||||||
|
|
||||||
|
# docker push (假设客户已在节点上构建好镜像)
|
||||||
|
rc, out, err = await ssh_exec(build_node.ip_address, build_node.ssh_port or 22,
|
||||||
|
build_node.ssh_user or 'root',
|
||||||
|
'docker push ' + target_full, timeout=300)
|
||||||
|
if rc != 0:
|
||||||
|
return {'status': 'error', 'message': 'Push failed: ' + err[:300]}
|
||||||
|
|
||||||
|
# 记录
|
||||||
|
now = datetime.datetime.now().isoformat()
|
||||||
|
image_id = getID()
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
await sor.C('container_image', {
|
||||||
|
'id': image_id,
|
||||||
|
'resellerid': reg.resellerid or '*',
|
||||||
|
'registry_id': registry_id,
|
||||||
|
'image_name': image_name,
|
||||||
|
'image_tag': image_tag,
|
||||||
|
'full_name': image_name + ':' + image_tag,
|
||||||
|
'source_image': target_full,
|
||||||
|
'image_type': 'custom',
|
||||||
|
'is_customer': 1,
|
||||||
|
'customer_id': customer_id or '',
|
||||||
|
'sync_status': 'synced',
|
||||||
|
'created_at': now,
|
||||||
|
'updated_at': now,
|
||||||
|
})
|
||||||
|
await sor.sqlExe(
|
||||||
|
'UPDATE image_registry SET image_count = image_count + 1, '
|
||||||
|
'updated_at = ${now}$ WHERE id = ${rid}$',
|
||||||
|
{'now': now, 'rid': registry_id})
|
||||||
|
|
||||||
|
return {'status': 'ok', 'message': 'Pushed ' + target_full, 'image_id': image_id}
|
||||||
|
|
||||||
|
async def image_stats(request, params_kw):
|
||||||
|
"""获取镜像统计"""
|
||||||
|
env = request._run_ns
|
||||||
|
dbname = env.get_module_dbname('image_mgr')
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
mirrors = await sor.R('mirror_source', {})
|
||||||
|
registries = await sor.R('image_registry', {})
|
||||||
|
images = await sor.R('container_image', {})
|
||||||
|
return {'status': 'ok', 'data': {
|
||||||
|
'mirror_count': len(mirrors),
|
||||||
|
'active_mirrors': sum(1 for m in mirrors if getattr(m, 'status', '') == 'active'),
|
||||||
|
'registry_count': len(registries),
|
||||||
|
'running_registries': sum(1 for r in registries if getattr(r, 'status', '') == 'running'),
|
||||||
|
'image_count': len(images),
|
||||||
|
'custom_images': sum(1 for i in images if getattr(i, 'is_custom', '') == '1'),
|
||||||
|
}}
|
||||||
|
|
||||||
70
build/lib/scripts/load_path.py
Normal file
70
build/lib/scripts/load_path.py
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""image_mgr RBAC 权限管理"""
|
||||||
|
import subprocess, os, sys, json, glob
|
||||||
|
|
||||||
|
mod_name = 'image_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.")
|
||||||
58
i18n/en/msg.txt
Normal file
58
i18n/en/msg.txt
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
上游源地址(如docker.io): Upstream Source URL (e.g. docker.io)
|
||||||
|
仓库:
|
||||||
|
仓库名称: Registry Name
|
||||||
|
优先级:
|
||||||
|
优先级(越小越高): Priority (lower=higher)
|
||||||
|
创建时间: Created At
|
||||||
|
区域:
|
||||||
|
区域(cn/us/eu): Region (cn/us/eu)
|
||||||
|
同步:
|
||||||
|
同步日志:
|
||||||
|
同步状态(pending/syncing/synced/failed):
|
||||||
|
名称: Name
|
||||||
|
商户机构id: Reseller ID
|
||||||
|
大小: Size
|
||||||
|
存储大小:
|
||||||
|
存储大小(如50Gi):
|
||||||
|
完整名称(name:tag): Full Name (name:tag)
|
||||||
|
客户ID: Customer ID
|
||||||
|
客户镜像:
|
||||||
|
容器镜像: Container Image
|
||||||
|
已用存储GB:
|
||||||
|
所属仓库: Registry
|
||||||
|
所属集群: Cluster
|
||||||
|
描述: Description
|
||||||
|
是否客户自定义镜像:
|
||||||
|
是否默认源:
|
||||||
|
更新时间: Updated At
|
||||||
|
最后健康检查:
|
||||||
|
来源镜像源: Source Mirror
|
||||||
|
标签:
|
||||||
|
源镜像地址:
|
||||||
|
状态: Status
|
||||||
|
状态(active/inactive/error):
|
||||||
|
状态(deploying/running/failed/destroyed):
|
||||||
|
管理员密码(加密):
|
||||||
|
管理员账号:
|
||||||
|
类型:
|
||||||
|
类型(docker-registry/harbor):
|
||||||
|
类型(public_mirror/private_registry/hub):
|
||||||
|
类型(vm/base/app/custom):
|
||||||
|
认证密码(加密):
|
||||||
|
认证用户名:
|
||||||
|
访问地址:
|
||||||
|
访问地址(IP:PORT):
|
||||||
|
部署配置JSON:
|
||||||
|
镜像仓库: Image Registry
|
||||||
|
镜像名:
|
||||||
|
镜像名(namespace/repo):
|
||||||
|
镜像大小(字节):
|
||||||
|
镜像摘要(sha256):
|
||||||
|
镜像数:
|
||||||
|
镜像数量:
|
||||||
|
镜像源:
|
||||||
|
镜像源名称:
|
||||||
|
镜像源管理:
|
||||||
|
集群: Cluster
|
||||||
|
集群内地址:
|
||||||
|
默认:
|
||||||
58
i18n/zh/msg.txt
Normal file
58
i18n/zh/msg.txt
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
上游源地址(如docker.io): 上游源地址(如docker.io)
|
||||||
|
仓库: 仓库
|
||||||
|
仓库名称: 仓库名称
|
||||||
|
优先级: 优先级
|
||||||
|
优先级(越小越高): 优先级(越小越高)
|
||||||
|
创建时间: 创建时间
|
||||||
|
区域: 区域
|
||||||
|
区域(cn/us/eu): 区域(cn/us/eu)
|
||||||
|
同步: 同步
|
||||||
|
同步日志: 同步日志
|
||||||
|
同步状态(pending/syncing/synced/failed): 同步状态(pending/syncing/synced/failed)
|
||||||
|
名称: 名称
|
||||||
|
商户机构id: 商户机构id
|
||||||
|
大小: 大小
|
||||||
|
存储大小: 存储大小
|
||||||
|
存储大小(如50Gi): 存储大小(如50Gi)
|
||||||
|
完整名称(name:tag): 完整名称(name:tag)
|
||||||
|
客户ID: 客户ID
|
||||||
|
客户镜像: 客户镜像
|
||||||
|
容器镜像: 容器镜像
|
||||||
|
已用存储GB: 已用存储GB
|
||||||
|
所属仓库: 所属仓库
|
||||||
|
所属集群: 所属集群
|
||||||
|
描述: 描述
|
||||||
|
是否客户自定义镜像: 是否客户自定义镜像
|
||||||
|
是否默认源: 是否默认源
|
||||||
|
更新时间: 更新时间
|
||||||
|
最后健康检查: 最后健康检查
|
||||||
|
来源镜像源: 来源镜像源
|
||||||
|
标签: 标签
|
||||||
|
源镜像地址: 源镜像地址
|
||||||
|
状态: 状态
|
||||||
|
状态(active/inactive/error): 状态(active/inactive/error)
|
||||||
|
状态(deploying/running/failed/destroyed): 状态(deploying/running/failed/destroyed)
|
||||||
|
管理员密码(加密): 管理员密码(加密)
|
||||||
|
管理员账号: 管理员账号
|
||||||
|
类型: 类型
|
||||||
|
类型(docker-registry/harbor): 类型(docker-registry/harbor)
|
||||||
|
类型(public_mirror/private_registry/hub): 类型(public_mirror/private_registry/hub)
|
||||||
|
类型(vm/base/app/custom): 类型(vm/base/app/custom)
|
||||||
|
认证密码(加密): 认证密码(加密)
|
||||||
|
认证用户名: 认证用户名
|
||||||
|
访问地址: 访问地址
|
||||||
|
访问地址(IP:PORT): 访问地址(IP:PORT)
|
||||||
|
部署配置JSON: 部署配置JSON
|
||||||
|
镜像仓库: 镜像仓库
|
||||||
|
镜像名: 镜像名
|
||||||
|
镜像名(namespace/repo): 镜像名(namespace/repo)
|
||||||
|
镜像大小(字节): 镜像大小(字节)
|
||||||
|
镜像摘要(sha256): 镜像摘要(sha256)
|
||||||
|
镜像数: 镜像数
|
||||||
|
镜像数量: 镜像数量
|
||||||
|
镜像源: 镜像源
|
||||||
|
镜像源名称: 镜像源名称
|
||||||
|
镜像源管理: 镜像源管理
|
||||||
|
集群: 集群
|
||||||
|
集群内地址: 集群内地址
|
||||||
|
默认: 默认
|
||||||
9
image_mgr.egg-info/PKG-INFO
Normal file
9
image_mgr.egg-info/PKG-INFO
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
Metadata-Version: 2.4
|
||||||
|
Name: image_mgr
|
||||||
|
Version: 0.1.0
|
||||||
|
Summary: 容器镜像管理:国内镜像源配置、本地镜像仓库、客户自定义镜像导入导出
|
||||||
|
Requires-Python: >=3.10
|
||||||
|
Requires-Dist: apppublic
|
||||||
|
Requires-Dist: sqlor
|
||||||
|
Requires-Dist: ahserver
|
||||||
|
Requires-Dist: appbase
|
||||||
9
image_mgr.egg-info/SOURCES.txt
Normal file
9
image_mgr.egg-info/SOURCES.txt
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
README.md
|
||||||
|
pyproject.toml
|
||||||
|
image_mgr/__init__.py
|
||||||
|
image_mgr.egg-info/PKG-INFO
|
||||||
|
image_mgr.egg-info/SOURCES.txt
|
||||||
|
image_mgr.egg-info/dependency_links.txt
|
||||||
|
image_mgr.egg-info/requires.txt
|
||||||
|
image_mgr.egg-info/top_level.txt
|
||||||
|
scripts/load_path.py
|
||||||
1
image_mgr.egg-info/dependency_links.txt
Normal file
1
image_mgr.egg-info/dependency_links.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
4
image_mgr.egg-info/requires.txt
Normal file
4
image_mgr.egg-info/requires.txt
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
apppublic
|
||||||
|
sqlor
|
||||||
|
ahserver
|
||||||
|
appbase
|
||||||
7
image_mgr.egg-info/top_level.txt
Normal file
7
image_mgr.egg-info/top_level.txt
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
build
|
||||||
|
i18n
|
||||||
|
image_mgr
|
||||||
|
json
|
||||||
|
models
|
||||||
|
scripts
|
||||||
|
wwwroot
|
||||||
@ -677,3 +677,21 @@ async def image_push(request, params_kw):
|
|||||||
{'now': now, 'rid': registry_id})
|
{'now': now, 'rid': registry_id})
|
||||||
|
|
||||||
return {'status': 'ok', 'message': 'Pushed ' + target_full, 'image_id': image_id}
|
return {'status': 'ok', 'message': 'Pushed ' + target_full, 'image_id': image_id}
|
||||||
|
|
||||||
|
async def image_stats(request, params_kw):
|
||||||
|
"""获取镜像统计"""
|
||||||
|
env = request._run_ns
|
||||||
|
dbname = env.get_module_dbname('image_mgr')
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
mirrors = await sor.R('mirror_source', {})
|
||||||
|
registries = await sor.R('image_registry', {})
|
||||||
|
images = await sor.R('container_image', {})
|
||||||
|
return {'status': 'ok', 'data': {
|
||||||
|
'mirror_count': len(mirrors),
|
||||||
|
'active_mirrors': sum(1 for m in mirrors if getattr(m, 'status', '') == 'active'),
|
||||||
|
'registry_count': len(registries),
|
||||||
|
'running_registries': sum(1 for r in registries if getattr(r, 'status', '') == 'running'),
|
||||||
|
'image_count': len(images),
|
||||||
|
'custom_images': sum(1 for i in images if getattr(i, 'is_custom', '') == '1'),
|
||||||
|
}}
|
||||||
|
|
||||||
|
|||||||
@ -4,20 +4,50 @@
|
|||||||
"title": "容器镜像",
|
"title": "容器镜像",
|
||||||
"params": {
|
"params": {
|
||||||
"browserfields": {
|
"browserfields": {
|
||||||
"image_name": {"title": "镜像名", "width": 200},
|
"image_name": {
|
||||||
"image_tag": {"title": "标签", "width": 120},
|
"title": "镜像名",
|
||||||
"registry_id": {"title": "仓库", "width": 120},
|
"width": 200
|
||||||
"image_type": {"title": "类型", "width": 80},
|
},
|
||||||
"size_bytes": {"title": "大小", "width": 80},
|
"image_tag": {
|
||||||
"sync_status": {"title": "同步", "width": 80},
|
"title": "标签",
|
||||||
"is_customer": {"title": "客户镜像", "width": 80}
|
"width": 120
|
||||||
|
},
|
||||||
|
"registry_id": {
|
||||||
|
"title": "仓库",
|
||||||
|
"width": 120
|
||||||
|
},
|
||||||
|
"image_type": {
|
||||||
|
"title": "类型",
|
||||||
|
"width": 80
|
||||||
|
},
|
||||||
|
"size_bytes": {
|
||||||
|
"title": "大小",
|
||||||
|
"width": 80
|
||||||
|
},
|
||||||
|
"sync_status": {
|
||||||
|
"title": "同步",
|
||||||
|
"width": 80
|
||||||
|
},
|
||||||
|
"is_customer": {
|
||||||
|
"title": "客户镜像",
|
||||||
|
"width": 80
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"editexclouded": ["id", "resellerid", "digest", "source_image", "sync_log", "created_at", "updated_at"],
|
"editexclouded": [
|
||||||
"toolbar": {"tools": []},
|
"id",
|
||||||
|
"resellerid",
|
||||||
|
"digest",
|
||||||
|
"source_image",
|
||||||
|
"sync_log",
|
||||||
|
"created_at",
|
||||||
|
"updated_at"
|
||||||
|
],
|
||||||
"binds": [],
|
"binds": [],
|
||||||
"new_data_url": "{{entire_url('/image_mgr/api/container_image_create.dspy')}}",
|
"logined_userorgid": "resellerid",
|
||||||
"update_data_url": "{{entire_url('/image_mgr/api/container_image_update.dspy')}}",
|
"editable": {
|
||||||
"delete_data_url": "{{entire_url('/image_mgr/api/container_image_delete.dspy')}}",
|
"new_data_url": "{{entire_url('/image_mgr/api/container_image_create.dspy')}}",
|
||||||
"logined_userorgid": "resellerid"
|
"update_data_url": "{{entire_url('/image_mgr/api/container_image_update.dspy')}}",
|
||||||
|
"delete_data_url": "{{entire_url('/image_mgr/api/container_image_delete.dspy')}}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -4,20 +4,50 @@
|
|||||||
"title": "镜像仓库",
|
"title": "镜像仓库",
|
||||||
"params": {
|
"params": {
|
||||||
"browserfields": {
|
"browserfields": {
|
||||||
"name": {"title": "仓库名称", "width": 150},
|
"name": {
|
||||||
"registry_type": {"title": "类型", "width": 100},
|
"title": "仓库名称",
|
||||||
"cluster_id": {"title": "集群", "width": 100},
|
"width": 150
|
||||||
"endpoint": {"title": "访问地址", "width": 200},
|
},
|
||||||
"storage_size": {"title": "存储大小", "width": 80},
|
"registry_type": {
|
||||||
"image_count": {"title": "镜像数", "width": 60},
|
"title": "类型",
|
||||||
"status": {"title": "状态", "width": 80}
|
"width": 100
|
||||||
|
},
|
||||||
|
"cluster_id": {
|
||||||
|
"title": "集群",
|
||||||
|
"width": 100
|
||||||
|
},
|
||||||
|
"endpoint": {
|
||||||
|
"title": "访问地址",
|
||||||
|
"width": 200
|
||||||
|
},
|
||||||
|
"storage_size": {
|
||||||
|
"title": "存储大小",
|
||||||
|
"width": 80
|
||||||
|
},
|
||||||
|
"image_count": {
|
||||||
|
"title": "镜像数",
|
||||||
|
"width": 60
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"title": "状态",
|
||||||
|
"width": 80
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"editexclouded": ["id", "resellerid", "admin_password", "internal_endpoint", "deploy_config", "created_at", "updated_at"],
|
"editexclouded": [
|
||||||
"toolbar": {"tools": []},
|
"id",
|
||||||
|
"resellerid",
|
||||||
|
"admin_password",
|
||||||
|
"internal_endpoint",
|
||||||
|
"deploy_config",
|
||||||
|
"created_at",
|
||||||
|
"updated_at"
|
||||||
|
],
|
||||||
"binds": [],
|
"binds": [],
|
||||||
"new_data_url": "{{entire_url('/image_mgr/api/image_registry_create.dspy')}}",
|
"logined_userorgid": "resellerid",
|
||||||
"update_data_url": "{{entire_url('/image_mgr/api/image_registry_update.dspy')}}",
|
"editable": {
|
||||||
"delete_data_url": "{{entire_url('/image_mgr/api/image_registry_delete.dspy')}}",
|
"new_data_url": "{{entire_url('/image_mgr/api/image_registry_create.dspy')}}",
|
||||||
"logined_userorgid": "resellerid"
|
"update_data_url": "{{entire_url('/image_mgr/api/image_registry_update.dspy')}}",
|
||||||
|
"delete_data_url": "{{entire_url('/image_mgr/api/image_registry_delete.dspy')}}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -4,20 +4,49 @@
|
|||||||
"title": "镜像源管理",
|
"title": "镜像源管理",
|
||||||
"params": {
|
"params": {
|
||||||
"browserfields": {
|
"browserfields": {
|
||||||
"name": {"title": "名称", "width": 150},
|
"name": {
|
||||||
"source_type": {"title": "类型", "width": 120},
|
"title": "名称",
|
||||||
"registry_url": {"title": "Registry URL", "width": 250},
|
"width": 150
|
||||||
"region": {"title": "区域", "width": 60},
|
},
|
||||||
"priority": {"title": "优先级", "width": 60},
|
"source_type": {
|
||||||
"is_default": {"title": "默认", "width": 60},
|
"title": "类型",
|
||||||
"status": {"title": "状态", "width": 80}
|
"width": 120
|
||||||
|
},
|
||||||
|
"registry_url": {
|
||||||
|
"title": "Registry URL",
|
||||||
|
"width": 250
|
||||||
|
},
|
||||||
|
"region": {
|
||||||
|
"title": "区域",
|
||||||
|
"width": 60
|
||||||
|
},
|
||||||
|
"priority": {
|
||||||
|
"title": "优先级",
|
||||||
|
"width": 60
|
||||||
|
},
|
||||||
|
"is_default": {
|
||||||
|
"title": "默认",
|
||||||
|
"width": 60
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"title": "状态",
|
||||||
|
"width": 80
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"editexclouded": ["id", "resellerid", "auth_password", "health_check_at", "created_at", "updated_at"],
|
"editexclouded": [
|
||||||
"toolbar": {"tools": []},
|
"id",
|
||||||
|
"resellerid",
|
||||||
|
"auth_password",
|
||||||
|
"health_check_at",
|
||||||
|
"created_at",
|
||||||
|
"updated_at"
|
||||||
|
],
|
||||||
"binds": [],
|
"binds": [],
|
||||||
"new_data_url": "{{entire_url('/image_mgr/api/mirror_source_create.dspy')}}",
|
"logined_userorgid": "resellerid",
|
||||||
"update_data_url": "{{entire_url('/image_mgr/api/mirror_source_update.dspy')}}",
|
"editable": {
|
||||||
"delete_data_url": "{{entire_url('/image_mgr/api/mirror_source_delete.dspy')}}",
|
"new_data_url": "{{entire_url('/image_mgr/api/mirror_source_create.dspy')}}",
|
||||||
"logined_userorgid": "resellerid"
|
"update_data_url": "{{entire_url('/image_mgr/api/mirror_source_update.dspy')}}",
|
||||||
|
"delete_data_url": "{{entire_url('/image_mgr/api/mirror_source_delete.dspy')}}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,34 +1,156 @@
|
|||||||
{
|
{
|
||||||
"summary": [{
|
"summary": [
|
||||||
"name": "container_image",
|
{
|
||||||
"title": "容器镜像",
|
"name": "container_image",
|
||||||
"primary": ["id"],
|
"title": "容器镜像",
|
||||||
"catelog": "entity"
|
"primary": [
|
||||||
}],
|
"id"
|
||||||
|
],
|
||||||
|
"catelog": "entity"
|
||||||
|
}
|
||||||
|
],
|
||||||
"fields": [
|
"fields": [
|
||||||
{"name": "id", "title": "id", "type": "str", "length": 32, "nullable": "no"},
|
{
|
||||||
{"name": "resellerid", "title": "商户机构id", "type": "str", "length": 32, "nullable": "no"},
|
"name": "id",
|
||||||
{"name": "registry_id", "title": "所属仓库", "type": "str", "length": 32, "nullable": "no"},
|
"title": "id",
|
||||||
{"name": "mirror_id", "title": "来源镜像源", "type": "str", "length": 32},
|
"type": "str",
|
||||||
{"name": "image_name", "title": "镜像名(namespace/repo)", "type": "str", "length": 256, "nullable": "no"},
|
"length": 32,
|
||||||
{"name": "image_tag", "title": "标签", "type": "str", "length": 128, "nullable": "no"},
|
"nullable": "no"
|
||||||
{"name": "full_name", "title": "完整名称(name:tag)", "type": "str", "length": 512},
|
},
|
||||||
{"name": "source_image", "title": "源镜像地址", "type": "str", "length": 512},
|
{
|
||||||
{"name": "size_bytes", "title": "镜像大小(字节)", "type": "bigint", "default": 0},
|
"name": "resellerid",
|
||||||
{"name": "digest", "title": "镜像摘要(sha256)", "type": "str", "length": 256},
|
"title": "商户机构id",
|
||||||
{"name": "image_type", "title": "类型(vm/base/app/custom)", "type": "char", "length": 16, "default": "custom"},
|
"type": "str",
|
||||||
{"name": "description", "title": "描述", "type": "str", "length": 512},
|
"length": 32,
|
||||||
{"name": "is_customer", "title": "是否客户自定义镜像", "type": "int", "default": 0},
|
"nullable": "no"
|
||||||
{"name": "customer_id", "title": "客户ID", "type": "str", "length": 32},
|
},
|
||||||
{"name": "sync_status", "title": "同步状态(pending/syncing/synced/failed)", "type": "char", "length": 16, "default": "pending"},
|
{
|
||||||
{"name": "sync_log", "title": "同步日志", "type": "text"},
|
"name": "registry_id",
|
||||||
{"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"},
|
"title": "所属仓库",
|
||||||
{"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "no"}
|
"type": "str",
|
||||||
|
"length": 32,
|
||||||
|
"nullable": "no"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mirror_id",
|
||||||
|
"title": "来源镜像源",
|
||||||
|
"type": "str",
|
||||||
|
"length": 32
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "image_name",
|
||||||
|
"title": "镜像名(namespace/repo)",
|
||||||
|
"type": "str",
|
||||||
|
"length": 256,
|
||||||
|
"nullable": "no"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "image_tag",
|
||||||
|
"title": "标签",
|
||||||
|
"type": "str",
|
||||||
|
"length": 128,
|
||||||
|
"nullable": "no"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "full_name",
|
||||||
|
"title": "完整名称(name:tag)",
|
||||||
|
"type": "str",
|
||||||
|
"length": 512
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "source_image",
|
||||||
|
"title": "源镜像地址",
|
||||||
|
"type": "str",
|
||||||
|
"length": 512
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "size_bytes",
|
||||||
|
"title": "镜像大小(字节)",
|
||||||
|
"type": "long",
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "digest",
|
||||||
|
"title": "镜像摘要(sha256)",
|
||||||
|
"type": "str",
|
||||||
|
"length": 256
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "image_type",
|
||||||
|
"title": "类型(vm/base/app/custom)",
|
||||||
|
"type": "char",
|
||||||
|
"length": 16,
|
||||||
|
"default": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "description",
|
||||||
|
"title": "描述",
|
||||||
|
"type": "str",
|
||||||
|
"length": 512
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "is_customer",
|
||||||
|
"title": "是否客户自定义镜像",
|
||||||
|
"type": "int",
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "customer_id",
|
||||||
|
"title": "客户ID",
|
||||||
|
"type": "str",
|
||||||
|
"length": 32
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sync_status",
|
||||||
|
"title": "同步状态(pending/syncing/synced/failed)",
|
||||||
|
"type": "char",
|
||||||
|
"length": 16,
|
||||||
|
"default": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sync_log",
|
||||||
|
"title": "同步日志",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "created_at",
|
||||||
|
"title": "创建时间",
|
||||||
|
"type": "timestamp",
|
||||||
|
"nullable": "no"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "updated_at",
|
||||||
|
"title": "更新时间",
|
||||||
|
"type": "timestamp",
|
||||||
|
"nullable": "no"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"codes": [
|
"codes": [
|
||||||
{"field": "registry_id", "table": "image_registry", "valuefield": "id", "textfield": "name"},
|
{
|
||||||
{"field": "mirror_id", "table": "mirror_source", "valuefield": "id", "textfield": "name"},
|
"field": "registry_id",
|
||||||
{"field": "image_type", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='image_type'"},
|
"table": "image_registry",
|
||||||
{"field": "sync_status", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='sync_status'"}
|
"valuefield": "id",
|
||||||
|
"textfield": "name"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "mirror_id",
|
||||||
|
"table": "mirror_source",
|
||||||
|
"valuefield": "id",
|
||||||
|
"textfield": "name"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "image_type",
|
||||||
|
"table": "appcodes_kv",
|
||||||
|
"valuefield": "k",
|
||||||
|
"textfield": "v",
|
||||||
|
"cond": "parentid='image_type'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "sync_status",
|
||||||
|
"table": "appcodes_kv",
|
||||||
|
"valuefield": "k",
|
||||||
|
"textfield": "v",
|
||||||
|
"cond": "parentid='sync_status'"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
16
pyproject.toml
Normal file
16
pyproject.toml
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61.0"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "image_mgr"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "容器镜像管理:国内镜像源配置、本地镜像仓库、客户自定义镜像导入导出"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = ["apppublic", "sqlor", "ahserver", "appbase"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-dir]
|
||||||
|
"image_mgr" = "image_mgr"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["."]
|
||||||
@ -1,3 +1,3 @@
|
|||||||
# 镜像导入:从外部镜像源拉取镜像 → 推送到本地仓库
|
ns = params_kw.copy()
|
||||||
result = await image_import(request, params_kw)
|
async with DBPools().sqlorContext(get_module_dbname('image_mgr')) as sor:
|
||||||
return result if isinstance(result, dict) else {'widgettype': 'Error', 'options': {'title': '失败', 'message': str(result)}}
|
return {'widgettype':'Message','options':{'cwidth':16,'cheight':9,'title':'Info','timeout':3,'message':'Operation not yet implemented'}}
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
# 推送客户自定义镜像到本地仓库
|
ns = params_kw.copy()
|
||||||
result = await image_push(request, params_kw)
|
async with DBPools().sqlorContext(get_module_dbname('image_mgr')) as sor:
|
||||||
return result if isinstance(result, dict) else {'widgettype': 'Error', 'options': {'title': '失败', 'message': str(result)}}
|
return {'widgettype':'Message','options':{'cwidth':16,'cheight':9,'title':'Info','timeout':3,'message':'Operation not yet implemented'}}
|
||||||
|
|||||||
10
wwwroot/api/image_stats.dspy
Normal file
10
wwwroot/api/image_stats.dspy
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
env = request._run_ns
|
||||||
|
dbname = get_module_dbname('image_mgr')
|
||||||
|
try:
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
mirrors = await sor.R('mirror_source', {})
|
||||||
|
registries = await sor.R('image_registry', {})
|
||||||
|
images = await sor.R('container_image', {})
|
||||||
|
return {'widgettype': 'Text', 'options': {'otext': '镜像: ' + str(len(mirrors or [])) + ' 镜像源\n仓库: ' + str(len(registries or [])) + ' | 镜像: ' + str(len(images or [])) + '\n活跃源: ' + str(sum(1 for m in (mirrors or []) if getattr(m,'status','')=='active')), 'cfontsize': 0.9, 'color': '#1e293b'}}
|
||||||
|
except:
|
||||||
|
return {'widgettype': 'Text', 'options': {'otext': '镜像: 加载失败', 'color': '#dc2626'}}
|
||||||
@ -1,3 +1,3 @@
|
|||||||
# 镜像同步:从镜像源拉取最新 tag 列表 → 同步到本地仓库
|
ns = params_kw.copy()
|
||||||
result = await image_sync(request, params_kw)
|
async with DBPools().sqlorContext(get_module_dbname('image_mgr')) as sor:
|
||||||
return result if isinstance(result, dict) else {'widgettype': 'Error', 'options': {'title': '失败', 'message': str(result)}}
|
return {'widgettype':'Message','options':{'cwidth':16,'cheight':9,'title':'Info','timeout':3,'message':'Operation not yet implemented'}}
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
# 镜像源健康检查
|
ns = params_kw.copy()
|
||||||
result = await mirror_health_check(request, params_kw)
|
async with DBPools().sqlorContext(get_module_dbname('image_mgr')) as sor:
|
||||||
return result if isinstance(result, dict) else {'widgettype': 'Error', 'options': {'title': '失败', 'message': str(result)}}
|
return {'widgettype':'Message','options':{'cwidth':16,'cheight':9,'title':'Info','timeout':3,'message':'Operation not yet implemented'}}
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
# 部署镜像仓库:在指定集群上部署本地 Docker Registry
|
ns = params_kw.copy()
|
||||||
result = await registry_deploy(request, params_kw)
|
async with DBPools().sqlorContext(get_module_dbname('image_mgr')) as sor:
|
||||||
return result if isinstance(result, dict) else {'widgettype': 'Error', 'options': {'title': '失败', 'message': str(result)}}
|
return {'widgettype':'Message','options':{'cwidth':16,'cheight':9,'title':'Info','timeout':3,'message':'Operation not yet implemented'}}
|
||||||
|
|||||||
57
wwwroot/container_image_list/add_container_image.dspy
Normal file
57
wwwroot/container_image_list/add_container_image.dspy
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
|
||||||
|
ns = params_kw.copy()
|
||||||
|
for k,v in ns.items():
|
||||||
|
if v == 'NaN' or v == 'null':
|
||||||
|
ns[k] = None
|
||||||
|
id = params_kw.id
|
||||||
|
if not id or len(id) > 32:
|
||||||
|
id = uuid()
|
||||||
|
ns['id'] = id
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
userorgid = await get_userorgid()
|
||||||
|
if not userorgid:
|
||||||
|
return {
|
||||||
|
"widgettype":"Error",
|
||||||
|
"options":{
|
||||||
|
"title":"Authorization Error",
|
||||||
|
"timeout":3,
|
||||||
|
"cwidth":16,
|
||||||
|
"cheight":9,
|
||||||
|
"message":"Please login"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ns['resellerid'] = userorgid
|
||||||
|
|
||||||
|
for k in list(ns.keys()):
|
||||||
|
if k.endswith('_at') or k.endswith('_time') or k == 'last_heartbeat':
|
||||||
|
v = ns.get(k, '')
|
||||||
|
if v == '' or v is None or v == 'None':
|
||||||
|
ns[k] = None
|
||||||
|
db = DBPools()
|
||||||
|
dbname = get_module_dbname('image_mgr')
|
||||||
|
async with db.sqlorContext(dbname) as sor:
|
||||||
|
r = await sor.C('container_image', ns.copy())
|
||||||
|
return {
|
||||||
|
"widgettype":"Message",
|
||||||
|
"options":{
|
||||||
|
"user_data":ns,
|
||||||
|
"cwidth":16,
|
||||||
|
"cheight":9,
|
||||||
|
"title":"Add Success",
|
||||||
|
"timeout":3,
|
||||||
|
"message":"ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"widgettype":"Error",
|
||||||
|
"options":{
|
||||||
|
"title":"Add Error",
|
||||||
|
"cwidth":16,
|
||||||
|
"cheight":9,
|
||||||
|
"timeout":3,
|
||||||
|
"message":"failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
47
wwwroot/container_image_list/delete_container_image.dspy
Normal file
47
wwwroot/container_image_list/delete_container_image.dspy
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
|
||||||
|
ns = {
|
||||||
|
'id':params_kw['id'],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
userorgid = await get_userorgid()
|
||||||
|
if not userorgid:
|
||||||
|
return {
|
||||||
|
"widgettype":"Error",
|
||||||
|
"options":{
|
||||||
|
"title":"Authorization Error",
|
||||||
|
"timeout":3,
|
||||||
|
"cwidth":16,
|
||||||
|
"cheight":9,
|
||||||
|
"message":"Please login"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ns['resellerid'] = userorgid
|
||||||
|
|
||||||
|
db = DBPools()
|
||||||
|
dbname = get_module_dbname('image_mgr')
|
||||||
|
async with db.sqlorContext(dbname) as sor:
|
||||||
|
r = await sor.D('container_image', ns)
|
||||||
|
debug('delete success');
|
||||||
|
return {
|
||||||
|
"widgettype":"Message",
|
||||||
|
"options":{
|
||||||
|
"title":"Delete Success",
|
||||||
|
"timeout":3,
|
||||||
|
"cwidth":16,
|
||||||
|
"cheight":9,
|
||||||
|
"message":"ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debug('Delete failed');
|
||||||
|
return {
|
||||||
|
"widgettype":"Error",
|
||||||
|
"options":{
|
||||||
|
"title":"Delete Error",
|
||||||
|
"timeout":3,
|
||||||
|
"cwidth":16,
|
||||||
|
"cheight":9,
|
||||||
|
"message":"failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
180
wwwroot/container_image_list/get_container_image.dspy
Normal file
180
wwwroot/container_image_list/get_container_image.dspy
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
|
||||||
|
ns = params_kw.copy()
|
||||||
|
|
||||||
|
|
||||||
|
userorgid = await get_userorgid()
|
||||||
|
if not userorgid:
|
||||||
|
return {
|
||||||
|
"widgettype":"Error",
|
||||||
|
"options":{
|
||||||
|
"title":"Authorization Error",
|
||||||
|
"timeout":3,
|
||||||
|
"cwidth":16,
|
||||||
|
"cheight":9,
|
||||||
|
"message":"Please login"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ns['resellerid'] = userorgid
|
||||||
|
ns['userorgid'] = userorgid
|
||||||
|
|
||||||
|
debug(f'get_container_image.dspy:{ns=}')
|
||||||
|
if not ns.get('page'):
|
||||||
|
ns['page'] = 1
|
||||||
|
if not ns.get('sort'):
|
||||||
|
|
||||||
|
ns['sort'] = 'id'
|
||||||
|
|
||||||
|
|
||||||
|
sql = '''select a.*, b.registry_id_text, c.mirror_id_text, d.image_type_text, e.sync_status_text
|
||||||
|
from (select * from container_image where 1=1 [[filterstr]]) a left join (select id as registry_id,
|
||||||
|
name as registry_id_text from image_registry where 1 = 1) b on a.registry_id = b.registry_id left join (select id as mirror_id,
|
||||||
|
name as mirror_id_text from mirror_source where 1 = 1) c on a.mirror_id = c.mirror_id left join (select k as image_type,
|
||||||
|
v as image_type_text from appcodes_kv where parentid='image_type') d on a.image_type = d.image_type left join (select k as sync_status,
|
||||||
|
v as sync_status_text from appcodes_kv where parentid='sync_status') e on a.sync_status = e.sync_status'''
|
||||||
|
|
||||||
|
filterjson = params_kw.get('data_filter')
|
||||||
|
if not filterjson:
|
||||||
|
fields = [ f['name'] for f in [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"title": "id",
|
||||||
|
"type": "str",
|
||||||
|
"length": 32,
|
||||||
|
"nullable": "no"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "resellerid",
|
||||||
|
"title": "商户机构id",
|
||||||
|
"type": "str",
|
||||||
|
"length": 32,
|
||||||
|
"nullable": "no"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "registry_id",
|
||||||
|
"title": "所属仓库",
|
||||||
|
"type": "str",
|
||||||
|
"length": 32,
|
||||||
|
"nullable": "no"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mirror_id",
|
||||||
|
"title": "来源镜像源",
|
||||||
|
"type": "str",
|
||||||
|
"length": 32
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "image_name",
|
||||||
|
"title": "镜像名(namespace/repo)",
|
||||||
|
"type": "str",
|
||||||
|
"length": 256,
|
||||||
|
"nullable": "no"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "image_tag",
|
||||||
|
"title": "标签",
|
||||||
|
"type": "str",
|
||||||
|
"length": 128,
|
||||||
|
"nullable": "no"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "full_name",
|
||||||
|
"title": "完整名称(name:tag)",
|
||||||
|
"type": "str",
|
||||||
|
"length": 512
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "source_image",
|
||||||
|
"title": "源镜像地址",
|
||||||
|
"type": "str",
|
||||||
|
"length": 512
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "size_bytes",
|
||||||
|
"title": "镜像大小(字节)",
|
||||||
|
"type": "long",
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "digest",
|
||||||
|
"title": "镜像摘要(sha256)",
|
||||||
|
"type": "str",
|
||||||
|
"length": 256
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "image_type",
|
||||||
|
"title": "类型(vm/base/app/custom)",
|
||||||
|
"type": "char",
|
||||||
|
"length": 16,
|
||||||
|
"default": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "description",
|
||||||
|
"title": "描述",
|
||||||
|
"type": "str",
|
||||||
|
"length": 512
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "is_customer",
|
||||||
|
"title": "是否客户自定义镜像",
|
||||||
|
"type": "int",
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "customer_id",
|
||||||
|
"title": "客户ID",
|
||||||
|
"type": "str",
|
||||||
|
"length": 32
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sync_status",
|
||||||
|
"title": "同步状态(pending/syncing/synced/failed)",
|
||||||
|
"type": "char",
|
||||||
|
"length": 16,
|
||||||
|
"default": "pending"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sync_log",
|
||||||
|
"title": "同步日志",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "created_at",
|
||||||
|
"title": "创建时间",
|
||||||
|
"type": "timestamp",
|
||||||
|
"nullable": "no"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "updated_at",
|
||||||
|
"title": "更新时间",
|
||||||
|
"type": "timestamp",
|
||||||
|
"nullable": "no"
|
||||||
|
}
|
||||||
|
] ]
|
||||||
|
filterjson = default_filterjson(fields, ns)
|
||||||
|
filterdic = ns.copy()
|
||||||
|
filterdic['filterstr'] = ''
|
||||||
|
filterdic['userorgid'] = '${userorgid}$'
|
||||||
|
filterdic['userid'] = '${userid}$'
|
||||||
|
if filterjson:
|
||||||
|
dbf = DBFilter(filterjson)
|
||||||
|
conds = dbf.gen(ns)
|
||||||
|
if conds:
|
||||||
|
ns.update(dbf.consts)
|
||||||
|
conds = f' and {conds}'
|
||||||
|
filterdic['filterstr'] = conds
|
||||||
|
ac = ArgsConvert('[[', ']]')
|
||||||
|
vars = ac.findAllVariables(sql)
|
||||||
|
NameSpace = {v:'${' + v + '}$' for v in vars if v != 'filterstr' }
|
||||||
|
filterdic.update(NameSpace)
|
||||||
|
sql = ac.convert(sql, filterdic)
|
||||||
|
|
||||||
|
debug(f'{sql=}')
|
||||||
|
db = DBPools()
|
||||||
|
dbname = get_module_dbname('image_mgr')
|
||||||
|
async with db.sqlorContext(dbname) as sor:
|
||||||
|
r = await sor.sqlPaging(sql, ns)
|
||||||
|
return r
|
||||||
|
return {
|
||||||
|
"total":0,
|
||||||
|
"rows":[]
|
||||||
|
}
|
||||||
317
wwwroot/container_image_list/index.ui
Normal file
317
wwwroot/container_image_list/index.ui
Normal file
@ -0,0 +1,317 @@
|
|||||||
|
|
||||||
|
{
|
||||||
|
"id":"container_image_tbl",
|
||||||
|
"widgettype":"Tabular",
|
||||||
|
"options":{
|
||||||
|
"width":"100%",
|
||||||
|
"height":"100%",
|
||||||
|
|
||||||
|
|
||||||
|
"title":"容器镜像",
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"css":"card",
|
||||||
|
|
||||||
|
|
||||||
|
"editable":{
|
||||||
|
|
||||||
|
"new_data_url":"{{entire_url('add_container_image.dspy')}}",
|
||||||
|
|
||||||
|
|
||||||
|
"delete_data_url":"{{entire_url('delete_container_image.dspy')}}",
|
||||||
|
|
||||||
|
|
||||||
|
"update_data_url":"{{entire_url('update_container_image.dspy')}}"
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
"data_url":"{{entire_url('./get_container_image.dspy')}}",
|
||||||
|
|
||||||
|
"data_method":"GET",
|
||||||
|
"data_params":{{json.dumps(params_kw, indent=4, ensure_ascii=False)}},
|
||||||
|
"row_options":{
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"browserfields": {
|
||||||
|
"image_name": {
|
||||||
|
"title": "镜像名",
|
||||||
|
"width": 200
|
||||||
|
},
|
||||||
|
"image_tag": {
|
||||||
|
"title": "标签",
|
||||||
|
"width": 120
|
||||||
|
},
|
||||||
|
"registry_id": {
|
||||||
|
"title": "仓库",
|
||||||
|
"width": 120
|
||||||
|
},
|
||||||
|
"image_type": {
|
||||||
|
"title": "类型",
|
||||||
|
"width": 80
|
||||||
|
},
|
||||||
|
"size_bytes": {
|
||||||
|
"title": "大小",
|
||||||
|
"width": 80
|
||||||
|
},
|
||||||
|
"sync_status": {
|
||||||
|
"title": "同步",
|
||||||
|
"width": 80
|
||||||
|
},
|
||||||
|
"is_customer": {
|
||||||
|
"title": "客户镜像",
|
||||||
|
"width": 80
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
"editexclouded":[
|
||||||
|
"id",
|
||||||
|
"resellerid",
|
||||||
|
"digest",
|
||||||
|
"source_image",
|
||||||
|
"sync_log",
|
||||||
|
"created_at",
|
||||||
|
"updated_at"
|
||||||
|
],
|
||||||
|
|
||||||
|
"fields":[
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"title": "id",
|
||||||
|
"type": "str",
|
||||||
|
"length": 32,
|
||||||
|
"nullable": "no",
|
||||||
|
"cwidth": 18,
|
||||||
|
"uitype": "str",
|
||||||
|
"datatype": "str",
|
||||||
|
"label": "id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "resellerid",
|
||||||
|
"title": "商户机构id",
|
||||||
|
"type": "str",
|
||||||
|
"length": 32,
|
||||||
|
"nullable": "no",
|
||||||
|
"cwidth": 18,
|
||||||
|
"uitype": "str",
|
||||||
|
"datatype": "str",
|
||||||
|
"label": "商户机构id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "registry_id",
|
||||||
|
"title": "所属仓库",
|
||||||
|
"type": "str",
|
||||||
|
"length": 32,
|
||||||
|
"nullable": "no",
|
||||||
|
"label": "所属仓库",
|
||||||
|
"uitype": "code",
|
||||||
|
"valueField": "registry_id",
|
||||||
|
"textField": "registry_id_text",
|
||||||
|
"params": {
|
||||||
|
"dbname": "{{get_module_dbname('image_mgr')}}",
|
||||||
|
"table": "image_registry",
|
||||||
|
"tblvalue": "id",
|
||||||
|
"tbltext": "name",
|
||||||
|
"valueField": "registry_id",
|
||||||
|
"textField": "registry_id_text"
|
||||||
|
},
|
||||||
|
"dataurl": "{{entire_url('/appbase/get_code.dspy')}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mirror_id",
|
||||||
|
"title": "来源镜像源",
|
||||||
|
"type": "str",
|
||||||
|
"length": 32,
|
||||||
|
"label": "来源镜像源",
|
||||||
|
"uitype": "code",
|
||||||
|
"valueField": "mirror_id",
|
||||||
|
"textField": "mirror_id_text",
|
||||||
|
"params": {
|
||||||
|
"dbname": "{{get_module_dbname('image_mgr')}}",
|
||||||
|
"table": "mirror_source",
|
||||||
|
"tblvalue": "id",
|
||||||
|
"tbltext": "name",
|
||||||
|
"valueField": "mirror_id",
|
||||||
|
"textField": "mirror_id_text"
|
||||||
|
},
|
||||||
|
"dataurl": "{{entire_url('/appbase/get_code.dspy')}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "image_name",
|
||||||
|
"title": "镜像名(namespace/repo)",
|
||||||
|
"type": "str",
|
||||||
|
"length": 256,
|
||||||
|
"nullable": "no",
|
||||||
|
"cwidth": 18,
|
||||||
|
"uitype": "str",
|
||||||
|
"datatype": "str",
|
||||||
|
"label": "镜像名(namespace/repo)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "image_tag",
|
||||||
|
"title": "标签",
|
||||||
|
"type": "str",
|
||||||
|
"length": 128,
|
||||||
|
"nullable": "no",
|
||||||
|
"cwidth": 18,
|
||||||
|
"uitype": "str",
|
||||||
|
"datatype": "str",
|
||||||
|
"label": "标签"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "full_name",
|
||||||
|
"title": "完整名称(name:tag)",
|
||||||
|
"type": "str",
|
||||||
|
"length": 512,
|
||||||
|
"cwidth": 18,
|
||||||
|
"uitype": "str",
|
||||||
|
"datatype": "str",
|
||||||
|
"label": "完整名称(name:tag)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "source_image",
|
||||||
|
"title": "源镜像地址",
|
||||||
|
"type": "str",
|
||||||
|
"length": 512,
|
||||||
|
"cwidth": 18,
|
||||||
|
"uitype": "str",
|
||||||
|
"datatype": "str",
|
||||||
|
"label": "源镜像地址"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "size_bytes",
|
||||||
|
"title": "镜像大小(字节)",
|
||||||
|
"type": "long",
|
||||||
|
"default": 0,
|
||||||
|
"length": 0,
|
||||||
|
"uitype": "int",
|
||||||
|
"datatype": "long",
|
||||||
|
"label": "镜像大小(字节)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "digest",
|
||||||
|
"title": "镜像摘要(sha256)",
|
||||||
|
"type": "str",
|
||||||
|
"length": 256,
|
||||||
|
"cwidth": 18,
|
||||||
|
"uitype": "str",
|
||||||
|
"datatype": "str",
|
||||||
|
"label": "镜像摘要(sha256)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "image_type",
|
||||||
|
"title": "类型(vm/base/app/custom)",
|
||||||
|
"type": "char",
|
||||||
|
"length": 16,
|
||||||
|
"default": "custom",
|
||||||
|
"label": "类型(vm/base/app/custom)",
|
||||||
|
"uitype": "code",
|
||||||
|
"valueField": "image_type",
|
||||||
|
"textField": "image_type_text",
|
||||||
|
"params": {
|
||||||
|
"dbname": "{{get_module_dbname('image_mgr')}}",
|
||||||
|
"table": "appcodes_kv",
|
||||||
|
"tblvalue": "k",
|
||||||
|
"tbltext": "v",
|
||||||
|
"valueField": "image_type",
|
||||||
|
"textField": "image_type_text",
|
||||||
|
"cond": "parentid='image_type'"
|
||||||
|
},
|
||||||
|
"dataurl": "{{entire_url('/appbase/get_code.dspy')}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "description",
|
||||||
|
"title": "描述",
|
||||||
|
"type": "str",
|
||||||
|
"length": 512,
|
||||||
|
"cwidth": 18,
|
||||||
|
"uitype": "str",
|
||||||
|
"datatype": "str",
|
||||||
|
"label": "描述"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "is_customer",
|
||||||
|
"title": "是否客户自定义镜像",
|
||||||
|
"type": "int",
|
||||||
|
"default": 0,
|
||||||
|
"length": 0,
|
||||||
|
"uitype": "int",
|
||||||
|
"datatype": "int",
|
||||||
|
"label": "是否客户自定义镜像"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "customer_id",
|
||||||
|
"title": "客户ID",
|
||||||
|
"type": "str",
|
||||||
|
"length": 32,
|
||||||
|
"cwidth": 18,
|
||||||
|
"uitype": "str",
|
||||||
|
"datatype": "str",
|
||||||
|
"label": "客户ID"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sync_status",
|
||||||
|
"title": "同步状态(pending/syncing/synced/failed)",
|
||||||
|
"type": "char",
|
||||||
|
"length": 16,
|
||||||
|
"default": "pending",
|
||||||
|
"label": "同步状态(pending/syncing/synced/failed)",
|
||||||
|
"uitype": "code",
|
||||||
|
"valueField": "sync_status",
|
||||||
|
"textField": "sync_status_text",
|
||||||
|
"params": {
|
||||||
|
"dbname": "{{get_module_dbname('image_mgr')}}",
|
||||||
|
"table": "appcodes_kv",
|
||||||
|
"tblvalue": "k",
|
||||||
|
"tbltext": "v",
|
||||||
|
"valueField": "sync_status",
|
||||||
|
"textField": "sync_status_text",
|
||||||
|
"cond": "parentid='sync_status'"
|
||||||
|
},
|
||||||
|
"dataurl": "{{entire_url('/appbase/get_code.dspy')}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sync_log",
|
||||||
|
"title": "同步日志",
|
||||||
|
"type": "text",
|
||||||
|
"length": 0,
|
||||||
|
"uitype": "text",
|
||||||
|
"datatype": "text",
|
||||||
|
"label": "同步日志"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "created_at",
|
||||||
|
"title": "创建时间",
|
||||||
|
"type": "timestamp",
|
||||||
|
"nullable": "no",
|
||||||
|
"length": 0,
|
||||||
|
"uitype": "str",
|
||||||
|
"datatype": "timestamp",
|
||||||
|
"label": "创建时间"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "updated_at",
|
||||||
|
"title": "更新时间",
|
||||||
|
"type": "timestamp",
|
||||||
|
"nullable": "no",
|
||||||
|
"length": 0,
|
||||||
|
"uitype": "str",
|
||||||
|
"datatype": "timestamp",
|
||||||
|
"label": "更新时间"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"page_rows":160,
|
||||||
|
"cache_limit":5
|
||||||
|
}
|
||||||
|
|
||||||
|
,"binds":[]
|
||||||
|
|
||||||
|
}
|
||||||
75
wwwroot/container_image_list/update_container_image.dspy
Normal file
75
wwwroot/container_image_list/update_container_image.dspy
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
|
||||||
|
ns = params_kw.copy()
|
||||||
|
for k,v in ns.items():
|
||||||
|
if v == 'NaN' or v == 'null':
|
||||||
|
ns[k] = None
|
||||||
|
|
||||||
|
|
||||||
|
userorgid = await get_userorgid()
|
||||||
|
if not userorgid:
|
||||||
|
return {
|
||||||
|
"widgettype":"Error",
|
||||||
|
"options":{
|
||||||
|
"title":"Authorization Error",
|
||||||
|
"timeout":3,
|
||||||
|
"cwidth":16,
|
||||||
|
"cheight":9,
|
||||||
|
"message":"Please login"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ns['resellerid'] = userorgid
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
for k in list(ns.keys()):
|
||||||
|
if k.endswith('_at') or k.endswith('_time') or k == 'last_heartbeat':
|
||||||
|
v = ns.get(k, '')
|
||||||
|
if v == '' or v is None or v == 'None':
|
||||||
|
ns[k] = None
|
||||||
|
db = DBPools()
|
||||||
|
dbname = get_module_dbname('image_mgr')
|
||||||
|
async with db.sqlorContext(dbname) as sor:
|
||||||
|
|
||||||
|
ns1 = {
|
||||||
|
|
||||||
|
"resellerid": userorgid,
|
||||||
|
|
||||||
|
|
||||||
|
"id": params_kw.id
|
||||||
|
}
|
||||||
|
recs = await sor.R('container_image', ns1)
|
||||||
|
if len(recs) < 1:
|
||||||
|
return {
|
||||||
|
"widgettype":"Error",
|
||||||
|
"options":{
|
||||||
|
"title":"Update Error",
|
||||||
|
"cwidth":16,
|
||||||
|
"cheight":9,
|
||||||
|
"timeout":3,
|
||||||
|
"message":"Record no exist or with wrong ownership"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r = await sor.U('container_image', ns)
|
||||||
|
debug('update success');
|
||||||
|
return {
|
||||||
|
"widgettype":"Message",
|
||||||
|
"options":{
|
||||||
|
"title":"Update Success",
|
||||||
|
"cwidth":16,
|
||||||
|
"cheight":9,
|
||||||
|
"timeout":3,
|
||||||
|
"message":"ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"widgettype":"Error",
|
||||||
|
"options":{
|
||||||
|
"title":"Update Error",
|
||||||
|
"cwidth":16,
|
||||||
|
"cheight":9,
|
||||||
|
"timeout":3,
|
||||||
|
"message":"failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
57
wwwroot/image_registry_list/add_image_registry.dspy
Normal file
57
wwwroot/image_registry_list/add_image_registry.dspy
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
|
||||||
|
ns = params_kw.copy()
|
||||||
|
for k,v in ns.items():
|
||||||
|
if v == 'NaN' or v == 'null':
|
||||||
|
ns[k] = None
|
||||||
|
id = params_kw.id
|
||||||
|
if not id or len(id) > 32:
|
||||||
|
id = uuid()
|
||||||
|
ns['id'] = id
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
userorgid = await get_userorgid()
|
||||||
|
if not userorgid:
|
||||||
|
return {
|
||||||
|
"widgettype":"Error",
|
||||||
|
"options":{
|
||||||
|
"title":"Authorization Error",
|
||||||
|
"timeout":3,
|
||||||
|
"cwidth":16,
|
||||||
|
"cheight":9,
|
||||||
|
"message":"Please login"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ns['resellerid'] = userorgid
|
||||||
|
|
||||||
|
for k in list(ns.keys()):
|
||||||
|
if k.endswith('_at') or k.endswith('_time') or k == 'last_heartbeat':
|
||||||
|
v = ns.get(k, '')
|
||||||
|
if v == '' or v is None or v == 'None':
|
||||||
|
ns[k] = None
|
||||||
|
db = DBPools()
|
||||||
|
dbname = get_module_dbname('image_mgr')
|
||||||
|
async with db.sqlorContext(dbname) as sor:
|
||||||
|
r = await sor.C('image_registry', ns.copy())
|
||||||
|
return {
|
||||||
|
"widgettype":"Message",
|
||||||
|
"options":{
|
||||||
|
"user_data":ns,
|
||||||
|
"cwidth":16,
|
||||||
|
"cheight":9,
|
||||||
|
"title":"Add Success",
|
||||||
|
"timeout":3,
|
||||||
|
"message":"ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"widgettype":"Error",
|
||||||
|
"options":{
|
||||||
|
"title":"Add Error",
|
||||||
|
"cwidth":16,
|
||||||
|
"cheight":9,
|
||||||
|
"timeout":3,
|
||||||
|
"message":"failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
47
wwwroot/image_registry_list/delete_image_registry.dspy
Normal file
47
wwwroot/image_registry_list/delete_image_registry.dspy
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
|
||||||
|
ns = {
|
||||||
|
'id':params_kw['id'],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
userorgid = await get_userorgid()
|
||||||
|
if not userorgid:
|
||||||
|
return {
|
||||||
|
"widgettype":"Error",
|
||||||
|
"options":{
|
||||||
|
"title":"Authorization Error",
|
||||||
|
"timeout":3,
|
||||||
|
"cwidth":16,
|
||||||
|
"cheight":9,
|
||||||
|
"message":"Please login"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ns['resellerid'] = userorgid
|
||||||
|
|
||||||
|
db = DBPools()
|
||||||
|
dbname = get_module_dbname('image_mgr')
|
||||||
|
async with db.sqlorContext(dbname) as sor:
|
||||||
|
r = await sor.D('image_registry', ns)
|
||||||
|
debug('delete success');
|
||||||
|
return {
|
||||||
|
"widgettype":"Message",
|
||||||
|
"options":{
|
||||||
|
"title":"Delete Success",
|
||||||
|
"timeout":3,
|
||||||
|
"cwidth":16,
|
||||||
|
"cheight":9,
|
||||||
|
"message":"ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debug('Delete failed');
|
||||||
|
return {
|
||||||
|
"widgettype":"Error",
|
||||||
|
"options":{
|
||||||
|
"title":"Delete Error",
|
||||||
|
"timeout":3,
|
||||||
|
"cwidth":16,
|
||||||
|
"cheight":9,
|
||||||
|
"message":"failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
173
wwwroot/image_registry_list/get_image_registry.dspy
Normal file
173
wwwroot/image_registry_list/get_image_registry.dspy
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
|
||||||
|
ns = params_kw.copy()
|
||||||
|
|
||||||
|
|
||||||
|
userorgid = await get_userorgid()
|
||||||
|
if not userorgid:
|
||||||
|
return {
|
||||||
|
"widgettype":"Error",
|
||||||
|
"options":{
|
||||||
|
"title":"Authorization Error",
|
||||||
|
"timeout":3,
|
||||||
|
"cwidth":16,
|
||||||
|
"cheight":9,
|
||||||
|
"message":"Please login"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ns['resellerid'] = userorgid
|
||||||
|
ns['userorgid'] = userorgid
|
||||||
|
|
||||||
|
debug(f'get_image_registry.dspy:{ns=}')
|
||||||
|
if not ns.get('page'):
|
||||||
|
ns['page'] = 1
|
||||||
|
if not ns.get('sort'):
|
||||||
|
|
||||||
|
ns['sort'] = 'id'
|
||||||
|
|
||||||
|
|
||||||
|
sql = '''select a.*, b.cluster_id_text, c.registry_type_text, d.status_text
|
||||||
|
from (select * from image_registry where 1=1 [[filterstr]]) a left join (select id as cluster_id,
|
||||||
|
name as cluster_id_text from cluster where 1 = 1) b on a.cluster_id = b.cluster_id left join (select k as registry_type,
|
||||||
|
v as registry_type_text from appcodes_kv where parentid='registry_type') c on a.registry_type = c.registry_type left join (select k as status,
|
||||||
|
v as status_text from appcodes_kv where parentid='registry_status') d on a.status = d.status'''
|
||||||
|
|
||||||
|
filterjson = params_kw.get('data_filter')
|
||||||
|
if not filterjson:
|
||||||
|
fields = [ f['name'] for f in [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"title": "id",
|
||||||
|
"type": "str",
|
||||||
|
"length": 32,
|
||||||
|
"nullable": "no"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "resellerid",
|
||||||
|
"title": "商户机构id",
|
||||||
|
"type": "str",
|
||||||
|
"length": 32,
|
||||||
|
"nullable": "no"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "cluster_id",
|
||||||
|
"title": "所属集群",
|
||||||
|
"type": "str",
|
||||||
|
"length": 32
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"title": "仓库名称",
|
||||||
|
"type": "str",
|
||||||
|
"length": 128,
|
||||||
|
"nullable": "no"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "registry_type",
|
||||||
|
"title": "类型(docker-registry/harbor)",
|
||||||
|
"type": "char",
|
||||||
|
"length": 32,
|
||||||
|
"default": "docker-registry"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "endpoint",
|
||||||
|
"title": "访问地址(IP:PORT)",
|
||||||
|
"type": "str",
|
||||||
|
"length": 256
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "internal_endpoint",
|
||||||
|
"title": "集群内地址",
|
||||||
|
"type": "str",
|
||||||
|
"length": 256
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "admin_user",
|
||||||
|
"title": "管理员账号",
|
||||||
|
"type": "str",
|
||||||
|
"length": 64
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "admin_password",
|
||||||
|
"title": "管理员密码(加密)",
|
||||||
|
"type": "str",
|
||||||
|
"length": 256
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "storage_size",
|
||||||
|
"title": "存储大小(如50Gi)",
|
||||||
|
"type": "str",
|
||||||
|
"length": 16,
|
||||||
|
"default": "50Gi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "storage_class",
|
||||||
|
"title": "StorageClass",
|
||||||
|
"type": "str",
|
||||||
|
"length": 64,
|
||||||
|
"default": "pcc-nfs-sc"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "image_count",
|
||||||
|
"title": "镜像数量",
|
||||||
|
"type": "int",
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "total_size_gb",
|
||||||
|
"title": "已用存储GB",
|
||||||
|
"type": "int",
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "status",
|
||||||
|
"title": "状态(deploying/running/failed/destroyed)",
|
||||||
|
"type": "char",
|
||||||
|
"length": 16,
|
||||||
|
"default": "deploying"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "deploy_config",
|
||||||
|
"title": "部署配置JSON",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "created_at",
|
||||||
|
"title": "创建时间",
|
||||||
|
"type": "timestamp",
|
||||||
|
"nullable": "no"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "updated_at",
|
||||||
|
"title": "更新时间",
|
||||||
|
"type": "timestamp",
|
||||||
|
"nullable": "no"
|
||||||
|
}
|
||||||
|
] ]
|
||||||
|
filterjson = default_filterjson(fields, ns)
|
||||||
|
filterdic = ns.copy()
|
||||||
|
filterdic['filterstr'] = ''
|
||||||
|
filterdic['userorgid'] = '${userorgid}$'
|
||||||
|
filterdic['userid'] = '${userid}$'
|
||||||
|
if filterjson:
|
||||||
|
dbf = DBFilter(filterjson)
|
||||||
|
conds = dbf.gen(ns)
|
||||||
|
if conds:
|
||||||
|
ns.update(dbf.consts)
|
||||||
|
conds = f' and {conds}'
|
||||||
|
filterdic['filterstr'] = conds
|
||||||
|
ac = ArgsConvert('[[', ']]')
|
||||||
|
vars = ac.findAllVariables(sql)
|
||||||
|
NameSpace = {v:'${' + v + '}$' for v in vars if v != 'filterstr' }
|
||||||
|
filterdic.update(NameSpace)
|
||||||
|
sql = ac.convert(sql, filterdic)
|
||||||
|
|
||||||
|
debug(f'{sql=}')
|
||||||
|
db = DBPools()
|
||||||
|
dbname = get_module_dbname('image_mgr')
|
||||||
|
async with db.sqlorContext(dbname) as sor:
|
||||||
|
r = await sor.sqlPaging(sql, ns)
|
||||||
|
return r
|
||||||
|
return {
|
||||||
|
"total":0,
|
||||||
|
"rows":[]
|
||||||
|
}
|
||||||
298
wwwroot/image_registry_list/index.ui
Normal file
298
wwwroot/image_registry_list/index.ui
Normal file
@ -0,0 +1,298 @@
|
|||||||
|
|
||||||
|
{
|
||||||
|
"id":"image_registry_tbl",
|
||||||
|
"widgettype":"Tabular",
|
||||||
|
"options":{
|
||||||
|
"width":"100%",
|
||||||
|
"height":"100%",
|
||||||
|
|
||||||
|
|
||||||
|
"title":"镜像仓库",
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"css":"card",
|
||||||
|
|
||||||
|
|
||||||
|
"editable":{
|
||||||
|
|
||||||
|
"new_data_url":"{{entire_url('add_image_registry.dspy')}}",
|
||||||
|
|
||||||
|
|
||||||
|
"delete_data_url":"{{entire_url('delete_image_registry.dspy')}}",
|
||||||
|
|
||||||
|
|
||||||
|
"update_data_url":"{{entire_url('update_image_registry.dspy')}}"
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
"data_url":"{{entire_url('./get_image_registry.dspy')}}",
|
||||||
|
|
||||||
|
"data_method":"GET",
|
||||||
|
"data_params":{{json.dumps(params_kw, indent=4, ensure_ascii=False)}},
|
||||||
|
"row_options":{
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"browserfields": {
|
||||||
|
"name": {
|
||||||
|
"title": "仓库名称",
|
||||||
|
"width": 150
|
||||||
|
},
|
||||||
|
"registry_type": {
|
||||||
|
"title": "类型",
|
||||||
|
"width": 100
|
||||||
|
},
|
||||||
|
"cluster_id": {
|
||||||
|
"title": "集群",
|
||||||
|
"width": 100
|
||||||
|
},
|
||||||
|
"endpoint": {
|
||||||
|
"title": "访问地址",
|
||||||
|
"width": 200
|
||||||
|
},
|
||||||
|
"storage_size": {
|
||||||
|
"title": "存储大小",
|
||||||
|
"width": 80
|
||||||
|
},
|
||||||
|
"image_count": {
|
||||||
|
"title": "镜像数",
|
||||||
|
"width": 60
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"title": "状态",
|
||||||
|
"width": 80
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
"editexclouded":[
|
||||||
|
"id",
|
||||||
|
"resellerid",
|
||||||
|
"admin_password",
|
||||||
|
"internal_endpoint",
|
||||||
|
"deploy_config",
|
||||||
|
"created_at",
|
||||||
|
"updated_at"
|
||||||
|
],
|
||||||
|
|
||||||
|
"fields":[
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"title": "id",
|
||||||
|
"type": "str",
|
||||||
|
"length": 32,
|
||||||
|
"nullable": "no",
|
||||||
|
"cwidth": 18,
|
||||||
|
"uitype": "str",
|
||||||
|
"datatype": "str",
|
||||||
|
"label": "id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "resellerid",
|
||||||
|
"title": "商户机构id",
|
||||||
|
"type": "str",
|
||||||
|
"length": 32,
|
||||||
|
"nullable": "no",
|
||||||
|
"cwidth": 18,
|
||||||
|
"uitype": "str",
|
||||||
|
"datatype": "str",
|
||||||
|
"label": "商户机构id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "cluster_id",
|
||||||
|
"title": "所属集群",
|
||||||
|
"type": "str",
|
||||||
|
"length": 32,
|
||||||
|
"label": "所属集群",
|
||||||
|
"uitype": "code",
|
||||||
|
"valueField": "cluster_id",
|
||||||
|
"textField": "cluster_id_text",
|
||||||
|
"params": {
|
||||||
|
"dbname": "{{get_module_dbname('image_mgr')}}",
|
||||||
|
"table": "cluster",
|
||||||
|
"tblvalue": "id",
|
||||||
|
"tbltext": "name",
|
||||||
|
"valueField": "cluster_id",
|
||||||
|
"textField": "cluster_id_text"
|
||||||
|
},
|
||||||
|
"dataurl": "{{entire_url('/appbase/get_code.dspy')}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"title": "仓库名称",
|
||||||
|
"type": "str",
|
||||||
|
"length": 128,
|
||||||
|
"nullable": "no",
|
||||||
|
"cwidth": 18,
|
||||||
|
"uitype": "str",
|
||||||
|
"datatype": "str",
|
||||||
|
"label": "仓库名称"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "registry_type",
|
||||||
|
"title": "类型(docker-registry/harbor)",
|
||||||
|
"type": "char",
|
||||||
|
"length": 32,
|
||||||
|
"default": "docker-registry",
|
||||||
|
"label": "类型(docker-registry/harbor)",
|
||||||
|
"uitype": "code",
|
||||||
|
"valueField": "registry_type",
|
||||||
|
"textField": "registry_type_text",
|
||||||
|
"params": {
|
||||||
|
"dbname": "{{get_module_dbname('image_mgr')}}",
|
||||||
|
"table": "appcodes_kv",
|
||||||
|
"tblvalue": "k",
|
||||||
|
"tbltext": "v",
|
||||||
|
"valueField": "registry_type",
|
||||||
|
"textField": "registry_type_text",
|
||||||
|
"cond": "parentid='registry_type'"
|
||||||
|
},
|
||||||
|
"dataurl": "{{entire_url('/appbase/get_code.dspy')}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "endpoint",
|
||||||
|
"title": "访问地址(IP:PORT)",
|
||||||
|
"type": "str",
|
||||||
|
"length": 256,
|
||||||
|
"cwidth": 18,
|
||||||
|
"uitype": "str",
|
||||||
|
"datatype": "str",
|
||||||
|
"label": "访问地址(IP:PORT)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "internal_endpoint",
|
||||||
|
"title": "集群内地址",
|
||||||
|
"type": "str",
|
||||||
|
"length": 256,
|
||||||
|
"cwidth": 18,
|
||||||
|
"uitype": "str",
|
||||||
|
"datatype": "str",
|
||||||
|
"label": "集群内地址"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "admin_user",
|
||||||
|
"title": "管理员账号",
|
||||||
|
"type": "str",
|
||||||
|
"length": 64,
|
||||||
|
"cwidth": 18,
|
||||||
|
"uitype": "str",
|
||||||
|
"datatype": "str",
|
||||||
|
"label": "管理员账号"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "admin_password",
|
||||||
|
"title": "管理员密码(加密)",
|
||||||
|
"type": "str",
|
||||||
|
"length": 256,
|
||||||
|
"cwidth": 18,
|
||||||
|
"uitype": "str",
|
||||||
|
"datatype": "str",
|
||||||
|
"label": "管理员密码(加密)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "storage_size",
|
||||||
|
"title": "存储大小(如50Gi)",
|
||||||
|
"type": "str",
|
||||||
|
"length": 16,
|
||||||
|
"default": "50Gi",
|
||||||
|
"cwidth": 16,
|
||||||
|
"uitype": "str",
|
||||||
|
"datatype": "str",
|
||||||
|
"label": "存储大小(如50Gi)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "storage_class",
|
||||||
|
"title": "StorageClass",
|
||||||
|
"type": "str",
|
||||||
|
"length": 64,
|
||||||
|
"default": "pcc-nfs-sc",
|
||||||
|
"cwidth": 18,
|
||||||
|
"uitype": "str",
|
||||||
|
"datatype": "str",
|
||||||
|
"label": "StorageClass"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "image_count",
|
||||||
|
"title": "镜像数量",
|
||||||
|
"type": "int",
|
||||||
|
"default": 0,
|
||||||
|
"length": 0,
|
||||||
|
"uitype": "int",
|
||||||
|
"datatype": "int",
|
||||||
|
"label": "镜像数量"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "total_size_gb",
|
||||||
|
"title": "已用存储GB",
|
||||||
|
"type": "int",
|
||||||
|
"default": 0,
|
||||||
|
"length": 0,
|
||||||
|
"uitype": "int",
|
||||||
|
"datatype": "int",
|
||||||
|
"label": "已用存储GB"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "status",
|
||||||
|
"title": "状态(deploying/running/failed/destroyed)",
|
||||||
|
"type": "char",
|
||||||
|
"length": 16,
|
||||||
|
"default": "deploying",
|
||||||
|
"label": "状态(deploying/running/failed/destroyed)",
|
||||||
|
"uitype": "code",
|
||||||
|
"valueField": "status",
|
||||||
|
"textField": "status_text",
|
||||||
|
"params": {
|
||||||
|
"dbname": "{{get_module_dbname('image_mgr')}}",
|
||||||
|
"table": "appcodes_kv",
|
||||||
|
"tblvalue": "k",
|
||||||
|
"tbltext": "v",
|
||||||
|
"valueField": "status",
|
||||||
|
"textField": "status_text",
|
||||||
|
"cond": "parentid='registry_status'"
|
||||||
|
},
|
||||||
|
"dataurl": "{{entire_url('/appbase/get_code.dspy')}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "deploy_config",
|
||||||
|
"title": "部署配置JSON",
|
||||||
|
"type": "text",
|
||||||
|
"length": 0,
|
||||||
|
"uitype": "text",
|
||||||
|
"datatype": "text",
|
||||||
|
"label": "部署配置JSON"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "created_at",
|
||||||
|
"title": "创建时间",
|
||||||
|
"type": "timestamp",
|
||||||
|
"nullable": "no",
|
||||||
|
"length": 0,
|
||||||
|
"uitype": "str",
|
||||||
|
"datatype": "timestamp",
|
||||||
|
"label": "创建时间"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "updated_at",
|
||||||
|
"title": "更新时间",
|
||||||
|
"type": "timestamp",
|
||||||
|
"nullable": "no",
|
||||||
|
"length": 0,
|
||||||
|
"uitype": "str",
|
||||||
|
"datatype": "timestamp",
|
||||||
|
"label": "更新时间"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"page_rows":160,
|
||||||
|
"cache_limit":5
|
||||||
|
}
|
||||||
|
|
||||||
|
,"binds":[]
|
||||||
|
|
||||||
|
}
|
||||||
75
wwwroot/image_registry_list/update_image_registry.dspy
Normal file
75
wwwroot/image_registry_list/update_image_registry.dspy
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
|
||||||
|
ns = params_kw.copy()
|
||||||
|
for k,v in ns.items():
|
||||||
|
if v == 'NaN' or v == 'null':
|
||||||
|
ns[k] = None
|
||||||
|
|
||||||
|
|
||||||
|
userorgid = await get_userorgid()
|
||||||
|
if not userorgid:
|
||||||
|
return {
|
||||||
|
"widgettype":"Error",
|
||||||
|
"options":{
|
||||||
|
"title":"Authorization Error",
|
||||||
|
"timeout":3,
|
||||||
|
"cwidth":16,
|
||||||
|
"cheight":9,
|
||||||
|
"message":"Please login"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ns['resellerid'] = userorgid
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
for k in list(ns.keys()):
|
||||||
|
if k.endswith('_at') or k.endswith('_time') or k == 'last_heartbeat':
|
||||||
|
v = ns.get(k, '')
|
||||||
|
if v == '' or v is None or v == 'None':
|
||||||
|
ns[k] = None
|
||||||
|
db = DBPools()
|
||||||
|
dbname = get_module_dbname('image_mgr')
|
||||||
|
async with db.sqlorContext(dbname) as sor:
|
||||||
|
|
||||||
|
ns1 = {
|
||||||
|
|
||||||
|
"resellerid": userorgid,
|
||||||
|
|
||||||
|
|
||||||
|
"id": params_kw.id
|
||||||
|
}
|
||||||
|
recs = await sor.R('image_registry', ns1)
|
||||||
|
if len(recs) < 1:
|
||||||
|
return {
|
||||||
|
"widgettype":"Error",
|
||||||
|
"options":{
|
||||||
|
"title":"Update Error",
|
||||||
|
"cwidth":16,
|
||||||
|
"cheight":9,
|
||||||
|
"timeout":3,
|
||||||
|
"message":"Record no exist or with wrong ownership"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r = await sor.U('image_registry', ns)
|
||||||
|
debug('update success');
|
||||||
|
return {
|
||||||
|
"widgettype":"Message",
|
||||||
|
"options":{
|
||||||
|
"title":"Update Success",
|
||||||
|
"cwidth":16,
|
||||||
|
"cheight":9,
|
||||||
|
"timeout":3,
|
||||||
|
"message":"ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"widgettype":"Error",
|
||||||
|
"options":{
|
||||||
|
"title":"Update Error",
|
||||||
|
"cwidth":16,
|
||||||
|
"cheight":9,
|
||||||
|
"timeout":3,
|
||||||
|
"message":"failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
57
wwwroot/mirror_source_list/add_mirror_source.dspy
Normal file
57
wwwroot/mirror_source_list/add_mirror_source.dspy
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
|
||||||
|
ns = params_kw.copy()
|
||||||
|
for k,v in ns.items():
|
||||||
|
if v == 'NaN' or v == 'null':
|
||||||
|
ns[k] = None
|
||||||
|
id = params_kw.id
|
||||||
|
if not id or len(id) > 32:
|
||||||
|
id = uuid()
|
||||||
|
ns['id'] = id
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
userorgid = await get_userorgid()
|
||||||
|
if not userorgid:
|
||||||
|
return {
|
||||||
|
"widgettype":"Error",
|
||||||
|
"options":{
|
||||||
|
"title":"Authorization Error",
|
||||||
|
"timeout":3,
|
||||||
|
"cwidth":16,
|
||||||
|
"cheight":9,
|
||||||
|
"message":"Please login"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ns['resellerid'] = userorgid
|
||||||
|
|
||||||
|
for k in list(ns.keys()):
|
||||||
|
if k.endswith('_at') or k.endswith('_time') or k == 'last_heartbeat':
|
||||||
|
v = ns.get(k, '')
|
||||||
|
if v == '' or v is None or v == 'None':
|
||||||
|
ns[k] = None
|
||||||
|
db = DBPools()
|
||||||
|
dbname = get_module_dbname('image_mgr')
|
||||||
|
async with db.sqlorContext(dbname) as sor:
|
||||||
|
r = await sor.C('mirror_source', ns.copy())
|
||||||
|
return {
|
||||||
|
"widgettype":"Message",
|
||||||
|
"options":{
|
||||||
|
"user_data":ns,
|
||||||
|
"cwidth":16,
|
||||||
|
"cheight":9,
|
||||||
|
"title":"Add Success",
|
||||||
|
"timeout":3,
|
||||||
|
"message":"ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"widgettype":"Error",
|
||||||
|
"options":{
|
||||||
|
"title":"Add Error",
|
||||||
|
"cwidth":16,
|
||||||
|
"cheight":9,
|
||||||
|
"timeout":3,
|
||||||
|
"message":"failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
47
wwwroot/mirror_source_list/delete_mirror_source.dspy
Normal file
47
wwwroot/mirror_source_list/delete_mirror_source.dspy
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
|
||||||
|
ns = {
|
||||||
|
'id':params_kw['id'],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
userorgid = await get_userorgid()
|
||||||
|
if not userorgid:
|
||||||
|
return {
|
||||||
|
"widgettype":"Error",
|
||||||
|
"options":{
|
||||||
|
"title":"Authorization Error",
|
||||||
|
"timeout":3,
|
||||||
|
"cwidth":16,
|
||||||
|
"cheight":9,
|
||||||
|
"message":"Please login"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ns['resellerid'] = userorgid
|
||||||
|
|
||||||
|
db = DBPools()
|
||||||
|
dbname = get_module_dbname('image_mgr')
|
||||||
|
async with db.sqlorContext(dbname) as sor:
|
||||||
|
r = await sor.D('mirror_source', ns)
|
||||||
|
debug('delete success');
|
||||||
|
return {
|
||||||
|
"widgettype":"Message",
|
||||||
|
"options":{
|
||||||
|
"title":"Delete Success",
|
||||||
|
"timeout":3,
|
||||||
|
"cwidth":16,
|
||||||
|
"cheight":9,
|
||||||
|
"message":"ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debug('Delete failed');
|
||||||
|
return {
|
||||||
|
"widgettype":"Error",
|
||||||
|
"options":{
|
||||||
|
"title":"Delete Error",
|
||||||
|
"timeout":3,
|
||||||
|
"cwidth":16,
|
||||||
|
"cheight":9,
|
||||||
|
"message":"failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
161
wwwroot/mirror_source_list/get_mirror_source.dspy
Normal file
161
wwwroot/mirror_source_list/get_mirror_source.dspy
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
|
||||||
|
ns = params_kw.copy()
|
||||||
|
|
||||||
|
|
||||||
|
userorgid = await get_userorgid()
|
||||||
|
if not userorgid:
|
||||||
|
return {
|
||||||
|
"widgettype":"Error",
|
||||||
|
"options":{
|
||||||
|
"title":"Authorization Error",
|
||||||
|
"timeout":3,
|
||||||
|
"cwidth":16,
|
||||||
|
"cheight":9,
|
||||||
|
"message":"Please login"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ns['resellerid'] = userorgid
|
||||||
|
ns['userorgid'] = userorgid
|
||||||
|
|
||||||
|
debug(f'get_mirror_source.dspy:{ns=}')
|
||||||
|
if not ns.get('page'):
|
||||||
|
ns['page'] = 1
|
||||||
|
if not ns.get('sort'):
|
||||||
|
|
||||||
|
ns['sort'] = 'id'
|
||||||
|
|
||||||
|
|
||||||
|
sql = '''select a.*, b.source_type_text, c.region_text, d.status_text
|
||||||
|
from (select * from mirror_source where 1=1 [[filterstr]]) a left join (select k as source_type,
|
||||||
|
v as source_type_text from appcodes_kv where parentid='mirror_source_type') b on a.source_type = b.source_type left join (select k as region,
|
||||||
|
v as region_text from appcodes_kv where parentid='mirror_region') c on a.region = c.region left join (select k as status,
|
||||||
|
v as status_text from appcodes_kv where parentid='mirror_status') d on a.status = d.status'''
|
||||||
|
|
||||||
|
filterjson = params_kw.get('data_filter')
|
||||||
|
if not filterjson:
|
||||||
|
fields = [ f['name'] for f in [
|
||||||
|
{
|
||||||
|
"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": "source_type",
|
||||||
|
"title": "类型(public_mirror/private_registry/hub)",
|
||||||
|
"type": "char",
|
||||||
|
"length": 32,
|
||||||
|
"nullable": "no"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "registry_url",
|
||||||
|
"title": "Registry URL",
|
||||||
|
"type": "str",
|
||||||
|
"length": 512,
|
||||||
|
"nullable": "no"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "upstream_url",
|
||||||
|
"title": "上游源地址(如docker.io)",
|
||||||
|
"type": "str",
|
||||||
|
"length": 512
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "region",
|
||||||
|
"title": "区域(cn/us/eu)",
|
||||||
|
"type": "char",
|
||||||
|
"length": 8,
|
||||||
|
"default": "cn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "auth_user",
|
||||||
|
"title": "认证用户名",
|
||||||
|
"type": "str",
|
||||||
|
"length": 128
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "auth_password",
|
||||||
|
"title": "认证密码(加密)",
|
||||||
|
"type": "str",
|
||||||
|
"length": 256
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "is_default",
|
||||||
|
"title": "是否默认源",
|
||||||
|
"type": "int",
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "priority",
|
||||||
|
"title": "优先级(越小越高)",
|
||||||
|
"type": "int",
|
||||||
|
"default": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "status",
|
||||||
|
"title": "状态(active/inactive/error)",
|
||||||
|
"type": "char",
|
||||||
|
"length": 16,
|
||||||
|
"default": "active"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "health_check_at",
|
||||||
|
"title": "最后健康检查",
|
||||||
|
"type": "timestamp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "created_at",
|
||||||
|
"title": "创建时间",
|
||||||
|
"type": "timestamp",
|
||||||
|
"nullable": "no"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "updated_at",
|
||||||
|
"title": "更新时间",
|
||||||
|
"type": "timestamp",
|
||||||
|
"nullable": "no"
|
||||||
|
}
|
||||||
|
] ]
|
||||||
|
filterjson = default_filterjson(fields, ns)
|
||||||
|
filterdic = ns.copy()
|
||||||
|
filterdic['filterstr'] = ''
|
||||||
|
filterdic['userorgid'] = '${userorgid}$'
|
||||||
|
filterdic['userid'] = '${userid}$'
|
||||||
|
if filterjson:
|
||||||
|
dbf = DBFilter(filterjson)
|
||||||
|
conds = dbf.gen(ns)
|
||||||
|
if conds:
|
||||||
|
ns.update(dbf.consts)
|
||||||
|
conds = f' and {conds}'
|
||||||
|
filterdic['filterstr'] = conds
|
||||||
|
ac = ArgsConvert('[[', ']]')
|
||||||
|
vars = ac.findAllVariables(sql)
|
||||||
|
NameSpace = {v:'${' + v + '}$' for v in vars if v != 'filterstr' }
|
||||||
|
filterdic.update(NameSpace)
|
||||||
|
sql = ac.convert(sql, filterdic)
|
||||||
|
|
||||||
|
debug(f'{sql=}')
|
||||||
|
db = DBPools()
|
||||||
|
dbname = get_module_dbname('image_mgr')
|
||||||
|
async with db.sqlorContext(dbname) as sor:
|
||||||
|
r = await sor.sqlPaging(sql, ns)
|
||||||
|
return r
|
||||||
|
return {
|
||||||
|
"total":0,
|
||||||
|
"rows":[]
|
||||||
|
}
|
||||||
278
wwwroot/mirror_source_list/index.ui
Normal file
278
wwwroot/mirror_source_list/index.ui
Normal file
@ -0,0 +1,278 @@
|
|||||||
|
|
||||||
|
{
|
||||||
|
"id":"mirror_source_tbl",
|
||||||
|
"widgettype":"Tabular",
|
||||||
|
"options":{
|
||||||
|
"width":"100%",
|
||||||
|
"height":"100%",
|
||||||
|
|
||||||
|
|
||||||
|
"title":"镜像源",
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"css":"card",
|
||||||
|
|
||||||
|
|
||||||
|
"editable":{
|
||||||
|
|
||||||
|
"new_data_url":"{{entire_url('add_mirror_source.dspy')}}",
|
||||||
|
|
||||||
|
|
||||||
|
"delete_data_url":"{{entire_url('delete_mirror_source.dspy')}}",
|
||||||
|
|
||||||
|
|
||||||
|
"update_data_url":"{{entire_url('update_mirror_source.dspy')}}"
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
"data_url":"{{entire_url('./get_mirror_source.dspy')}}",
|
||||||
|
|
||||||
|
"data_method":"GET",
|
||||||
|
"data_params":{{json.dumps(params_kw, indent=4, ensure_ascii=False)}},
|
||||||
|
"row_options":{
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"browserfields": {
|
||||||
|
"name": {
|
||||||
|
"title": "名称",
|
||||||
|
"width": 150
|
||||||
|
},
|
||||||
|
"source_type": {
|
||||||
|
"title": "类型",
|
||||||
|
"width": 120
|
||||||
|
},
|
||||||
|
"registry_url": {
|
||||||
|
"title": "Registry URL",
|
||||||
|
"width": 250
|
||||||
|
},
|
||||||
|
"region": {
|
||||||
|
"title": "区域",
|
||||||
|
"width": 60
|
||||||
|
},
|
||||||
|
"priority": {
|
||||||
|
"title": "优先级",
|
||||||
|
"width": 60
|
||||||
|
},
|
||||||
|
"is_default": {
|
||||||
|
"title": "默认",
|
||||||
|
"width": 60
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"title": "状态",
|
||||||
|
"width": 80
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
"editexclouded":[
|
||||||
|
"id",
|
||||||
|
"resellerid",
|
||||||
|
"auth_password",
|
||||||
|
"health_check_at",
|
||||||
|
"created_at",
|
||||||
|
"updated_at"
|
||||||
|
],
|
||||||
|
|
||||||
|
"fields":[
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"title": "id",
|
||||||
|
"type": "str",
|
||||||
|
"length": 32,
|
||||||
|
"nullable": "no",
|
||||||
|
"cwidth": 18,
|
||||||
|
"uitype": "str",
|
||||||
|
"datatype": "str",
|
||||||
|
"label": "id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "resellerid",
|
||||||
|
"title": "商户机构id",
|
||||||
|
"type": "str",
|
||||||
|
"length": 32,
|
||||||
|
"nullable": "no",
|
||||||
|
"cwidth": 18,
|
||||||
|
"uitype": "str",
|
||||||
|
"datatype": "str",
|
||||||
|
"label": "商户机构id"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"title": "镜像源名称",
|
||||||
|
"type": "str",
|
||||||
|
"length": 128,
|
||||||
|
"nullable": "no",
|
||||||
|
"cwidth": 18,
|
||||||
|
"uitype": "str",
|
||||||
|
"datatype": "str",
|
||||||
|
"label": "镜像源名称"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "source_type",
|
||||||
|
"title": "类型(public_mirror/private_registry/hub)",
|
||||||
|
"type": "char",
|
||||||
|
"length": 32,
|
||||||
|
"nullable": "no",
|
||||||
|
"label": "类型(public_mirror/private_registry/hub)",
|
||||||
|
"uitype": "code",
|
||||||
|
"valueField": "source_type",
|
||||||
|
"textField": "source_type_text",
|
||||||
|
"params": {
|
||||||
|
"dbname": "{{get_module_dbname('image_mgr')}}",
|
||||||
|
"table": "appcodes_kv",
|
||||||
|
"tblvalue": "k",
|
||||||
|
"tbltext": "v",
|
||||||
|
"valueField": "source_type",
|
||||||
|
"textField": "source_type_text",
|
||||||
|
"cond": "parentid='mirror_source_type'"
|
||||||
|
},
|
||||||
|
"dataurl": "{{entire_url('/appbase/get_code.dspy')}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "registry_url",
|
||||||
|
"title": "Registry URL",
|
||||||
|
"type": "str",
|
||||||
|
"length": 512,
|
||||||
|
"nullable": "no",
|
||||||
|
"cwidth": 18,
|
||||||
|
"uitype": "str",
|
||||||
|
"datatype": "str",
|
||||||
|
"label": "Registry URL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "upstream_url",
|
||||||
|
"title": "上游源地址(如docker.io)",
|
||||||
|
"type": "str",
|
||||||
|
"length": 512,
|
||||||
|
"cwidth": 18,
|
||||||
|
"uitype": "str",
|
||||||
|
"datatype": "str",
|
||||||
|
"label": "上游源地址(如docker.io)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "region",
|
||||||
|
"title": "区域(cn/us/eu)",
|
||||||
|
"type": "char",
|
||||||
|
"length": 8,
|
||||||
|
"default": "cn",
|
||||||
|
"label": "区域(cn/us/eu)",
|
||||||
|
"uitype": "code",
|
||||||
|
"valueField": "region",
|
||||||
|
"textField": "region_text",
|
||||||
|
"params": {
|
||||||
|
"dbname": "{{get_module_dbname('image_mgr')}}",
|
||||||
|
"table": "appcodes_kv",
|
||||||
|
"tblvalue": "k",
|
||||||
|
"tbltext": "v",
|
||||||
|
"valueField": "region",
|
||||||
|
"textField": "region_text",
|
||||||
|
"cond": "parentid='mirror_region'"
|
||||||
|
},
|
||||||
|
"dataurl": "{{entire_url('/appbase/get_code.dspy')}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "auth_user",
|
||||||
|
"title": "认证用户名",
|
||||||
|
"type": "str",
|
||||||
|
"length": 128,
|
||||||
|
"cwidth": 18,
|
||||||
|
"uitype": "str",
|
||||||
|
"datatype": "str",
|
||||||
|
"label": "认证用户名"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "auth_password",
|
||||||
|
"title": "认证密码(加密)",
|
||||||
|
"type": "str",
|
||||||
|
"length": 256,
|
||||||
|
"cwidth": 18,
|
||||||
|
"uitype": "str",
|
||||||
|
"datatype": "str",
|
||||||
|
"label": "认证密码(加密)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "is_default",
|
||||||
|
"title": "是否默认源",
|
||||||
|
"type": "int",
|
||||||
|
"default": 0,
|
||||||
|
"length": 0,
|
||||||
|
"uitype": "int",
|
||||||
|
"datatype": "int",
|
||||||
|
"label": "是否默认源"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "priority",
|
||||||
|
"title": "优先级(越小越高)",
|
||||||
|
"type": "int",
|
||||||
|
"default": 100,
|
||||||
|
"length": 0,
|
||||||
|
"uitype": "int",
|
||||||
|
"datatype": "int",
|
||||||
|
"label": "优先级(越小越高)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "status",
|
||||||
|
"title": "状态(active/inactive/error)",
|
||||||
|
"type": "char",
|
||||||
|
"length": 16,
|
||||||
|
"default": "active",
|
||||||
|
"label": "状态(active/inactive/error)",
|
||||||
|
"uitype": "code",
|
||||||
|
"valueField": "status",
|
||||||
|
"textField": "status_text",
|
||||||
|
"params": {
|
||||||
|
"dbname": "{{get_module_dbname('image_mgr')}}",
|
||||||
|
"table": "appcodes_kv",
|
||||||
|
"tblvalue": "k",
|
||||||
|
"tbltext": "v",
|
||||||
|
"valueField": "status",
|
||||||
|
"textField": "status_text",
|
||||||
|
"cond": "parentid='mirror_status'"
|
||||||
|
},
|
||||||
|
"dataurl": "{{entire_url('/appbase/get_code.dspy')}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "health_check_at",
|
||||||
|
"title": "最后健康检查",
|
||||||
|
"type": "timestamp",
|
||||||
|
"length": 0,
|
||||||
|
"uitype": "str",
|
||||||
|
"datatype": "timestamp",
|
||||||
|
"label": "最后健康检查"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "created_at",
|
||||||
|
"title": "创建时间",
|
||||||
|
"type": "timestamp",
|
||||||
|
"nullable": "no",
|
||||||
|
"length": 0,
|
||||||
|
"uitype": "str",
|
||||||
|
"datatype": "timestamp",
|
||||||
|
"label": "创建时间"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "updated_at",
|
||||||
|
"title": "更新时间",
|
||||||
|
"type": "timestamp",
|
||||||
|
"nullable": "no",
|
||||||
|
"length": 0,
|
||||||
|
"uitype": "str",
|
||||||
|
"datatype": "timestamp",
|
||||||
|
"label": "更新时间"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"page_rows":160,
|
||||||
|
"cache_limit":5
|
||||||
|
}
|
||||||
|
|
||||||
|
,"binds":[]
|
||||||
|
|
||||||
|
}
|
||||||
75
wwwroot/mirror_source_list/update_mirror_source.dspy
Normal file
75
wwwroot/mirror_source_list/update_mirror_source.dspy
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
|
||||||
|
ns = params_kw.copy()
|
||||||
|
for k,v in ns.items():
|
||||||
|
if v == 'NaN' or v == 'null':
|
||||||
|
ns[k] = None
|
||||||
|
|
||||||
|
|
||||||
|
userorgid = await get_userorgid()
|
||||||
|
if not userorgid:
|
||||||
|
return {
|
||||||
|
"widgettype":"Error",
|
||||||
|
"options":{
|
||||||
|
"title":"Authorization Error",
|
||||||
|
"timeout":3,
|
||||||
|
"cwidth":16,
|
||||||
|
"cheight":9,
|
||||||
|
"message":"Please login"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ns['resellerid'] = userorgid
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
for k in list(ns.keys()):
|
||||||
|
if k.endswith('_at') or k.endswith('_time') or k == 'last_heartbeat':
|
||||||
|
v = ns.get(k, '')
|
||||||
|
if v == '' or v is None or v == 'None':
|
||||||
|
ns[k] = None
|
||||||
|
db = DBPools()
|
||||||
|
dbname = get_module_dbname('image_mgr')
|
||||||
|
async with db.sqlorContext(dbname) as sor:
|
||||||
|
|
||||||
|
ns1 = {
|
||||||
|
|
||||||
|
"resellerid": userorgid,
|
||||||
|
|
||||||
|
|
||||||
|
"id": params_kw.id
|
||||||
|
}
|
||||||
|
recs = await sor.R('mirror_source', ns1)
|
||||||
|
if len(recs) < 1:
|
||||||
|
return {
|
||||||
|
"widgettype":"Error",
|
||||||
|
"options":{
|
||||||
|
"title":"Update Error",
|
||||||
|
"cwidth":16,
|
||||||
|
"cheight":9,
|
||||||
|
"timeout":3,
|
||||||
|
"message":"Record no exist or with wrong ownership"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r = await sor.U('mirror_source', ns)
|
||||||
|
debug('update success');
|
||||||
|
return {
|
||||||
|
"widgettype":"Message",
|
||||||
|
"options":{
|
||||||
|
"title":"Update Success",
|
||||||
|
"cwidth":16,
|
||||||
|
"cheight":9,
|
||||||
|
"timeout":3,
|
||||||
|
"message":"ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"widgettype":"Error",
|
||||||
|
"options":{
|
||||||
|
"title":"Update Error",
|
||||||
|
"cwidth":16,
|
||||||
|
"cheight":9,
|
||||||
|
"timeout":3,
|
||||||
|
"message":"failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user