various improvements
This commit is contained in:
		@@ -5,25 +5,25 @@
 | 
			
		||||
# the Free Software Foundation, either version 3 of the License, or
 | 
			
		||||
# (at your option) any later version.
 | 
			
		||||
#
 | 
			
		||||
# Ansible is distributed in the hope that it will be useful,
 | 
			
		||||
# Compute is distributed in the hope that it will be useful,
 | 
			
		||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
 | 
			
		||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 | 
			
		||||
# GNU General Public License for more details.
 | 
			
		||||
#
 | 
			
		||||
# You should have received a copy of the GNU General Public License
 | 
			
		||||
# along with Ansible.  If not, see <http://www.gnu.org/licenses/>.
 | 
			
		||||
# along with Compute.  If not, see <http://www.gnu.org/licenses/>.
 | 
			
		||||
 | 
			
		||||
"""Command line interface."""
 | 
			
		||||
 | 
			
		||||
import argparse
 | 
			
		||||
import io
 | 
			
		||||
import json
 | 
			
		||||
import logging
 | 
			
		||||
import os
 | 
			
		||||
import re
 | 
			
		||||
import shlex
 | 
			
		||||
import string
 | 
			
		||||
import sys
 | 
			
		||||
from collections import UserDict
 | 
			
		||||
from typing import Any
 | 
			
		||||
from uuid import uuid4
 | 
			
		||||
import uuid
 | 
			
		||||
 | 
			
		||||
import libvirt
 | 
			
		||||
import yaml
 | 
			
		||||
@@ -31,9 +31,10 @@ from pydantic import ValidationError
 | 
			
		||||
 | 
			
		||||
from compute import __version__
 | 
			
		||||
from compute.exceptions import ComputeError, GuestAgentTimeoutError
 | 
			
		||||
from compute.instance import GuestAgent
 | 
			
		||||
from compute.instance import GuestAgent, Instance, InstanceSchema
 | 
			
		||||
from compute.instance.devices import DiskConfig, DiskDriver
 | 
			
		||||
from compute.session import Session
 | 
			
		||||
from compute.utils import ids
 | 
			
		||||
from compute.utils import dictutil, ids
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
log = logging.getLogger(__name__)
 | 
			
		||||
@@ -128,78 +129,77 @@ def _exec_guest_agent_command(
 | 
			
		||||
    sys.exit(output.exitcode)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class _NotPresent:
 | 
			
		||||
    """
 | 
			
		||||
    Type for representing non-existent dictionary keys.
 | 
			
		||||
 | 
			
		||||
    See :class:`_FillableDict`.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class _FillableDict(UserDict):
 | 
			
		||||
    """Use :method:`fill` to add key if not present."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, data: dict):
 | 
			
		||||
        self.data = data
 | 
			
		||||
 | 
			
		||||
    def fill(self, key: str, value: Any) -> None:  # noqa: ANN401
 | 
			
		||||
        if self.data.get(key, _NotPresent) is _NotPresent:
 | 
			
		||||
            self.data[key] = value
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _merge_dicts(a: dict, b: dict, path: list[str] | None = None) -> dict:
 | 
			
		||||
    """Merge `b` into `a`. Return modified `a`."""
 | 
			
		||||
    if path is None:
 | 
			
		||||
        path = []
 | 
			
		||||
    for key in b:
 | 
			
		||||
        if key in a:
 | 
			
		||||
            if isinstance(a[key], dict) and isinstance(b[key], dict):
 | 
			
		||||
                _merge_dicts(a[key], b[key], [path + str(key)])
 | 
			
		||||
            elif a[key] == b[key]:
 | 
			
		||||
                pass  # same leaf value
 | 
			
		||||
            else:
 | 
			
		||||
                a[key] = b[key]  # replace existing key's values
 | 
			
		||||
        else:
 | 
			
		||||
            a[key] = b[key]
 | 
			
		||||
    return a
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _create_instance(session: Session, file: io.TextIOWrapper) -> None:
 | 
			
		||||
def _init_instance(session: Session, args: argparse.Namespace) -> None:
 | 
			
		||||
    try:
 | 
			
		||||
        data = _FillableDict(yaml.load(file.read(), Loader=yaml.SafeLoader))
 | 
			
		||||
        data = yaml.load(args.file.read(), Loader=yaml.SafeLoader)
 | 
			
		||||
        log.debug('Read from file: %s', data)
 | 
			
		||||
    except yaml.YAMLError as e:
 | 
			
		||||
        sys.exit(f'error: cannot parse YAML: {e}')
 | 
			
		||||
 | 
			
		||||
    capabilities = session.get_capabilities()
 | 
			
		||||
    node_info = session.get_node_info()
 | 
			
		||||
 | 
			
		||||
    data.fill('name', uuid4().hex)
 | 
			
		||||
    data.fill('title', None)
 | 
			
		||||
    data.fill('description', None)
 | 
			
		||||
    data.fill('arch', capabilities.arch)
 | 
			
		||||
    data.fill('machine', capabilities.machine)
 | 
			
		||||
    data.fill('emulator', capabilities.emulator)
 | 
			
		||||
    data.fill('max_vcpus', node_info.cpus)
 | 
			
		||||
    data.fill('max_memory', node_info.memory)
 | 
			
		||||
    data.fill('cpu', {})
 | 
			
		||||
    cpu = {
 | 
			
		||||
        'emulation_mode': 'host-passthrough',
 | 
			
		||||
        'model': None,
 | 
			
		||||
        'vendor': None,
 | 
			
		||||
        'topology': None,
 | 
			
		||||
        'features': None,
 | 
			
		||||
    base_instance_config = {
 | 
			
		||||
        'name': str(uuid.uuid4()),
 | 
			
		||||
        'title': None,
 | 
			
		||||
        'description': None,
 | 
			
		||||
        'arch': capabilities.arch,
 | 
			
		||||
        'machine': capabilities.machine,
 | 
			
		||||
        'emulator': capabilities.emulator,
 | 
			
		||||
        'max_vcpus': node_info.cpus,
 | 
			
		||||
        'max_memory': node_info.memory,
 | 
			
		||||
        'cpu': {
 | 
			
		||||
            'emulation_mode': 'host-passthrough',
 | 
			
		||||
            'model': None,
 | 
			
		||||
            'vendor': None,
 | 
			
		||||
            'topology': None,
 | 
			
		||||
            'features': None,
 | 
			
		||||
        },
 | 
			
		||||
        'network_interfaces': [
 | 
			
		||||
            {
 | 
			
		||||
                'source': 'default',
 | 
			
		||||
                'mac': ids.random_mac(),
 | 
			
		||||
            },
 | 
			
		||||
        ],
 | 
			
		||||
        'boot': {'order': ['cdrom', 'hd']},
 | 
			
		||||
    }
 | 
			
		||||
    data['cpu'] = _merge_dicts(data['cpu'], cpu)
 | 
			
		||||
    data.fill(
 | 
			
		||||
        'network_interfaces',
 | 
			
		||||
        [{'source': 'default', 'mac': ids.random_mac()}],
 | 
			
		||||
    )
 | 
			
		||||
    data.fill('boot', {'order': ['cdrom', 'hd']})
 | 
			
		||||
 | 
			
		||||
    data = dictutil.override(base_instance_config, data)
 | 
			
		||||
    volumes = []
 | 
			
		||||
    for volume in data['volumes']:
 | 
			
		||||
        base_disk_config = {
 | 
			
		||||
            'bus': 'virtio',
 | 
			
		||||
            'is_readonly': False,
 | 
			
		||||
            'driver': {
 | 
			
		||||
                'name': 'qemu',
 | 
			
		||||
                'type': 'qcow2',
 | 
			
		||||
                'cache': 'writethrough',
 | 
			
		||||
            },
 | 
			
		||||
        }
 | 
			
		||||
        base_cdrom_config = {
 | 
			
		||||
            'bus': 'ide',
 | 
			
		||||
            'target': 'hda',
 | 
			
		||||
            'is_readonly': True,
 | 
			
		||||
            'driver': {
 | 
			
		||||
                'name': 'qemu',
 | 
			
		||||
                'type': 'raw',
 | 
			
		||||
                'cache': 'writethrough',
 | 
			
		||||
            },
 | 
			
		||||
        }
 | 
			
		||||
        if volume.get('device') is None:
 | 
			
		||||
            volume['device'] = 'disk'
 | 
			
		||||
        if volume['device'] == 'disk':
 | 
			
		||||
            volumes.append(dictutil.override(base_disk_config, volume))
 | 
			
		||||
        if volume['device'] == 'cdrom':
 | 
			
		||||
            volumes.append(dictutil.override(base_cdrom_config, volume))
 | 
			
		||||
    data['volumes'] = volumes
 | 
			
		||||
    try:
 | 
			
		||||
        log.debug('Input data: %s', data)
 | 
			
		||||
        session.create_instance(**data)
 | 
			
		||||
        if args.test:
 | 
			
		||||
            _ = InstanceSchema(**data)
 | 
			
		||||
            print(json.dumps(dict(data), indent=4, sort_keys=True))
 | 
			
		||||
            sys.exit()
 | 
			
		||||
        instance = session.create_instance(**data)
 | 
			
		||||
        print(f'initialised: {instance.name}')
 | 
			
		||||
        if args.start:
 | 
			
		||||
            instance.start()
 | 
			
		||||
    except ValidationError as e:
 | 
			
		||||
        for error in e.errors():
 | 
			
		||||
            fields = '.'.join([str(lc) for lc in error['loc']])
 | 
			
		||||
@@ -223,11 +223,84 @@ def _shutdown_instance(session: Session, args: argparse.Namespace) -> None:
 | 
			
		||||
    instance.shutdown(method)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _confirm(message: str, *, default: bool | None = None) -> None:
 | 
			
		||||
    while True:
 | 
			
		||||
        match default:
 | 
			
		||||
            case True:
 | 
			
		||||
                prompt = 'default: yes'
 | 
			
		||||
            case False:
 | 
			
		||||
                prompt = 'default: no'
 | 
			
		||||
            case _:
 | 
			
		||||
                prompt = 'no default'
 | 
			
		||||
        try:
 | 
			
		||||
            answer = input(f'{message} ({prompt}) ')
 | 
			
		||||
        except KeyboardInterrupt:
 | 
			
		||||
            sys.exit('aborted')
 | 
			
		||||
        if not answer and isinstance(default, bool):
 | 
			
		||||
            return default
 | 
			
		||||
        if re.match(r'^y(es)?$', answer, re.I):
 | 
			
		||||
            return True
 | 
			
		||||
        if re.match(r'^no?$', answer, re.I):
 | 
			
		||||
            return False
 | 
			
		||||
        print("Please respond 'yes' or 'no'")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _delete_instance(session: Session, args: argparse.Namespace) -> None:
 | 
			
		||||
    if args.yes is True or _confirm(
 | 
			
		||||
        'this action is irreversible, continue?',
 | 
			
		||||
        default=False,
 | 
			
		||||
    ):
 | 
			
		||||
        instance = session.get_instance(args.instance)
 | 
			
		||||
        if args.save_volumes is False:
 | 
			
		||||
            instance.delete(with_volumes=True)
 | 
			
		||||
        else:
 | 
			
		||||
            instance.delete()
 | 
			
		||||
    else:
 | 
			
		||||
        print('aborted')
 | 
			
		||||
        sys.exit()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _get_disk_target(instance: Instance, prefix: str = 'hd') -> str:
 | 
			
		||||
    disks_live = instance.list_disks(persistent=False)
 | 
			
		||||
    disks_inactive = instance.list_disks(persistent=True)
 | 
			
		||||
    disks = [d for d in disks_inactive if d not in disks_live]
 | 
			
		||||
    devs = [d.target[-1] for d in disks if d.target.startswith(prefix)]
 | 
			
		||||
    return prefix + [x for x in string.ascii_lowercase if x not in devs][0]  # noqa: RUF015
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _manage_cdrom(session: Session, args: argparse.Namespace) -> None:
 | 
			
		||||
    instance = session.get_instance(args.instance)
 | 
			
		||||
    if args.detach:
 | 
			
		||||
        for disk in instance.list_disks(persistent=True):
 | 
			
		||||
            if disk.device == 'cdrom' and disk.source == args.source:
 | 
			
		||||
                instance.detach_disk(disk.target, live=False)
 | 
			
		||||
                print(
 | 
			
		||||
                    f"disk '{disk.target}' detached, "
 | 
			
		||||
                    'perform power reset to apply changes'
 | 
			
		||||
                )
 | 
			
		||||
        return
 | 
			
		||||
    target = _get_disk_target(instance, 'hd')
 | 
			
		||||
    cdrom = DiskConfig(
 | 
			
		||||
        type='file',
 | 
			
		||||
        device='cdrom',
 | 
			
		||||
        source=args.source,
 | 
			
		||||
        target=target,
 | 
			
		||||
        is_readonly=True,
 | 
			
		||||
        bus='ide',
 | 
			
		||||
        driver=DiskDriver('qemu', 'raw', 'writethrough'),
 | 
			
		||||
    )
 | 
			
		||||
    instance.attach_device(cdrom, live=False)
 | 
			
		||||
    print(
 | 
			
		||||
        f"CDROM attached as disk '{target}', "
 | 
			
		||||
        'perform power reset to apply changes'
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def main(session: Session, args: argparse.Namespace) -> None:
 | 
			
		||||
    """Perform actions."""
 | 
			
		||||
    match args.command:
 | 
			
		||||
        case 'init':
 | 
			
		||||
            _create_instance(session, args.file)
 | 
			
		||||
            _init_instance(session, args)
 | 
			
		||||
        case 'exec':
 | 
			
		||||
            _exec_guest_agent_command(session, args)
 | 
			
		||||
        case 'ls':
 | 
			
		||||
@@ -268,13 +341,17 @@ def main(session: Session, args: argparse.Namespace) -> None:
 | 
			
		||||
                args.password,
 | 
			
		||||
                encrypted=args.encrypted,
 | 
			
		||||
            )
 | 
			
		||||
        case 'setcdrom':
 | 
			
		||||
            _manage_cdrom(session, args)
 | 
			
		||||
        case 'delete':
 | 
			
		||||
            _delete_instance(session, args)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def cli() -> None:  # noqa: PLR0915
 | 
			
		||||
def get_parser() -> argparse.ArgumentParser:  # noqa: PLR0915
 | 
			
		||||
    """Return command line arguments parser."""
 | 
			
		||||
    root = argparse.ArgumentParser(
 | 
			
		||||
        prog='compute',
 | 
			
		||||
        description='manage compute instances',
 | 
			
		||||
        description='Manage compute instances.',
 | 
			
		||||
        formatter_class=argparse.RawTextHelpFormatter,
 | 
			
		||||
    )
 | 
			
		||||
    root.add_argument(
 | 
			
		||||
@@ -317,12 +394,27 @@ def cli() -> None:  # noqa: PLR0915
 | 
			
		||||
        default='instance.yaml',
 | 
			
		||||
        help='instance config [default: instance.yaml]',
 | 
			
		||||
    )
 | 
			
		||||
    init.add_argument(
 | 
			
		||||
        '-s',
 | 
			
		||||
        '--start',
 | 
			
		||||
        action='store_true',
 | 
			
		||||
        default=False,
 | 
			
		||||
        help='start instance after init',
 | 
			
		||||
    )
 | 
			
		||||
    init.add_argument(
 | 
			
		||||
        '-t',
 | 
			
		||||
        '--test',
 | 
			
		||||
        action='store_true',
 | 
			
		||||
        default=False,
 | 
			
		||||
        help='just print resulting instance config as JSON and exit',
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    # exec subcommand
 | 
			
		||||
    execute = subparsers.add_parser(
 | 
			
		||||
        'exec',
 | 
			
		||||
        help='execute command in guest via guest agent',
 | 
			
		||||
        description=(
 | 
			
		||||
            'Execute command in guest via guest agent. '
 | 
			
		||||
            'NOTE: any argument after instance name will be passed into '
 | 
			
		||||
            'guest as shell command.'
 | 
			
		||||
        ),
 | 
			
		||||
@@ -463,27 +555,60 @@ def cli() -> None:  # noqa: PLR0915
 | 
			
		||||
        help='set it if password is already encrypted',
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    # setcdrom subcommand
 | 
			
		||||
    setcdrom = subparsers.add_parser('setcdrom', help='manage CDROM devices')
 | 
			
		||||
    setcdrom.add_argument('instance')
 | 
			
		||||
    setcdrom.add_argument('source', help='source for CDROM')
 | 
			
		||||
    setcdrom.add_argument(
 | 
			
		||||
        '-d',
 | 
			
		||||
        '--detach',
 | 
			
		||||
        action='store_true',
 | 
			
		||||
        default=False,
 | 
			
		||||
        help='detach CDROM device',
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    # delete subcommand
 | 
			
		||||
    delete = subparsers.add_parser(
 | 
			
		||||
        'delete',
 | 
			
		||||
        help='delete instance',
 | 
			
		||||
    )
 | 
			
		||||
    delete.add_argument('instance')
 | 
			
		||||
    delete.add_argument(
 | 
			
		||||
        '-y',
 | 
			
		||||
        '--yes',
 | 
			
		||||
        action='store_true',
 | 
			
		||||
        default=False,
 | 
			
		||||
        help='automatic yes to prompt',
 | 
			
		||||
    )
 | 
			
		||||
    delete.add_argument(
 | 
			
		||||
        '--save-volumes',
 | 
			
		||||
        action='store_true',
 | 
			
		||||
        default=False,
 | 
			
		||||
        help='do not delete local storage volumes',
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    return root
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def cli() -> None:
 | 
			
		||||
    """Run arguments parser."""
 | 
			
		||||
    root = get_parser()
 | 
			
		||||
    args = root.parse_args()
 | 
			
		||||
    if args.command is None:
 | 
			
		||||
        root.print_help()
 | 
			
		||||
        sys.exit()
 | 
			
		||||
 | 
			
		||||
    log_level = args.log_level or os.getenv('CMP_LOG')
 | 
			
		||||
 | 
			
		||||
    if isinstance(log_level, str) and log_level.lower() in log_levels:
 | 
			
		||||
        logging.basicConfig(
 | 
			
		||||
            level=logging.getLevelNamesMapping()[log_level.upper()]
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    log.debug('CLI started with args: %s', args)
 | 
			
		||||
 | 
			
		||||
    connect_uri = (
 | 
			
		||||
        args.connect
 | 
			
		||||
        or os.getenv('CMP_LIBVIRT_URI')
 | 
			
		||||
        or os.getenv('LIBVIRT_DEFAULT_URI')
 | 
			
		||||
        or 'qemu:///system'
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        with Session(connect_uri) as session:
 | 
			
		||||
            main(session, args)
 | 
			
		||||
@@ -493,8 +618,6 @@ def cli() -> None:  # noqa: PLR0915
 | 
			
		||||
        sys.exit()
 | 
			
		||||
    except SystemExit as e:
 | 
			
		||||
        sys.exit(e)
 | 
			
		||||
    except Exception as e:  # noqa: BLE001
 | 
			
		||||
        sys.exit(f'unexpected error {type(e)}: {e}')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
if __name__ == '__main__':
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user