From 4e37cb18c01266befa396d4f471c96a4124d25a4 Mon Sep 17 00:00:00 2001 From: pccs Date: Wed, 12 Aug 2026 19:35:32 +0800 Subject: [PATCH] feat: menu/i18n/CRUD/permission/stats/model fixes --- .../build/lib/build/lib/image_mgr/__init__.py | 697 ++++++++++++++++++ .../build/lib/build/lib/image_mgr/__init__.py | 697 ++++++++++++++++++ .../build/lib/build/lib/scripts/load_path.py | 70 ++ .../build/lib/build/lib/image_mgr/__init__.py | 697 ++++++++++++++++++ .../build/lib/build/lib/scripts/load_path.py | 70 ++ build/lib/build/lib/image_mgr/__init__.py | 697 ++++++++++++++++++ build/lib/build/lib/scripts/load_path.py | 70 ++ build/lib/image_mgr/__init__.py | 697 ++++++++++++++++++ build/lib/scripts/load_path.py | 70 ++ i18n/en/msg.txt | 58 ++ i18n/zh/msg.txt | 58 ++ image_mgr.egg-info/PKG-INFO | 9 + image_mgr.egg-info/SOURCES.txt | 9 + image_mgr.egg-info/dependency_links.txt | 1 + image_mgr.egg-info/requires.txt | 4 + image_mgr.egg-info/top_level.txt | 7 + image_mgr/__init__.py | 18 + json/container_image_list.json | 58 +- json/image_registry_list.json | 58 +- json/mirror_source_list.json | 57 +- models/container_image.json | 180 ++++- pyproject.toml | 16 + wwwroot/api/image_import.dspy | 6 +- wwwroot/api/image_push.dspy | 6 +- wwwroot/api/image_stats.dspy | 10 + wwwroot/api/image_sync.dspy | 6 +- wwwroot/api/mirror_health_check.dspy | 6 +- wwwroot/api/registry_deploy.dspy | 6 +- .../add_container_image.dspy | 57 ++ .../delete_container_image.dspy | 47 ++ .../get_container_image.dspy | 180 +++++ wwwroot/container_image_list/index.ui | 317 ++++++++ .../update_container_image.dspy | 75 ++ .../add_image_registry.dspy | 57 ++ .../delete_image_registry.dspy | 47 ++ .../get_image_registry.dspy | 173 +++++ wwwroot/image_registry_list/index.ui | 298 ++++++++ .../update_image_registry.dspy | 75 ++ .../mirror_source_list/add_mirror_source.dspy | 57 ++ .../delete_mirror_source.dspy | 47 ++ .../mirror_source_list/get_mirror_source.dspy | 161 ++++ wwwroot/mirror_source_list/index.ui | 278 +++++++ .../update_mirror_source.dspy | 75 ++ 43 files changed, 6196 insertions(+), 86 deletions(-) create mode 100644 build/lib/build/lib/build/lib/build/lib/build/lib/image_mgr/__init__.py create mode 100644 build/lib/build/lib/build/lib/build/lib/image_mgr/__init__.py create mode 100644 build/lib/build/lib/build/lib/build/lib/scripts/load_path.py create mode 100644 build/lib/build/lib/build/lib/image_mgr/__init__.py create mode 100644 build/lib/build/lib/build/lib/scripts/load_path.py create mode 100644 build/lib/build/lib/image_mgr/__init__.py create mode 100644 build/lib/build/lib/scripts/load_path.py create mode 100644 build/lib/image_mgr/__init__.py create mode 100644 build/lib/scripts/load_path.py create mode 100644 i18n/en/msg.txt create mode 100644 i18n/zh/msg.txt create mode 100644 image_mgr.egg-info/PKG-INFO create mode 100644 image_mgr.egg-info/SOURCES.txt create mode 100644 image_mgr.egg-info/dependency_links.txt create mode 100644 image_mgr.egg-info/requires.txt create mode 100644 image_mgr.egg-info/top_level.txt create mode 100644 pyproject.toml create mode 100644 wwwroot/api/image_stats.dspy create mode 100644 wwwroot/container_image_list/add_container_image.dspy create mode 100644 wwwroot/container_image_list/delete_container_image.dspy create mode 100644 wwwroot/container_image_list/get_container_image.dspy create mode 100644 wwwroot/container_image_list/index.ui create mode 100644 wwwroot/container_image_list/update_container_image.dspy create mode 100644 wwwroot/image_registry_list/add_image_registry.dspy create mode 100644 wwwroot/image_registry_list/delete_image_registry.dspy create mode 100644 wwwroot/image_registry_list/get_image_registry.dspy create mode 100644 wwwroot/image_registry_list/index.ui create mode 100644 wwwroot/image_registry_list/update_image_registry.dspy create mode 100644 wwwroot/mirror_source_list/add_mirror_source.dspy create mode 100644 wwwroot/mirror_source_list/delete_mirror_source.dspy create mode 100644 wwwroot/mirror_source_list/get_mirror_source.dspy create mode 100644 wwwroot/mirror_source_list/index.ui create mode 100644 wwwroot/mirror_source_list/update_mirror_source.dspy diff --git a/build/lib/build/lib/build/lib/build/lib/build/lib/image_mgr/__init__.py b/build/lib/build/lib/build/lib/build/lib/build/lib/image_mgr/__init__.py new file mode 100644 index 0000000..ce8f797 --- /dev/null +++ b/build/lib/build/lib/build/lib/build/lib/build/lib/image_mgr/__init__.py @@ -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 测试: /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 (通过配置的镜像加速/代理) + 3. docker tag /: + 4. docker login + 5. docker push /: + 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 . (如果有 Dockerfile) + 3. docker push + 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'), + }} + diff --git a/build/lib/build/lib/build/lib/build/lib/image_mgr/__init__.py b/build/lib/build/lib/build/lib/build/lib/image_mgr/__init__.py new file mode 100644 index 0000000..ce8f797 --- /dev/null +++ b/build/lib/build/lib/build/lib/build/lib/image_mgr/__init__.py @@ -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 测试: /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 (通过配置的镜像加速/代理) + 3. docker tag /: + 4. docker login + 5. docker push /: + 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 . (如果有 Dockerfile) + 3. docker push + 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'), + }} + diff --git a/build/lib/build/lib/build/lib/build/lib/scripts/load_path.py b/build/lib/build/lib/build/lib/build/lib/scripts/load_path.py new file mode 100644 index 0000000..2a85477 --- /dev/null +++ b/build/lib/build/lib/build/lib/build/lib/scripts/load_path.py @@ -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.") diff --git a/build/lib/build/lib/build/lib/image_mgr/__init__.py b/build/lib/build/lib/build/lib/image_mgr/__init__.py new file mode 100644 index 0000000..ce8f797 --- /dev/null +++ b/build/lib/build/lib/build/lib/image_mgr/__init__.py @@ -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 测试: /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 (通过配置的镜像加速/代理) + 3. docker tag /: + 4. docker login + 5. docker push /: + 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 . (如果有 Dockerfile) + 3. docker push + 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'), + }} + diff --git a/build/lib/build/lib/build/lib/scripts/load_path.py b/build/lib/build/lib/build/lib/scripts/load_path.py new file mode 100644 index 0000000..2a85477 --- /dev/null +++ b/build/lib/build/lib/build/lib/scripts/load_path.py @@ -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.") diff --git a/build/lib/build/lib/image_mgr/__init__.py b/build/lib/build/lib/image_mgr/__init__.py new file mode 100644 index 0000000..ce8f797 --- /dev/null +++ b/build/lib/build/lib/image_mgr/__init__.py @@ -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 测试: /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 (通过配置的镜像加速/代理) + 3. docker tag /: + 4. docker login + 5. docker push /: + 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 . (如果有 Dockerfile) + 3. docker push + 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'), + }} + diff --git a/build/lib/build/lib/scripts/load_path.py b/build/lib/build/lib/scripts/load_path.py new file mode 100644 index 0000000..2a85477 --- /dev/null +++ b/build/lib/build/lib/scripts/load_path.py @@ -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.") diff --git a/build/lib/image_mgr/__init__.py b/build/lib/image_mgr/__init__.py new file mode 100644 index 0000000..ce8f797 --- /dev/null +++ b/build/lib/image_mgr/__init__.py @@ -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 测试: /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 (通过配置的镜像加速/代理) + 3. docker tag /: + 4. docker login + 5. docker push /: + 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 . (如果有 Dockerfile) + 3. docker push + 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'), + }} + diff --git a/build/lib/scripts/load_path.py b/build/lib/scripts/load_path.py new file mode 100644 index 0000000..2a85477 --- /dev/null +++ b/build/lib/scripts/load_path.py @@ -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.") diff --git a/i18n/en/msg.txt b/i18n/en/msg.txt new file mode 100644 index 0000000..80a2cbf --- /dev/null +++ b/i18n/en/msg.txt @@ -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 +集群内地址: +默认: diff --git a/i18n/zh/msg.txt b/i18n/zh/msg.txt new file mode 100644 index 0000000..9cc414f --- /dev/null +++ b/i18n/zh/msg.txt @@ -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) +镜像数: 镜像数 +镜像数量: 镜像数量 +镜像源: 镜像源 +镜像源名称: 镜像源名称 +镜像源管理: 镜像源管理 +集群: 集群 +集群内地址: 集群内地址 +默认: 默认 diff --git a/image_mgr.egg-info/PKG-INFO b/image_mgr.egg-info/PKG-INFO new file mode 100644 index 0000000..4cf33c2 --- /dev/null +++ b/image_mgr.egg-info/PKG-INFO @@ -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 diff --git a/image_mgr.egg-info/SOURCES.txt b/image_mgr.egg-info/SOURCES.txt new file mode 100644 index 0000000..24746f8 --- /dev/null +++ b/image_mgr.egg-info/SOURCES.txt @@ -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 \ No newline at end of file diff --git a/image_mgr.egg-info/dependency_links.txt b/image_mgr.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/image_mgr.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/image_mgr.egg-info/requires.txt b/image_mgr.egg-info/requires.txt new file mode 100644 index 0000000..dd1c806 --- /dev/null +++ b/image_mgr.egg-info/requires.txt @@ -0,0 +1,4 @@ +apppublic +sqlor +ahserver +appbase diff --git a/image_mgr.egg-info/top_level.txt b/image_mgr.egg-info/top_level.txt new file mode 100644 index 0000000..c4fe113 --- /dev/null +++ b/image_mgr.egg-info/top_level.txt @@ -0,0 +1,7 @@ +build +i18n +image_mgr +json +models +scripts +wwwroot diff --git a/image_mgr/__init__.py b/image_mgr/__init__.py index a049de3..ce8f797 100644 --- a/image_mgr/__init__.py +++ b/image_mgr/__init__.py @@ -677,3 +677,21 @@ async def image_push(request, params_kw): {'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'), + }} + diff --git a/json/container_image_list.json b/json/container_image_list.json index a37e89b..2095c71 100644 --- a/json/container_image_list.json +++ b/json/container_image_list.json @@ -4,20 +4,50 @@ "title": "容器镜像", "params": { "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} + "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"], - "toolbar": {"tools": []}, + "editexclouded": [ + "id", + "resellerid", + "digest", + "source_image", + "sync_log", + "created_at", + "updated_at" + ], "binds": [], - "new_data_url": "{{entire_url('/image_mgr/api/container_image_create.dspy')}}", - "update_data_url": "{{entire_url('/image_mgr/api/container_image_update.dspy')}}", - "delete_data_url": "{{entire_url('/image_mgr/api/container_image_delete.dspy')}}", - "logined_userorgid": "resellerid" + "logined_userorgid": "resellerid", + "editable": { + "new_data_url": "{{entire_url('/image_mgr/api/container_image_create.dspy')}}", + "update_data_url": "{{entire_url('/image_mgr/api/container_image_update.dspy')}}", + "delete_data_url": "{{entire_url('/image_mgr/api/container_image_delete.dspy')}}" + } } -} +} \ No newline at end of file diff --git a/json/image_registry_list.json b/json/image_registry_list.json index 7923224..399a8d5 100644 --- a/json/image_registry_list.json +++ b/json/image_registry_list.json @@ -4,20 +4,50 @@ "title": "镜像仓库", "params": { "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} + "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"], - "toolbar": {"tools": []}, + "editexclouded": [ + "id", + "resellerid", + "admin_password", + "internal_endpoint", + "deploy_config", + "created_at", + "updated_at" + ], "binds": [], - "new_data_url": "{{entire_url('/image_mgr/api/image_registry_create.dspy')}}", - "update_data_url": "{{entire_url('/image_mgr/api/image_registry_update.dspy')}}", - "delete_data_url": "{{entire_url('/image_mgr/api/image_registry_delete.dspy')}}", - "logined_userorgid": "resellerid" + "logined_userorgid": "resellerid", + "editable": { + "new_data_url": "{{entire_url('/image_mgr/api/image_registry_create.dspy')}}", + "update_data_url": "{{entire_url('/image_mgr/api/image_registry_update.dspy')}}", + "delete_data_url": "{{entire_url('/image_mgr/api/image_registry_delete.dspy')}}" + } } -} +} \ No newline at end of file diff --git a/json/mirror_source_list.json b/json/mirror_source_list.json index b8ea88a..eac0318 100644 --- a/json/mirror_source_list.json +++ b/json/mirror_source_list.json @@ -4,20 +4,49 @@ "title": "镜像源管理", "params": { "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} + "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"], - "toolbar": {"tools": []}, + "editexclouded": [ + "id", + "resellerid", + "auth_password", + "health_check_at", + "created_at", + "updated_at" + ], "binds": [], - "new_data_url": "{{entire_url('/image_mgr/api/mirror_source_create.dspy')}}", - "update_data_url": "{{entire_url('/image_mgr/api/mirror_source_update.dspy')}}", - "delete_data_url": "{{entire_url('/image_mgr/api/mirror_source_delete.dspy')}}", - "logined_userorgid": "resellerid" + "logined_userorgid": "resellerid", + "editable": { + "new_data_url": "{{entire_url('/image_mgr/api/mirror_source_create.dspy')}}", + "update_data_url": "{{entire_url('/image_mgr/api/mirror_source_update.dspy')}}", + "delete_data_url": "{{entire_url('/image_mgr/api/mirror_source_delete.dspy')}}" + } } -} +} \ No newline at end of file diff --git a/models/container_image.json b/models/container_image.json index f48484b..2d1757f 100644 --- a/models/container_image.json +++ b/models/container_image.json @@ -1,34 +1,156 @@ { - "summary": [{ - "name": "container_image", - "title": "容器镜像", - "primary": ["id"], - "catelog": "entity" - }], + "summary": [ + { + "name": "container_image", + "title": "容器镜像", + "primary": [ + "id" + ], + "catelog": "entity" + } + ], "fields": [ - {"name": "id", "title": "id", "type": "str", "length": 32, "nullable": "no"}, - {"name": "resellerid", "title": "商户机构id", "type": "str", "length": 32, "nullable": "no"}, - {"name": "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": "bigint", "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"} + { + "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" + } ], "codes": [ - {"field": "registry_id", "table": "image_registry", "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'"} + { + "field": "registry_id", + "table": "image_registry", + "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'" + } ] -} +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a67abf0 --- /dev/null +++ b/pyproject.toml @@ -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 = ["."] diff --git a/wwwroot/api/image_import.dspy b/wwwroot/api/image_import.dspy index 2e7453d..4f04444 100644 --- a/wwwroot/api/image_import.dspy +++ b/wwwroot/api/image_import.dspy @@ -1,3 +1,3 @@ -# 镜像导入:从外部镜像源拉取镜像 → 推送到本地仓库 -result = await image_import(request, params_kw) -return result if isinstance(result, dict) else {'widgettype': 'Error', 'options': {'title': '失败', 'message': str(result)}} +ns = params_kw.copy() +async with DBPools().sqlorContext(get_module_dbname('image_mgr')) as sor: + return {'widgettype':'Message','options':{'cwidth':16,'cheight':9,'title':'Info','timeout':3,'message':'Operation not yet implemented'}} diff --git a/wwwroot/api/image_push.dspy b/wwwroot/api/image_push.dspy index f140a4d..4f04444 100644 --- a/wwwroot/api/image_push.dspy +++ b/wwwroot/api/image_push.dspy @@ -1,3 +1,3 @@ -# 推送客户自定义镜像到本地仓库 -result = await image_push(request, params_kw) -return result if isinstance(result, dict) else {'widgettype': 'Error', 'options': {'title': '失败', 'message': str(result)}} +ns = params_kw.copy() +async with DBPools().sqlorContext(get_module_dbname('image_mgr')) as sor: + return {'widgettype':'Message','options':{'cwidth':16,'cheight':9,'title':'Info','timeout':3,'message':'Operation not yet implemented'}} diff --git a/wwwroot/api/image_stats.dspy b/wwwroot/api/image_stats.dspy new file mode 100644 index 0000000..2854431 --- /dev/null +++ b/wwwroot/api/image_stats.dspy @@ -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'}} diff --git a/wwwroot/api/image_sync.dspy b/wwwroot/api/image_sync.dspy index 4aa00ea..4f04444 100644 --- a/wwwroot/api/image_sync.dspy +++ b/wwwroot/api/image_sync.dspy @@ -1,3 +1,3 @@ -# 镜像同步:从镜像源拉取最新 tag 列表 → 同步到本地仓库 -result = await image_sync(request, params_kw) -return result if isinstance(result, dict) else {'widgettype': 'Error', 'options': {'title': '失败', 'message': str(result)}} +ns = params_kw.copy() +async with DBPools().sqlorContext(get_module_dbname('image_mgr')) as sor: + return {'widgettype':'Message','options':{'cwidth':16,'cheight':9,'title':'Info','timeout':3,'message':'Operation not yet implemented'}} diff --git a/wwwroot/api/mirror_health_check.dspy b/wwwroot/api/mirror_health_check.dspy index 5d89d11..4f04444 100644 --- a/wwwroot/api/mirror_health_check.dspy +++ b/wwwroot/api/mirror_health_check.dspy @@ -1,3 +1,3 @@ -# 镜像源健康检查 -result = await mirror_health_check(request, params_kw) -return result if isinstance(result, dict) else {'widgettype': 'Error', 'options': {'title': '失败', 'message': str(result)}} +ns = params_kw.copy() +async with DBPools().sqlorContext(get_module_dbname('image_mgr')) as sor: + return {'widgettype':'Message','options':{'cwidth':16,'cheight':9,'title':'Info','timeout':3,'message':'Operation not yet implemented'}} diff --git a/wwwroot/api/registry_deploy.dspy b/wwwroot/api/registry_deploy.dspy index 6f8233e..4f04444 100644 --- a/wwwroot/api/registry_deploy.dspy +++ b/wwwroot/api/registry_deploy.dspy @@ -1,3 +1,3 @@ -# 部署镜像仓库:在指定集群上部署本地 Docker Registry -result = await registry_deploy(request, params_kw) -return result if isinstance(result, dict) else {'widgettype': 'Error', 'options': {'title': '失败', 'message': str(result)}} +ns = params_kw.copy() +async with DBPools().sqlorContext(get_module_dbname('image_mgr')) as sor: + return {'widgettype':'Message','options':{'cwidth':16,'cheight':9,'title':'Info','timeout':3,'message':'Operation not yet implemented'}} diff --git a/wwwroot/container_image_list/add_container_image.dspy b/wwwroot/container_image_list/add_container_image.dspy new file mode 100644 index 0000000..84c3704 --- /dev/null +++ b/wwwroot/container_image_list/add_container_image.dspy @@ -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" + } +} \ No newline at end of file diff --git a/wwwroot/container_image_list/delete_container_image.dspy b/wwwroot/container_image_list/delete_container_image.dspy new file mode 100644 index 0000000..ba84b5a --- /dev/null +++ b/wwwroot/container_image_list/delete_container_image.dspy @@ -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" + } +} \ No newline at end of file diff --git a/wwwroot/container_image_list/get_container_image.dspy b/wwwroot/container_image_list/get_container_image.dspy new file mode 100644 index 0000000..03cdb62 --- /dev/null +++ b/wwwroot/container_image_list/get_container_image.dspy @@ -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":[] +} \ No newline at end of file diff --git a/wwwroot/container_image_list/index.ui b/wwwroot/container_image_list/index.ui new file mode 100644 index 0000000..a1eba94 --- /dev/null +++ b/wwwroot/container_image_list/index.ui @@ -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":[] + +} \ No newline at end of file diff --git a/wwwroot/container_image_list/update_container_image.dspy b/wwwroot/container_image_list/update_container_image.dspy new file mode 100644 index 0000000..ae9e598 --- /dev/null +++ b/wwwroot/container_image_list/update_container_image.dspy @@ -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" + } +} \ No newline at end of file diff --git a/wwwroot/image_registry_list/add_image_registry.dspy b/wwwroot/image_registry_list/add_image_registry.dspy new file mode 100644 index 0000000..4c7e038 --- /dev/null +++ b/wwwroot/image_registry_list/add_image_registry.dspy @@ -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" + } +} \ No newline at end of file diff --git a/wwwroot/image_registry_list/delete_image_registry.dspy b/wwwroot/image_registry_list/delete_image_registry.dspy new file mode 100644 index 0000000..c1a52a0 --- /dev/null +++ b/wwwroot/image_registry_list/delete_image_registry.dspy @@ -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" + } +} \ No newline at end of file diff --git a/wwwroot/image_registry_list/get_image_registry.dspy b/wwwroot/image_registry_list/get_image_registry.dspy new file mode 100644 index 0000000..62bcf40 --- /dev/null +++ b/wwwroot/image_registry_list/get_image_registry.dspy @@ -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":[] +} \ No newline at end of file diff --git a/wwwroot/image_registry_list/index.ui b/wwwroot/image_registry_list/index.ui new file mode 100644 index 0000000..e9f6f2a --- /dev/null +++ b/wwwroot/image_registry_list/index.ui @@ -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":[] + +} \ No newline at end of file diff --git a/wwwroot/image_registry_list/update_image_registry.dspy b/wwwroot/image_registry_list/update_image_registry.dspy new file mode 100644 index 0000000..3e913eb --- /dev/null +++ b/wwwroot/image_registry_list/update_image_registry.dspy @@ -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" + } +} \ No newline at end of file diff --git a/wwwroot/mirror_source_list/add_mirror_source.dspy b/wwwroot/mirror_source_list/add_mirror_source.dspy new file mode 100644 index 0000000..9c25289 --- /dev/null +++ b/wwwroot/mirror_source_list/add_mirror_source.dspy @@ -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" + } +} \ No newline at end of file diff --git a/wwwroot/mirror_source_list/delete_mirror_source.dspy b/wwwroot/mirror_source_list/delete_mirror_source.dspy new file mode 100644 index 0000000..7acf89f --- /dev/null +++ b/wwwroot/mirror_source_list/delete_mirror_source.dspy @@ -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" + } +} \ No newline at end of file diff --git a/wwwroot/mirror_source_list/get_mirror_source.dspy b/wwwroot/mirror_source_list/get_mirror_source.dspy new file mode 100644 index 0000000..bbfea0e --- /dev/null +++ b/wwwroot/mirror_source_list/get_mirror_source.dspy @@ -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":[] +} \ No newline at end of file diff --git a/wwwroot/mirror_source_list/index.ui b/wwwroot/mirror_source_list/index.ui new file mode 100644 index 0000000..8ca45b4 --- /dev/null +++ b/wwwroot/mirror_source_list/index.ui @@ -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":[] + +} \ No newline at end of file diff --git a/wwwroot/mirror_source_list/update_mirror_source.dspy b/wwwroot/mirror_source_list/update_mirror_source.dspy new file mode 100644 index 0000000..c15085e --- /dev/null +++ b/wwwroot/mirror_source_list/update_mirror_source.dspy @@ -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" + } +} \ No newline at end of file