init
This commit is contained in:
		
							
								
								
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
				
			|||||||
 | 
					__pycache__/
 | 
				
			||||||
 | 
					*.pyc
 | 
				
			||||||
 | 
					*~
 | 
				
			||||||
							
								
								
									
										8
									
								
								Makefile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								Makefile
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,8 @@
 | 
				
			|||||||
 | 
					all: build
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					build:
 | 
				
			||||||
 | 
						poetry build
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					clean:
 | 
				
			||||||
 | 
						[ -d dist/ ] && rm -rf dist/ || true
 | 
				
			||||||
 | 
						find . -type d -name __pycache__ -exec rm -rf {} \; > /dev/null 2>&1 || true
 | 
				
			||||||
							
								
								
									
										27
									
								
								README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								README.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,27 @@
 | 
				
			|||||||
 | 
					# Node Agent
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Агент для работы на ворк-нодах.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Пока взаимодействовать можно только так (через [test.py](test.py)):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					sudo env NODEAGENT_CONFIG_FILE=$PWD/configuration.toml python test.py
 | 
				
			||||||
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Модули
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Основной класс тут `NodeAgent`. Через него осуществляется доступ ко всем методам.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- `base` тут базовый класс.
 | 
				
			||||||
 | 
					- `main` тут объявлен `NodeAgent`.
 | 
				
			||||||
 | 
					- `exceptions` тут исключения.
 | 
				
			||||||
 | 
					- `config` тут понятно.
 | 
				
			||||||
 | 
					- `vm` тут объявлен класс `VirtualMachine` с базовыми методами для виртуалок. Генерацию XML для дефайна ВМ следует сделать в отдельном модуле.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# TODO
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Нужно что-то придумать с обработкой ошибок. Сейчас на неожиданности я вызываю исключения, нужно некритичные из них обработать, чтобы приложение не падало при обращении к несуществующему домену или нефатальных ошибок при работе с существующими доменами.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Как это должно выглядеть
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					`node-agent` должен быть обычным DEB-пакетом. В пакете само приложение, sysyemd-сервис, конфиг. Бонусом можно доложить консольные утилиты (пока не реализованы): `nodeagent-vmctl` (чтобы напрямую дергать методы виртуалок), `guest-cmd` (обёртка над virsh quest-agent-command).
 | 
				
			||||||
							
								
								
									
										10
									
								
								configuration.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								configuration.toml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,10 @@
 | 
				
			|||||||
 | 
					[general]
 | 
				
			||||||
 | 
					connect_uri = 'qemu:///system'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[blockstorage]
 | 
				
			||||||
 | 
					local = true
 | 
				
			||||||
 | 
					local_path = '/srv/vm-disks'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[imagestorage]
 | 
				
			||||||
 | 
					local = true
 | 
				
			||||||
 | 
					local_path = '/srv/vm-images'
 | 
				
			||||||
							
								
								
									
										1
									
								
								node_agent/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								node_agent/__init__.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
				
			|||||||
 | 
					from .main import NodeAgent
 | 
				
			||||||
							
								
								
									
										7
									
								
								node_agent/base.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								node_agent/base.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,7 @@
 | 
				
			|||||||
 | 
					import libvirt
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class NodeAgentBase:
 | 
				
			||||||
 | 
					    def __init__(self, conn: libvirt.virConnect, config: dict):
 | 
				
			||||||
 | 
					        self.config = config
 | 
				
			||||||
 | 
					        self.conn = conn
 | 
				
			||||||
							
								
								
									
										21
									
								
								node_agent/config.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								node_agent/config.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,21 @@
 | 
				
			|||||||
 | 
					import os
 | 
				
			||||||
 | 
					import sys
 | 
				
			||||||
 | 
					import pathlib
 | 
				
			||||||
 | 
					import tomllib
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					NODEAGENT_CONFIG_FILE = \
 | 
				
			||||||
 | 
					    os.getenv('NODEAGENT_CONFIG_FILE') or '/etc/nodeagent/configuration.toml'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def load_config(config: pathlib.Path):
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        with open(config, 'rb') as conf:
 | 
				
			||||||
 | 
					            return tomllib.load(conf)
 | 
				
			||||||
 | 
					    except (OSError, ValueError) as readerr:
 | 
				
			||||||
 | 
					        sys.exit(f'Error: Cannot read configuration file: {readerr}')
 | 
				
			||||||
 | 
					    except tomllib.TOMLDecodeError as tomlerr:
 | 
				
			||||||
 | 
					        sys.exit(f'Error: Bad TOML syntax in configuration file: {tomlerr}')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					config = load_config(pathlib.Path(NODEAGENT_CONFIG_FILE))
 | 
				
			||||||
							
								
								
									
										34
									
								
								node_agent/exceptions.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								node_agent/exceptions.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,34 @@
 | 
				
			|||||||
 | 
					class VMNotFound(Exception):
 | 
				
			||||||
 | 
					    def __init__(self, domain, message='VM not found: {domain}'):
 | 
				
			||||||
 | 
					        self.domain = domain
 | 
				
			||||||
 | 
					        self.message = message.format(domain=domain)
 | 
				
			||||||
 | 
					        super().__init__(self.message)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class VMStartError(Exception):
 | 
				
			||||||
 | 
					    def __init__(self, domain, message='VM start error: {domain}'):
 | 
				
			||||||
 | 
					        self.domain = domain
 | 
				
			||||||
 | 
					        self.message = message.format(domain=domain)
 | 
				
			||||||
 | 
					        super().__init__(self.message)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class VMShutdownError(Exception):
 | 
				
			||||||
 | 
					    def __init__(
 | 
				
			||||||
 | 
					            self,
 | 
				
			||||||
 | 
					            domain,
 | 
				
			||||||
 | 
					            message="VM '{domain}' cannot shutdown, try with hard=True"
 | 
				
			||||||
 | 
					        ):
 | 
				
			||||||
 | 
					        self.domain = domain
 | 
				
			||||||
 | 
					        self.message = message.format(domain=domain)
 | 
				
			||||||
 | 
					        super().__init__(self.message)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class VMRebootError(Exception):
 | 
				
			||||||
 | 
					    def __init__(
 | 
				
			||||||
 | 
					            self,
 | 
				
			||||||
 | 
					            domain,
 | 
				
			||||||
 | 
					            message="VM '{domain}' reboot, try with hard=True",
 | 
				
			||||||
 | 
					        ):
 | 
				
			||||||
 | 
					        self.domain = domain
 | 
				
			||||||
 | 
					        self.message = message.format(domain=domain)
 | 
				
			||||||
 | 
					        super().__init__(self.message)
 | 
				
			||||||
							
								
								
									
										8
									
								
								node_agent/main.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								node_agent/main.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,8 @@
 | 
				
			|||||||
 | 
					import libvirt
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from .vm import VirtualMachine
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class NodeAgent:
 | 
				
			||||||
 | 
					    def __init__(self, conn: libvirt.virConnect, config: dict):
 | 
				
			||||||
 | 
					        self.vm = VirtualMachine(conn, config)
 | 
				
			||||||
							
								
								
									
										120
									
								
								node_agent/vm.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										120
									
								
								node_agent/vm.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,120 @@
 | 
				
			|||||||
 | 
					import libvirt
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from .base import NodeAgentBase
 | 
				
			||||||
 | 
					from .exceptions import (
 | 
				
			||||||
 | 
					    VMNotFound,
 | 
				
			||||||
 | 
					    VMStartError,
 | 
				
			||||||
 | 
					    VMRebootError,
 | 
				
			||||||
 | 
					    VMShutdownError,
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class VirtualMachine(NodeAgentBase):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def _dom(self, domain: str) -> libvirt.virDomain:
 | 
				
			||||||
 | 
					        """Get virDomain object to manipulate with domain."""
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            ret = self.conn.lookupByName(domain)
 | 
				
			||||||
 | 
					            if ret is not None:
 | 
				
			||||||
 | 
					                return ret
 | 
				
			||||||
 | 
					            raise VMNotFound(domain)
 | 
				
			||||||
 | 
					        except libvirt.libvirtError as err:
 | 
				
			||||||
 | 
					            raise VMNotFound(err) from err
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def create(
 | 
				
			||||||
 | 
					        self,
 | 
				
			||||||
 | 
					        name: str,
 | 
				
			||||||
 | 
					        volumes: list[dict],
 | 
				
			||||||
 | 
					        vcpus: int,
 | 
				
			||||||
 | 
					        vram: int,
 | 
				
			||||||
 | 
					        image: dict,
 | 
				
			||||||
 | 
					        cdrom: dict | None = None,
 | 
				
			||||||
 | 
					    ):
 | 
				
			||||||
 | 
					        # TODO
 | 
				
			||||||
 | 
					        pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def delete(self, name: str, delete_volumes=False):
 | 
				
			||||||
 | 
					        pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def status(self, name: str) -> str:
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Return VM state: 'running', 'shutoff', etc. Ref:
 | 
				
			||||||
 | 
					        https://libvirt.org/html/libvirt-libvirt-domain.html#virDomainState
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        state = self._dom(name).info()[0]
 | 
				
			||||||
 | 
					        match state:
 | 
				
			||||||
 | 
					            case libvirt.VIR_DOMAIN_NOSTATE:
 | 
				
			||||||
 | 
					                return 'nostate'
 | 
				
			||||||
 | 
					            case libvirt.VIR_DOMAIN_RUNNING:
 | 
				
			||||||
 | 
					                return 'running'
 | 
				
			||||||
 | 
					            case libvirt.VIR_DOMAIN_BLOCKED:
 | 
				
			||||||
 | 
					                return 'blocked'
 | 
				
			||||||
 | 
					            case libvirt.VIR_DOMAIN_PAUSED:
 | 
				
			||||||
 | 
					                return 'paused'
 | 
				
			||||||
 | 
					            case libvirt.VIR_DOMAIN_SHUTDOWN:
 | 
				
			||||||
 | 
					                return 'shutdown'
 | 
				
			||||||
 | 
					            case libvirt.VIR_DOMAIN_SHUTOFF:
 | 
				
			||||||
 | 
					                return 'shutoff'
 | 
				
			||||||
 | 
					            case libvirt.VIR_DOMAIN_CRASHED:
 | 
				
			||||||
 | 
					                return 'crashed'
 | 
				
			||||||
 | 
					            case libvirt.VIR_DOMAIN_PMSUSPENDED:
 | 
				
			||||||
 | 
					                return 'pmsuspended'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def is_running(self, name: str) -> bool:
 | 
				
			||||||
 | 
					        """Return True if VM is running, else return False."""
 | 
				
			||||||
 | 
					        if self._dom(name).isActive() != 1:
 | 
				
			||||||
 | 
					            return False  # inactive (0) or error (-1)
 | 
				
			||||||
 | 
					        return True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def start(self, name: str) -> None:
 | 
				
			||||||
 | 
					        """Start VM."""
 | 
				
			||||||
 | 
					        if not self.is_running(name):
 | 
				
			||||||
 | 
					            ret = self._dom(name).create()
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        if ret != 0:
 | 
				
			||||||
 | 
					            raise VMStartError(name)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def shutdown(self, name: str, hard=False) -> None:
 | 
				
			||||||
 | 
					        """Shutdown VM. Use hard=True to force shutdown."""
 | 
				
			||||||
 | 
					        if hard:
 | 
				
			||||||
 | 
					            # Destroy VM gracefully (no SIGKILL)
 | 
				
			||||||
 | 
					            ret = self._dom(name).destroyFlags(flags=libvirt.VIR_DOMAIN_DESTROY_GRACEFUL)
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            # Normal VM shutdown, OS may ignore this.
 | 
				
			||||||
 | 
					            ret = self._dom(name).shutdown()
 | 
				
			||||||
 | 
					        if ret != 0:
 | 
				
			||||||
 | 
					            raise VMShutdownError(name)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def reboot(self, name: str, hard=False) -> None:
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Reboot VM. Use hard=True to force reboot. With forced reboot
 | 
				
			||||||
 | 
					        VM will shutdown via self.shutdown() (no forced) and started.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        if hard:
 | 
				
			||||||
 | 
					            # Forced "reboot"
 | 
				
			||||||
 | 
					            self.shutdown(name)
 | 
				
			||||||
 | 
					            self.start(name)
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            # Normal reboot.
 | 
				
			||||||
 | 
					            ret = self._dom(name).reboot()
 | 
				
			||||||
 | 
					        if ret != 0:
 | 
				
			||||||
 | 
					            raise VMRebootError(name)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def vcpu_set(self, name: str, count: int):
 | 
				
			||||||
 | 
					        pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def vram_set(self, name: str, count: int):
 | 
				
			||||||
 | 
					        pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def ssh_keys_list(self, name: str, user: str):
 | 
				
			||||||
 | 
					        pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def ssh_keys_add(self, name: str, user: str):
 | 
				
			||||||
 | 
					        pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def ssh_keys_remove(self, name: str, user: str):
 | 
				
			||||||
 | 
					        pass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def set_user_password(self, name: str, user: str):
 | 
				
			||||||
 | 
					        pass
 | 
				
			||||||
							
								
								
									
										15
									
								
								pyproject.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								pyproject.toml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,15 @@
 | 
				
			|||||||
 | 
					[tool.poetry]
 | 
				
			||||||
 | 
					name = "node-agent"
 | 
				
			||||||
 | 
					version = "0.1.0"
 | 
				
			||||||
 | 
					description = "Node Agent"
 | 
				
			||||||
 | 
					authors = ["ge <ge@nixhacks.net>"]
 | 
				
			||||||
 | 
					readme = "README.md"
 | 
				
			||||||
 | 
					packages = [{include = "node_agent"}]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[tool.poetry.dependencies]
 | 
				
			||||||
 | 
					python = "^3.11"
 | 
				
			||||||
 | 
					libvirt-python = "^9.4.0"  # 9.0.0 on Debian 12
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[build-system]
 | 
				
			||||||
 | 
					requires = ["poetry-core"]
 | 
				
			||||||
 | 
					build-backend = "poetry.core.masonry.api"
 | 
				
			||||||
							
								
								
									
										16
									
								
								test.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								test.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,16 @@
 | 
				
			|||||||
 | 
					import libvirt
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from node_agent import NodeAgent
 | 
				
			||||||
 | 
					from node_agent.config import config
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					try:
 | 
				
			||||||
 | 
					    conn = libvirt.open(config['general']['connect_uri'])
 | 
				
			||||||
 | 
					except libvirt.libvirtError as err:
 | 
				
			||||||
 | 
					    sys.exit('Failed to open connection to the hypervisor: %s' % err)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					node_agent = NodeAgent(conn, config)
 | 
				
			||||||
 | 
					s = node_agent.vm.status('debian12')
 | 
				
			||||||
 | 
					print(s)
 | 
				
			||||||
 | 
					conn.close()
 | 
				
			||||||
		Reference in New Issue
	
	Block a user