diff --git a/image_mgr/__init__.py b/image_mgr/__init__.py new file mode 100644 index 0000000..a049de3 --- /dev/null +++ b/image_mgr/__init__.py @@ -0,0 +1,679 @@ +""" +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} diff --git a/json/container_image_list.json b/json/container_image_list.json new file mode 100644 index 0000000..a37e89b --- /dev/null +++ b/json/container_image_list.json @@ -0,0 +1,23 @@ +{ + "tblname": "container_image", + "alias": "container_image_list", + "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} + }, + "editexclouded": ["id", "resellerid", "digest", "source_image", "sync_log", "created_at", "updated_at"], + "toolbar": {"tools": []}, + "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" + } +} diff --git a/json/image_registry_list.json b/json/image_registry_list.json new file mode 100644 index 0000000..7923224 --- /dev/null +++ b/json/image_registry_list.json @@ -0,0 +1,23 @@ +{ + "tblname": "image_registry", + "alias": "image_registry_list", + "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} + }, + "editexclouded": ["id", "resellerid", "admin_password", "internal_endpoint", "deploy_config", "created_at", "updated_at"], + "toolbar": {"tools": []}, + "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" + } +} diff --git a/json/mirror_source_list.json b/json/mirror_source_list.json new file mode 100644 index 0000000..b8ea88a --- /dev/null +++ b/json/mirror_source_list.json @@ -0,0 +1,23 @@ +{ + "tblname": "mirror_source", + "alias": "mirror_source_list", + "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} + }, + "editexclouded": ["id", "resellerid", "auth_password", "health_check_at", "created_at", "updated_at"], + "toolbar": {"tools": []}, + "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" + } +} diff --git a/models/container_image.json b/models/container_image.json new file mode 100644 index 0000000..f48484b --- /dev/null +++ b/models/container_image.json @@ -0,0 +1,34 @@ +{ + "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"} + ], + "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'"} + ] +} diff --git a/models/image_registry.json b/models/image_registry.json new file mode 100644 index 0000000..7e7614e --- /dev/null +++ b/models/image_registry.json @@ -0,0 +1,32 @@ +{ + "summary": [{ + "name": "image_registry", + "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": "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"} + ], + "codes": [ + {"field": "cluster_id", "table": "cluster", "valuefield": "id", "textfield": "name"}, + {"field": "registry_type", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='registry_type'"}, + {"field": "status", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='registry_status'"} + ] +} diff --git a/models/mirror_source.json b/models/mirror_source.json new file mode 100644 index 0000000..693fc9d --- /dev/null +++ b/models/mirror_source.json @@ -0,0 +1,30 @@ +{ + "summary": [{ + "name": "mirror_source", + "title": "镜像源", + "primary": ["id"], + "catelog": "entity" + }], + "fields": [ + {"name": "id", "title": "id", "type": "str", "length": 32, "nullable": "no"}, + {"name": "resellerid", "title": "商户机构id", "type": "str", "length": 32, "nullable": "no"}, + {"name": "name", "title": "镜像源名称", "type": "str", "length": 128, "nullable": "no"}, + {"name": "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"} + ], + "codes": [ + {"field": "source_type", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='mirror_source_type'"}, + {"field": "region", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='mirror_region'"}, + {"field": "status", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='mirror_status'"} + ] +} diff --git a/scripts/load_path.py b/scripts/load_path.py new file mode 100644 index 0000000..2a85477 --- /dev/null +++ b/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/setup.json b/setup.json new file mode 100644 index 0000000..8f31557 --- /dev/null +++ b/setup.json @@ -0,0 +1,8 @@ +{ + "name": "image_mgr", + "version": "0.1.0", + "description": "容器镜像管理:国内镜像源配置、本地镜像仓库、客户自定义镜像导入导出", + "packages": ["image_mgr"], + "install_requires": ["apppublic", "sqlor", "ahserver", "appbase"], + "python_requires": ">=3.10" +} diff --git a/wwwroot/api/image_import.dspy b/wwwroot/api/image_import.dspy new file mode 100644 index 0000000..2e7453d --- /dev/null +++ b/wwwroot/api/image_import.dspy @@ -0,0 +1,3 @@ +# 镜像导入:从外部镜像源拉取镜像 → 推送到本地仓库 +result = await image_import(request, params_kw) +return result if isinstance(result, dict) else {'widgettype': 'Error', 'options': {'title': '失败', 'message': str(result)}} diff --git a/wwwroot/api/image_push.dspy b/wwwroot/api/image_push.dspy new file mode 100644 index 0000000..f140a4d --- /dev/null +++ b/wwwroot/api/image_push.dspy @@ -0,0 +1,3 @@ +# 推送客户自定义镜像到本地仓库 +result = await image_push(request, params_kw) +return result if isinstance(result, dict) else {'widgettype': 'Error', 'options': {'title': '失败', 'message': str(result)}} diff --git a/wwwroot/api/image_sync.dspy b/wwwroot/api/image_sync.dspy new file mode 100644 index 0000000..4aa00ea --- /dev/null +++ b/wwwroot/api/image_sync.dspy @@ -0,0 +1,3 @@ +# 镜像同步:从镜像源拉取最新 tag 列表 → 同步到本地仓库 +result = await image_sync(request, params_kw) +return result if isinstance(result, dict) else {'widgettype': 'Error', 'options': {'title': '失败', 'message': str(result)}} diff --git a/wwwroot/api/mirror_health_check.dspy b/wwwroot/api/mirror_health_check.dspy new file mode 100644 index 0000000..5d89d11 --- /dev/null +++ b/wwwroot/api/mirror_health_check.dspy @@ -0,0 +1,3 @@ +# 镜像源健康检查 +result = await mirror_health_check(request, params_kw) +return result if isinstance(result, dict) else {'widgettype': 'Error', 'options': {'title': '失败', 'message': str(result)}} diff --git a/wwwroot/api/registry_deploy.dspy b/wwwroot/api/registry_deploy.dspy new file mode 100644 index 0000000..6f8233e --- /dev/null +++ b/wwwroot/api/registry_deploy.dspy @@ -0,0 +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)}}