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 = `
+
| Название | +Статус | +Тип | +Архитектура | +Создан | +Последнее использование | +
|---|---|---|---|---|---|
| ${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