From 8dc6f21719557ee1b61a3503f6ef3f4b96121d2e Mon Sep 17 00:00:00 2001 From: Ali <010109ali@gmail.com> Date: Fri, 5 Sep 2025 17:36:18 +0400 Subject: [PATCH] first commit --- .../incus_manager/doctype/__init__.py | 0 .../doctype/incus_container/__init__.py | 0 .../incus_container/incus_container.js | 297 ++++++++++++++++++ .../incus_container/incus_container.json | 122 +++++++ .../incus_container/incus_container.py | 248 +++++++++++++++ .../incus_container/test_incus_container.py | 30 ++ 6 files changed, 697 insertions(+) create mode 100644 incus_manager/incus_manager/doctype/__init__.py create mode 100644 incus_manager/incus_manager/doctype/incus_container/__init__.py create mode 100644 incus_manager/incus_manager/doctype/incus_container/incus_container.js create mode 100644 incus_manager/incus_manager/doctype/incus_container/incus_container.json create mode 100644 incus_manager/incus_manager/doctype/incus_container/incus_container.py create mode 100644 incus_manager/incus_manager/doctype/incus_container/test_incus_container.py diff --git a/incus_manager/incus_manager/doctype/__init__.py b/incus_manager/incus_manager/doctype/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/incus_manager/incus_manager/doctype/incus_container/__init__.py b/incus_manager/incus_manager/doctype/incus_container/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/incus_manager/incus_manager/doctype/incus_container/incus_container.js b/incus_manager/incus_manager/doctype/incus_container/incus_container.js new file mode 100644 index 0000000..63bd8df --- /dev/null +++ b/incus_manager/incus_manager/doctype/incus_container/incus_container.js @@ -0,0 +1,297 @@ +frappe.ui.form.on("Incus Container", { + refresh: function (frm) { + // Кнопка: Создать контейнер + if (frm.doc.status === "Creating") { + frm.add_custom_button("Создать", function () { + // Показываем индикатор загрузки + frappe.show_alert({ + message: "Создание контейнера...", + indicator: "blue" + }); + + frappe.call({ + method: "incus_container.incus_container.doctype.incus_container.incus_container.create_container", + args: { + name: frm.doc.container_name, + image_alias: frm.doc.image || "alpine/edge", + container_type: frm.doc.container_type || "container", + wait_completion: true + }, + callback: function (r) { + if (r.message) { + const response = r.message; + + if (response.success) { + frappe.msgprint({ + title: "Успех", + indicator: "green", + message: response.message + }); + + // Показываем детали операции если есть + if (response.operation_data) { + console.log("Operation details:", response.operation_data); + } + + // Обновляем статус документа + frm.set_value('status', 'Created'); + frm.save(); + + } else { + frappe.msgprint({ + title: "Ошибка создания контейнера", + indicator: "red", + message: response.message + }); + + console.error("Error details:", response.error); + + // При ошибке ставим статус Error + frm.set_value('status', 'Error'); + frm.save(); + } + } + }, + error: function(r) { + frappe.msgprint({ + title: "Системная ошибка", + indicator: "red", + message: "Произошла ошибка при вызове API" + }); + console.error("API call error:", r); + } + }); + }, "Управление"); + } + + // Кнопка: Быстрое создание (без ожидания) + if (frm.doc.status === "Creating") { + frm.add_custom_button("Создать (быстро)", function () { + frappe.show_alert({ + message: "Отправка запроса на создание контейнера...", + indicator: "orange" + }); + + frappe.call({ + method: "incus_container.incus_container.doctype.incus_container.incus_container.create_container", + args: { + name: frm.doc.container_name, + image_alias: frm.doc.image || "alpine/edge", + container_type: frm.doc.container_type || "container", + wait_completion: false + }, + callback: function (r) { + if (r.message) { + const response = r.message; + + if (response.success) { + frappe.msgprint({ + title: "Запрос отправлен", + indicator: "blue", + message: `${response.message}
Операция: ${response.operation_url}` + }); + + console.log("Operation URL:", response.operation_url); + + // Обновляем статус на "В процессе" + frm.set_value('status', 'Processing'); + frm.save(); + + } else { + frappe.msgprint({ + title: "Ошибка", + indicator: "red", + message: response.message + }); + + console.error("Error details:", response.error); + } + } + } + }); + }, "Управление"); + } + + // Кнопка: Создать с пользовательскими параметрами + frm.add_custom_button("Создать с параметрами", function () { + // Создаем диалог для ввода параметров + let dialog = new frappe.ui.Dialog({ + title: 'Создание контейнера', + fields: [ + { + label: 'Имя контейнера', + fieldname: 'container_name', + fieldtype: 'Data', + reqd: 1, + default: frm.doc.container_name || '' + }, + { + label: 'Образ (алиас)', + fieldname: 'image_alias', + fieldtype: 'Data', + reqd: 1, + default: frm.doc.image || 'alpine/edge' + }, + { + label: 'Тип контейнера', + fieldname: 'container_type', + fieldtype: 'Select', + options: 'container\nvirtual-machine', + default: frm.doc.container_type || 'container' + }, + { + label: 'Ждать завершения', + fieldname: 'wait_completion', + fieldtype: 'Check', + default: 1 + } + ], + primary_action_label: 'Создать', + primary_action(values) { + dialog.hide(); + + frappe.show_alert({ + message: "Создание контейнера...", + indicator: "blue" + }); + + frappe.call({ + method: "incus_container.incus_container.doctype.incus_container.incus_container.create_container", + args: { + name: values.container_name, + image_alias: values.image_alias, + container_type: values.container_type, + wait_completion: values.wait_completion + }, + callback: function (r) { + if (r.message) { + const response = r.message; + + if (response.success) { + frappe.msgprint({ + title: "Успех", + indicator: "green", + message: response.message + }); + + // Обновляем поля документа если создавался для него + if (values.container_name === frm.doc.container_name) { + frm.set_value('image', values.image_alias); + frm.set_value('container_type', values.container_type); + frm.set_value('status', values.wait_completion ? 'Created' : 'Processing'); + frm.save(); + } + + } else { + frappe.msgprint({ + title: "Ошибка", + indicator: "red", + message: response.message + }); + + console.error("Error details:", response.error); + } + } + } + }); + } + }); + dialog.show(); + }, "Управление"); + + // Кнопка: Показать список контейнеров + frm.add_custom_button("Список контейнеров", function () { + frappe.show_alert({ + message: "Загружаем список контейнеров...", + indicator: "blue" + }); + + frappe.call({ + method: "incus_container.incus_container.doctype.incus_container.incus_container.list_containers", + callback: function (r) { + if (r.message) { + const response = r.message; + + if (response.success && response.containers) { + // Создаем HTML таблицу для отображения + let table_html = ` +
+ + + + + + + + + + + + + `; + + response.containers.forEach(container => { + // Определяем цвет статуса + let status_color = 'text-muted'; + if (container.status === 'Running') status_color = 'text-success'; + else if (container.status === 'Stopped') status_color = 'text-warning'; + else if (container.status === 'Error') status_color = 'text-danger'; + + // Форматируем даты + let created_at = container.created_at !== 'Unknown' ? + new Date(container.created_at).toLocaleString() : 'Unknown'; + let last_used_at = container.last_used_at !== 'Unknown' ? + new Date(container.last_used_at).toLocaleString() : 'Unknown'; + + table_html += ` + + + + + + + + + `; + }); + + table_html += ` + +
НазваниеСтатусТипАрхитектураСозданПоследнее использование
${container.name}${container.status}${container.type}${container.architecture}${created_at}${last_used_at}
+
+

Всего контейнеров: ${response.total_count}

+ `; + + // Показываем диалог с таблицей + frappe.msgprint({ + title: "Список контейнеров Incus", + message: table_html, + wide: true + }); + + // Также выводим в консоль для разработчика + console.log("Containers list:", response.containers); + + } else { + frappe.msgprint({ + title: "Ошибка", + indicator: "red", + message: response.message || "Не удалось получить список контейнеров" + }); + + console.error("Error:", response.error); + } + } + }, + error: function(r) { + frappe.msgprint({ + title: "Системная ошибка", + indicator: "red", + message: "Произошла ошибка при получении списка контейнеров" + }); + console.error("API call error:", r); + } + }); + }, "Действия"); + } +}); \ No newline at end of file diff --git a/incus_manager/incus_manager/doctype/incus_container/incus_container.json b/incus_manager/incus_manager/doctype/incus_container/incus_container.json new file mode 100644 index 0000000..3512f40 --- /dev/null +++ b/incus_manager/incus_manager/doctype/incus_container/incus_container.json @@ -0,0 +1,122 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2025-09-03 19:23:42.889432", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "container_name", + "image", + "container_type", + "server", + "status", + "ip_address", + "section_break_details", + "created_at", + "last_used_at", + "architecture", + "section_break_pmeb" + ], + "fields": [ + { + "fieldname": "container_name", + "fieldtype": "Data", + "label": "Container Name", + "reqd": 1, + "description": "Уникальное имя контейнера в Incus" + }, + { + "fieldname": "image", + "fieldtype": "Data", + "label": "Image Alias", + "reqd": 1, + "default": "alpine/edge", + "description": "Алиас образа Incus (например: alpine/edge, ubuntu/22.04)" + }, + { + "fieldname": "container_type", + "fieldtype": "Select", + "label": "Container Type", + "options": "container\nvirtual-machine", + "default": "container", + "description": "Тип экземпляра: контейнер или виртуальная машина" + }, + { + "fieldname": "server", + "fieldtype": "Data", + "label": "Server", + "default": "router1", + "description": "Адрес Incus API (пример: router1 или 10.0.0.1)" + }, + { + "fieldname": "status", + "fieldtype": "Select", + "label": "Status", + "options": "Creating\nProcessing\nCreated\nRunning\nStopped\nError", + "default": "Creating", + "read_only": 1, + "description": "Текущий статус контейнера" + }, + { + "fieldname": "ip_address", + "fieldtype": "Data", + "label": "IP Address", + "read_only": 1, + "description": "IP адрес контейнера (если назначен)" + }, + { + "fieldname": "section_break_details", + "fieldtype": "Section Break", + "label": "Детали контейнера" + }, + { + "fieldname": "created_at", + "fieldtype": "Datetime", + "label": "Created At", + "read_only": 1, + "description": "Дата и время создания контейнера в Incus" + }, + { + "fieldname": "last_used_at", + "fieldtype": "Datetime", + "label": "Last Used At", + "read_only": 1, + "description": "Дата и время последнего использования" + }, + { + "fieldname": "architecture", + "fieldtype": "Data", + "label": "Architecture", + "read_only": 1, + "description": "Архитектура контейнера (x86_64, aarch64, etc.)" + }, + { + "fieldname": "section_break_pmeb", + "fieldtype": "Section Break" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2025-09-03 19:23:42.889432", + "modified_by": "Administrator", + "module": "Incus Manager", + "name": "Incus Container", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/incus_manager/incus_manager/doctype/incus_container/incus_container.py b/incus_manager/incus_manager/doctype/incus_container/incus_container.py new file mode 100644 index 0000000..883e691 --- /dev/null +++ b/incus_manager/incus_manager/doctype/incus_container/incus_container.py @@ -0,0 +1,248 @@ +import frappe +import requests +import json +import time +from frappe.model.document import Document + +# === Настройки Incus API === +INCUS_HOST = "https://10.220.187.117:8443" +CLIENT_CERT = ("/home/frappe/frappe-bench/apps/jey_erp/client.crt", "/home/frappe/frappe-bench/apps/jey_erp/client.key") +VERIFY = False + +class IncusContainer(Document): + """DocType для управления контейнерами Incus""" + pass + +def wait_for_operation(operation_url, timeout=60): + """ + Ждёт завершения асинхронной операции в Incus + """ + start_time = time.time() + + while time.time() - start_time < timeout: + try: + r = requests.get( + operation_url, + headers={"Content-Type": "application/json"}, + cert=CLIENT_CERT, + verify=VERIFY + ) + r.raise_for_status() + + op_data = r.json() + metadata = op_data.get("metadata", {}) + status = metadata.get("status") + + if status == "Success": + return { + "success": True, + "operation_data": op_data, + "message": "Операция успешно завершена" + } + elif status == "Failure": + return { + "success": False, + "operation_data": op_data, + "error": metadata.get("err", "Unknown error"), + "message": "Операция завершена с ошибкой" + } + elif status in ["Running", "Pending"]: + time.sleep(2) + continue + + except requests.exceptions.RequestException as e: + return { + "success": False, + "error": str(e), + "message": "Ошибка при проверке статуса операции" + } + + return { + "success": False, + "error": "Timeout", + "message": f"Операция не завершилась за {timeout} секунд" + } + +@frappe.whitelist() +def list_containers(): + """ + Получает список контейнеров + """ + url = f"{INCUS_HOST}/1.0/instances" + + try: + r = requests.get( + url, + headers={"Content-Type": "application/json"}, + cert=CLIENT_CERT, + verify=VERIFY + ) + r.raise_for_status() + + containers_data = r.json() + + # Получаем детальную информацию о каждом контейнере + detailed_containers = [] + + if containers_data.get("metadata"): + for container_url in containers_data["metadata"]: + # container_url выглядит как "/1.0/instances/container_name" + container_name = container_url.split("/")[-1] + + # Получаем подробную информацию о контейнере + detail_url = f"{INCUS_HOST}{container_url}" + try: + detail_r = requests.get( + detail_url, + headers={"Content-Type": "application/json"}, + cert=CLIENT_CERT, + verify=VERIFY + ) + detail_r.raise_for_status() + container_detail = detail_r.json() + + if container_detail.get("metadata"): + metadata = container_detail["metadata"] + detailed_containers.append({ + "name": container_name, + "status": metadata.get("status", "Unknown"), + "type": metadata.get("type", "Unknown"), + "architecture": metadata.get("architecture", "Unknown"), + "created_at": metadata.get("created_at", "Unknown"), + "last_used_at": metadata.get("last_used_at", "Unknown") + }) + except: + # Если не удалось получить детали, добавляем базовую информацию + detailed_containers.append({ + "name": container_name, + "status": "Unknown", + "type": "Unknown", + "architecture": "Unknown", + "created_at": "Unknown", + "last_used_at": "Unknown" + }) + + return { + "success": True, + "containers": detailed_containers, + "total_count": len(detailed_containers), + "message": f"Найдено контейнеров: {len(detailed_containers)}" + } + + except requests.exceptions.RequestException as e: + return { + "success": False, + "error": str(e), + "message": "Ошибка получения списка контейнеров" + } + +@frappe.whitelist() +def create_container(name, image_alias="alpine/edge", container_type="container", wait_completion=True): + """ + Создаёт новый контейнер + + Args: + name (str): Имя контейнера + image_alias (str): Алиас образа (по умолчанию alpine/edge) + container_type (str): Тип контейнера (по умолчанию container) + wait_completion (bool): Ждать ли завершения операции (по умолчанию True) + + Returns: + dict: Результат операции создания контейнера + """ + url = f"{INCUS_HOST}/1.0/instances" + + # Подготавливаем данные для создания контейнера + container_data = { + "name": name, + "type": container_type, + "source": { + "type": "image", + "alias": image_alias + } + } + + try: + # Отправляем запрос на создание контейнера + r = requests.post( + url, + headers={"Content-Type": "application/json"}, + cert=CLIENT_CERT, + verify=VERIFY, + data=json.dumps(container_data) + ) + r.raise_for_status() + + response_data = r.json() + + # Если wait_completion=False, возвращаем сразу + if not wait_completion: + return { + "success": True, + "container_name": name, + "operation_url": response_data.get("operation"), + "message": f"Запрос на создание контейнера '{name}' отправлен" + } + + # Ждём завершения операции + operation_url = response_data.get("operation") + if operation_url: + full_operation_url = f"{INCUS_HOST}{operation_url}" + operation_result = wait_for_operation(full_operation_url, timeout=120) + + if operation_result["success"]: + return { + "success": True, + "container_name": name, + "image_alias": image_alias, + "container_type": container_type, + "operation_data": operation_result["operation_data"], + "message": f"Контейнер '{name}' успешно создан" + } + else: + return { + "success": False, + "container_name": name, + "error": operation_result.get("error"), + "operation_data": operation_result.get("operation_data"), + "message": f"Ошибка при создании контейнера '{name}': {operation_result.get('error')}" + } + else: + return { + "success": False, + "container_name": name, + "error": "No operation URL returned", + "message": f"Не удалось получить URL операции для контейнера '{name}'" + } + + except requests.exceptions.HTTPError as e: + # Обрабатываем HTTP ошибки более детально + error_detail = "" + try: + error_response = e.response.json() + error_detail = error_response.get("error", str(e)) + except: + error_detail = str(e) + + return { + "success": False, + "container_name": name, + "error": error_detail, + "status_code": e.response.status_code if e.response else None, + "message": f"HTTP ошибка при создании контейнера '{name}': {error_detail}" + } + + except requests.exceptions.RequestException as e: + return { + "success": False, + "container_name": name, + "error": str(e), + "message": f"Ошибка сети при создании контейнера '{name}': {str(e)}" + } + except Exception as e: + return { + "success": False, + "container_name": name, + "error": str(e), + "message": f"Неожиданная ошибка при создании контейнера '{name}': {str(e)}" + } \ No newline at end of file diff --git a/incus_manager/incus_manager/doctype/incus_container/test_incus_container.py b/incus_manager/incus_manager/doctype/incus_container/test_incus_container.py new file mode 100644 index 0000000..af43750 --- /dev/null +++ b/incus_manager/incus_manager/doctype/incus_container/test_incus_container.py @@ -0,0 +1,30 @@ +# Copyright (c) 2025, Jey ERP and Contributors +# See license.txt + +# import frappe +from frappe.tests import IntegrationTestCase, UnitTestCase + + +# On IntegrationTestCase, the doctype test records and all +# link-field test record depdendencies are recursively loaded +# Use these module variables to add/remove to/from that list +EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] + + +class UnitTestIncusContainer(UnitTestCase): + """ + Unit tests for IncusContainer. + Use this class for testing individual functions and methods. + """ + + pass + + +class IntegrationTestIncusContainer(IntegrationTestCase): + """ + Integration tests for IncusContainer. + Use this class for testing interactions between multiple components. + """ + + pass