various updates v.dev3
This commit is contained in:
409
compute/cli/commands.py
Normal file
409
compute/cli/commands.py
Normal file
@ -0,0 +1,409 @@
|
||||
# This file is part of Compute
|
||||
#
|
||||
# Compute is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# 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 Compute. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""CLI commands."""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import pathlib
|
||||
import re
|
||||
import shlex
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
import libvirt
|
||||
import pydantic
|
||||
import yaml
|
||||
|
||||
from compute import Session
|
||||
from compute.cli.term import Table, confirm
|
||||
from compute.exceptions import GuestAgentTimeoutExpired
|
||||
from compute.instance import CloudInit, GuestAgent, InstanceSchema
|
||||
from compute.instance.devices import DiskConfig, DiskDriver
|
||||
from compute.utils import dictutil, diskutils, ids
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
libvirt.registerErrorHandler(
|
||||
lambda userdata, err: None, # noqa: ARG005
|
||||
ctx=None,
|
||||
)
|
||||
|
||||
|
||||
def init(session: Session, args: argparse.Namespace) -> None:
|
||||
"""Initialise compute instance using YAML config."""
|
||||
try:
|
||||
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()
|
||||
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']},
|
||||
'cloud_init': None,
|
||||
}
|
||||
data = dictutil.override(base_instance_config, data)
|
||||
volumes = []
|
||||
targets = []
|
||||
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',
|
||||
'is_readonly': True,
|
||||
'driver': {
|
||||
'name': 'qemu',
|
||||
'type': 'raw',
|
||||
'cache': 'writethrough',
|
||||
},
|
||||
}
|
||||
if volume.get('device') is None:
|
||||
volume['device'] = 'disk'
|
||||
if volume.get('target') is None:
|
||||
prefix = 'hd' if volume['device'] == 'cdrom' else 'vd'
|
||||
target = diskutils.get_disk_target(targets, prefix)
|
||||
volume['target'] = target
|
||||
targets.append(target)
|
||||
else:
|
||||
targets.append(volume['target'])
|
||||
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
|
||||
if data['cloud_init'] is not None:
|
||||
cloud_init_config = {
|
||||
'user_data': None,
|
||||
'meta_data': None,
|
||||
'vendor_data': None,
|
||||
'network_config': None,
|
||||
}
|
||||
data['cloud_init'] = dictutil.override(
|
||||
cloud_init_config,
|
||||
data['cloud_init'],
|
||||
)
|
||||
for item in data['cloud_init']:
|
||||
cidata = data['cloud_init'][item]
|
||||
if cidata is None:
|
||||
pass
|
||||
elif isinstance(cidata, str):
|
||||
if cidata.startswith('base64:'):
|
||||
data['cloud_init'][item] = base64.b64decode(
|
||||
cidata.split(':')[1]
|
||||
).decode('utf-8')
|
||||
elif re.fullmatch(r'^[^\n]{1,1024}$', cidata, re.I):
|
||||
data_file = pathlib.Path(cidata)
|
||||
if data_file.exists():
|
||||
with data_file.open('r') as f:
|
||||
data['cloud_init'][item] = f.read()
|
||||
else:
|
||||
pass
|
||||
else:
|
||||
data['cloud_init'][item] = yaml.dump(cidata)
|
||||
try:
|
||||
log.debug('Input data: %s', 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()
|
||||
print(f'Started: {instance.name}')
|
||||
except pydantic.ValidationError as e:
|
||||
for error in e.errors():
|
||||
fields = '.'.join([str(lc) for lc in error['loc']])
|
||||
print(
|
||||
f"validation error: {fields}: {error['msg']}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
def exec_(session: Session, args: argparse.Namespace) -> None:
|
||||
"""
|
||||
Execute command in guest via guest agent.
|
||||
|
||||
NOTE: any argument after instance name will be passed into guest's shell
|
||||
"""
|
||||
instance = session.get_instance(args.instance)
|
||||
ga = GuestAgent(instance.domain, timeout=args.timeout)
|
||||
arguments = args.arguments.copy()
|
||||
if len(arguments) > 1 and not args.no_join_args:
|
||||
arguments = [shlex.join(arguments)]
|
||||
if not args.no_join_args:
|
||||
arguments.insert(0, '-c')
|
||||
stdin = None
|
||||
if not sys.stdin.isatty():
|
||||
stdin = sys.stdin.read()
|
||||
try:
|
||||
output = ga.guest_exec(
|
||||
path=args.executable,
|
||||
args=arguments,
|
||||
env=args.env,
|
||||
stdin=stdin,
|
||||
capture_output=True,
|
||||
decode_output=True,
|
||||
poll=True,
|
||||
)
|
||||
except GuestAgentTimeoutExpired as e:
|
||||
sys.exit(
|
||||
f'{e}. NOTE: command may still running in guest, '
|
||||
f'PID={ga.last_pid}'
|
||||
)
|
||||
if output.stderr:
|
||||
print(output.stderr.strip(), file=sys.stderr)
|
||||
if output.stdout:
|
||||
print(output.stdout.strip(), file=sys.stdout)
|
||||
sys.exit(output.exitcode)
|
||||
|
||||
|
||||
def ls(session: Session, args: argparse.Namespace) -> None: # noqa: ARG001
|
||||
"""List compute instances."""
|
||||
table = Table()
|
||||
table.header = ['NAME', 'STATE', 'NVCPUS', 'MEMORY']
|
||||
for instance in session.list_instances():
|
||||
info = instance.get_info()
|
||||
table.add_row(
|
||||
[
|
||||
instance.name,
|
||||
instance.get_status() + ' ',
|
||||
info.nproc,
|
||||
f'{int(info.memory / 1024)} MiB',
|
||||
]
|
||||
)
|
||||
print(table)
|
||||
|
||||
|
||||
def lsdisks(session: Session, args: argparse.Namespace) -> None:
|
||||
"""List block devices attached to instance."""
|
||||
instance = session.get_instance(args.instance)
|
||||
if args.persistent:
|
||||
disks = instance.list_disks(persistent=True)
|
||||
else:
|
||||
disks = instance.list_disks()
|
||||
table = Table()
|
||||
table.header = ['TARGET', 'SOURCE']
|
||||
for disk in disks:
|
||||
table.add_row([disk.target, disk.source])
|
||||
print(table)
|
||||
|
||||
|
||||
def start(session: Session, args: argparse.Namespace) -> None:
|
||||
"""Start instance."""
|
||||
instance = session.get_instance(args.instance)
|
||||
instance.start()
|
||||
|
||||
|
||||
def shutdown(session: Session, args: argparse.Namespace) -> None:
|
||||
"""Shutdown instance."""
|
||||
instance = session.get_instance(args.instance)
|
||||
if args.soft:
|
||||
method = 'SOFT'
|
||||
elif args.hard:
|
||||
method = 'HARD'
|
||||
elif args.unsafe:
|
||||
method = 'UNSAFE'
|
||||
else:
|
||||
method = 'NORMAL'
|
||||
instance.shutdown(method)
|
||||
|
||||
|
||||
def reboot(session: Session, args: argparse.Namespace) -> None:
|
||||
"""Reboot instance."""
|
||||
instance = session.get_instance(args.instance)
|
||||
instance.reboot()
|
||||
|
||||
|
||||
def reset(session: Session, args: argparse.Namespace) -> None:
|
||||
"""Reset instance."""
|
||||
instance = session.get_instance(args.instance)
|
||||
instance.reset()
|
||||
|
||||
|
||||
def powrst(session: Session, args: argparse.Namespace) -> None:
|
||||
"""Power reset instance."""
|
||||
instance = session.get_instance(args.instance)
|
||||
instance.power_reset()
|
||||
|
||||
|
||||
def pause(session: Session, args: argparse.Namespace) -> None:
|
||||
"""Pause instance."""
|
||||
instance = session.get_instance(args.instance)
|
||||
instance.pause()
|
||||
|
||||
|
||||
def resume(session: Session, args: argparse.Namespace) -> None:
|
||||
"""Resume instance."""
|
||||
instance = session.get_instance(args.instance)
|
||||
instance.resume()
|
||||
|
||||
|
||||
def status(session: Session, args: argparse.Namespace) -> None:
|
||||
"""Display instance status."""
|
||||
instance = session.get_instance(args.instance)
|
||||
print(instance.get_status())
|
||||
|
||||
|
||||
def setvcpus(session: Session, args: argparse.Namespace) -> None:
|
||||
"""Set instance vCPU number."""
|
||||
instance = session.get_instance(args.instance)
|
||||
instance.set_vcpus(args.nvcpus, live=True)
|
||||
|
||||
|
||||
def setmem(session: Session, args: argparse.Namespace) -> None:
|
||||
"""Set instance memory size."""
|
||||
instance = session.get_instance(args.instance)
|
||||
instance.set_memory(args.memory, live=True)
|
||||
|
||||
|
||||
def setpass(session: Session, args: argparse.Namespace) -> None:
|
||||
"""Set user password in guest."""
|
||||
instance = session.get_instance(args.instance)
|
||||
instance.set_user_password(
|
||||
args.username,
|
||||
args.password,
|
||||
encrypted=args.encrypted,
|
||||
)
|
||||
|
||||
|
||||
def setcdrom(session: Session, args: argparse.Namespace) -> None:
|
||||
"""Manage CDROM devices."""
|
||||
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
|
||||
disks_live = instance.list_disks(persistent=False)
|
||||
disks_inactive = instance.list_disks(persistent=True)
|
||||
disks = [d.target for d in disks_inactive if d not in disks_live]
|
||||
target = diskutils.get_disk_target(disks, '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 setcloudinit(session: Session, args: argparse.Namespace) -> None:
|
||||
"""
|
||||
Set cloud-init configuration.
|
||||
|
||||
The cloud-init disk must not be mounted to the host system while making
|
||||
changes using this command! In this case, data may be damaged when writing
|
||||
to disk - if the new content of the file is longer than the old one, it
|
||||
will be truncated.
|
||||
"""
|
||||
if (
|
||||
args.user_data is None
|
||||
and args.vendor_data is None
|
||||
and args.network_config is None
|
||||
and args.meta_data is None
|
||||
):
|
||||
sys.exit('nothing to do')
|
||||
instance = session.get_instance(args.instance)
|
||||
disks = instance.list_disks()
|
||||
cloud_init_disk_path = None
|
||||
cloud_init_disk_target = diskutils.get_disk_target(
|
||||
[d.target for d in disks], prefix='vd'
|
||||
)
|
||||
cloud_init = CloudInit()
|
||||
if args.user_data:
|
||||
cloud_init.user_data = args.user_data.read()
|
||||
if args.vendor_data:
|
||||
cloud_init.vendor_data = args.vendor_data.read()
|
||||
if args.network_config:
|
||||
cloud_init.network_config = args.network_config.read()
|
||||
if args.meta_data:
|
||||
cloud_init.meta_data = args.meta_data.read()
|
||||
for disk in disks:
|
||||
if disk.source.endswith('cloud-init.img'):
|
||||
cloud_init_disk_path = disk.source
|
||||
break
|
||||
if cloud_init_disk_path is None:
|
||||
volumes = session.get_storage_pool(session.VOLUMES_POOL)
|
||||
cloud_init_disk_path = volumes.path.joinpath(
|
||||
f'{instance.name}-cloud-init.img'
|
||||
)
|
||||
cloud_init.create_disk(cloud_init_disk_path)
|
||||
volumes.refresh()
|
||||
cloud_init.attach_disk(
|
||||
cloud_init_disk_path,
|
||||
cloud_init_disk_target,
|
||||
instance,
|
||||
)
|
||||
else:
|
||||
cloud_init.update_disk(cloud_init_disk_path)
|
||||
|
||||
|
||||
def delete(session: Session, args: argparse.Namespace) -> None:
|
||||
"""Delete instance with local storage volumes."""
|
||||
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')
|
@ -1,624 +0,0 @@
|
||||
# This file is part of Compute
|
||||
#
|
||||
# Compute is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# 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 Compute. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""Command line interface."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import string
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
import libvirt
|
||||
import yaml
|
||||
from pydantic import ValidationError
|
||||
|
||||
from compute import __version__
|
||||
from compute.exceptions import ComputeError, GuestAgentTimeoutError
|
||||
from compute.instance import GuestAgent, Instance, InstanceSchema
|
||||
from compute.instance.devices import DiskConfig, DiskDriver
|
||||
from compute.session import Session
|
||||
from compute.utils import dictutil, ids
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log_levels = [lv.lower() for lv in logging.getLevelNamesMapping()]
|
||||
|
||||
libvirt.registerErrorHandler(
|
||||
lambda userdata, err: None, # noqa: ARG005
|
||||
ctx=None,
|
||||
)
|
||||
|
||||
|
||||
class Table:
|
||||
"""Minimalistic text table constructor."""
|
||||
|
||||
def __init__(self, whitespace: str | None = None):
|
||||
"""Initialise Table."""
|
||||
self.whitespace = whitespace or '\t'
|
||||
self.header = []
|
||||
self.rows = []
|
||||
self.table = ''
|
||||
|
||||
def add_row(self, row: list) -> None:
|
||||
"""Add table row."""
|
||||
self.rows.append([str(col) for col in row])
|
||||
|
||||
def add_rows(self, rows: list[list]) -> None:
|
||||
"""Add multiple rows."""
|
||||
for row in rows:
|
||||
self.add_row(row)
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Build table and return."""
|
||||
widths = [max(map(len, col)) for col in zip(*self.rows, strict=True)]
|
||||
self.rows.insert(0, [str(h).upper() for h in self.header])
|
||||
for row in self.rows:
|
||||
self.table += self.whitespace.join(
|
||||
(
|
||||
val.ljust(width)
|
||||
for val, width in zip(row, widths, strict=True)
|
||||
)
|
||||
)
|
||||
self.table += '\n'
|
||||
return self.table.strip()
|
||||
|
||||
|
||||
def _list_instances(session: Session) -> None:
|
||||
table = Table()
|
||||
table.header = ['NAME', 'STATE']
|
||||
for instance in session.list_instances():
|
||||
table.add_row(
|
||||
[
|
||||
instance.name,
|
||||
instance.get_status(),
|
||||
]
|
||||
)
|
||||
print(table)
|
||||
sys.exit()
|
||||
|
||||
|
||||
def _exec_guest_agent_command(
|
||||
session: Session, args: argparse.Namespace
|
||||
) -> None:
|
||||
instance = session.get_instance(args.instance)
|
||||
ga = GuestAgent(instance.domain, timeout=args.timeout)
|
||||
arguments = args.arguments.copy()
|
||||
if len(arguments) > 1 and not args.no_join_args:
|
||||
arguments = [shlex.join(arguments)]
|
||||
if not args.no_join_args:
|
||||
arguments.insert(0, '-c')
|
||||
stdin = None
|
||||
if not sys.stdin.isatty():
|
||||
stdin = sys.stdin.read()
|
||||
try:
|
||||
output = ga.guest_exec(
|
||||
path=args.executable,
|
||||
args=arguments,
|
||||
env=args.env,
|
||||
stdin=stdin,
|
||||
capture_output=True,
|
||||
decode_output=True,
|
||||
poll=True,
|
||||
)
|
||||
except GuestAgentTimeoutError as e:
|
||||
sys.exit(
|
||||
f'{e}. NOTE: command may still running in guest, '
|
||||
f'PID={ga.last_pid}'
|
||||
)
|
||||
if output.stderr:
|
||||
print(output.stderr.strip(), file=sys.stderr)
|
||||
if output.stdout:
|
||||
print(output.stdout.strip(), file=sys.stdout)
|
||||
sys.exit(output.exitcode)
|
||||
|
||||
|
||||
def _init_instance(session: Session, args: argparse.Namespace) -> None:
|
||||
try:
|
||||
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()
|
||||
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 = 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)
|
||||
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']])
|
||||
print(
|
||||
f"validation error: {fields}: {error['msg']}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit()
|
||||
|
||||
|
||||
def _shutdown_instance(session: Session, args: argparse.Namespace) -> None:
|
||||
instance = session.get_instance(args.instance)
|
||||
if args.soft:
|
||||
method = 'SOFT'
|
||||
elif args.hard:
|
||||
method = 'HARD'
|
||||
elif args.unsafe:
|
||||
method = 'UNSAFE'
|
||||
else:
|
||||
method = 'NORMAL'
|
||||
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':
|
||||
_init_instance(session, args)
|
||||
case 'exec':
|
||||
_exec_guest_agent_command(session, args)
|
||||
case 'ls':
|
||||
_list_instances(session)
|
||||
case 'start':
|
||||
instance = session.get_instance(args.instance)
|
||||
instance.start()
|
||||
case 'shutdown':
|
||||
_shutdown_instance(session, args)
|
||||
case 'reboot':
|
||||
instance = session.get_instance(args.instance)
|
||||
instance.reboot()
|
||||
case 'reset':
|
||||
instance = session.get_instance(args.instance)
|
||||
instance.reset()
|
||||
case 'powrst':
|
||||
instance = session.get_instance(args.instance)
|
||||
instance.power_reset()
|
||||
case 'pause':
|
||||
instance = session.get_instance(args.instance)
|
||||
instance.pause()
|
||||
case 'resume':
|
||||
instance = session.get_instance(args.instance)
|
||||
instance.resume()
|
||||
case 'status':
|
||||
instance = session.get_instance(args.instance)
|
||||
print(instance.status)
|
||||
case 'setvcpus':
|
||||
instance = session.get_instance(args.instance)
|
||||
instance.set_vcpus(args.nvcpus, live=True)
|
||||
case 'setmem':
|
||||
instance = session.get_instance(args.instance)
|
||||
instance.set_memory(args.memory, live=True)
|
||||
case 'setpass':
|
||||
instance = session.get_instance(args.instance)
|
||||
instance.set_user_password(
|
||||
args.username,
|
||||
args.password,
|
||||
encrypted=args.encrypted,
|
||||
)
|
||||
case 'setcdrom':
|
||||
_manage_cdrom(session, args)
|
||||
case 'delete':
|
||||
_delete_instance(session, args)
|
||||
|
||||
|
||||
def get_parser() -> argparse.ArgumentParser: # noqa: PLR0915
|
||||
"""Return command line arguments parser."""
|
||||
root = argparse.ArgumentParser(
|
||||
prog='compute',
|
||||
description='Manage compute instances.',
|
||||
formatter_class=argparse.RawTextHelpFormatter,
|
||||
)
|
||||
root.add_argument(
|
||||
'-v',
|
||||
'--verbose',
|
||||
action='store_true',
|
||||
default=False,
|
||||
help='enable verbose mode',
|
||||
)
|
||||
root.add_argument(
|
||||
'-c',
|
||||
'--connect',
|
||||
metavar='URI',
|
||||
help='libvirt connection URI',
|
||||
)
|
||||
root.add_argument(
|
||||
'-l',
|
||||
'--log-level',
|
||||
type=str.lower,
|
||||
metavar='LEVEL',
|
||||
choices=log_levels,
|
||||
help='log level',
|
||||
)
|
||||
root.add_argument(
|
||||
'-V',
|
||||
'--version',
|
||||
action='version',
|
||||
version=__version__,
|
||||
)
|
||||
subparsers = root.add_subparsers(dest='command', metavar='COMMAND')
|
||||
|
||||
# init command
|
||||
init = subparsers.add_parser(
|
||||
'init', help='initialise instance using YAML config file'
|
||||
)
|
||||
init.add_argument(
|
||||
'file',
|
||||
type=argparse.FileType('r', encoding='UTF-8'),
|
||||
nargs='?',
|
||||
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.'
|
||||
),
|
||||
)
|
||||
execute.add_argument('instance')
|
||||
execute.add_argument('arguments', nargs=argparse.REMAINDER)
|
||||
execute.add_argument(
|
||||
'-t',
|
||||
'--timeout',
|
||||
type=int,
|
||||
default=60,
|
||||
help=(
|
||||
'waiting time in seconds for a command to be executed '
|
||||
'in guest [default: 60]'
|
||||
),
|
||||
)
|
||||
execute.add_argument(
|
||||
'-x',
|
||||
'--executable',
|
||||
default='/bin/sh',
|
||||
help='path to executable in guest [default: /bin/sh]',
|
||||
)
|
||||
execute.add_argument(
|
||||
'-e',
|
||||
'--env',
|
||||
type=str,
|
||||
nargs='?',
|
||||
action='append',
|
||||
help='environment variables to pass to executable in guest',
|
||||
)
|
||||
execute.add_argument(
|
||||
'-n',
|
||||
'--no-join-args',
|
||||
action='store_true',
|
||||
default=False,
|
||||
help=(
|
||||
"do not join arguments list and add '-c' option, suitable "
|
||||
'for non-shell executables and other specific cases.'
|
||||
),
|
||||
)
|
||||
|
||||
# ls subcommand
|
||||
listall = subparsers.add_parser('ls', help='list instances')
|
||||
listall.add_argument(
|
||||
'-a',
|
||||
'--all',
|
||||
action='store_true',
|
||||
default=False,
|
||||
help='list all instances including inactive',
|
||||
)
|
||||
|
||||
# start subcommand
|
||||
start = subparsers.add_parser('start', help='start instance')
|
||||
start.add_argument('instance')
|
||||
|
||||
# shutdown subcommand
|
||||
shutdown = subparsers.add_parser('shutdown', help='shutdown instance')
|
||||
shutdown.add_argument('instance')
|
||||
shutdown_opts = shutdown.add_mutually_exclusive_group()
|
||||
shutdown_opts.add_argument(
|
||||
'-s',
|
||||
'--soft',
|
||||
action='store_true',
|
||||
help='normal guest OS shutdown, guest agent is used',
|
||||
)
|
||||
shutdown_opts.add_argument(
|
||||
'-n',
|
||||
'--normal',
|
||||
action='store_true',
|
||||
help='shutdown with hypervisor selected method [default]',
|
||||
)
|
||||
shutdown_opts.add_argument(
|
||||
'-H',
|
||||
'--hard',
|
||||
action='store_true',
|
||||
help=(
|
||||
"gracefully destroy instance, it's like long "
|
||||
'pressing the power button'
|
||||
),
|
||||
)
|
||||
shutdown_opts.add_argument(
|
||||
'-u',
|
||||
'--unsafe',
|
||||
action='store_true',
|
||||
help=(
|
||||
'destroy instance, this is similar to a power outage '
|
||||
'and may result in data loss or corruption'
|
||||
),
|
||||
)
|
||||
|
||||
# reboot subcommand
|
||||
reboot = subparsers.add_parser('reboot', help='reboot instance')
|
||||
reboot.add_argument('instance')
|
||||
|
||||
# reset subcommand
|
||||
reset = subparsers.add_parser('reset', help='reset instance')
|
||||
reset.add_argument('instance')
|
||||
|
||||
# powrst subcommand
|
||||
powrst = subparsers.add_parser('powrst', help='power reset instance')
|
||||
powrst.add_argument('instance')
|
||||
|
||||
# pause subcommand
|
||||
pause = subparsers.add_parser('pause', help='pause instance')
|
||||
pause.add_argument('instance')
|
||||
|
||||
# resume subcommand
|
||||
resume = subparsers.add_parser('resume', help='resume paused instance')
|
||||
resume.add_argument('instance')
|
||||
|
||||
# status subcommand
|
||||
status = subparsers.add_parser('status', help='display instance status')
|
||||
status.add_argument('instance')
|
||||
|
||||
# setvcpus subcommand
|
||||
setvcpus = subparsers.add_parser('setvcpus', help='set vCPU number')
|
||||
setvcpus.add_argument('instance')
|
||||
setvcpus.add_argument('nvcpus', type=int)
|
||||
|
||||
# setmem subcommand
|
||||
setmem = subparsers.add_parser('setmem', help='set memory size')
|
||||
setmem.add_argument('instance')
|
||||
setmem.add_argument('memory', type=int, help='memory in MiB')
|
||||
|
||||
# setpass subcommand
|
||||
setpass = subparsers.add_parser(
|
||||
'setpass',
|
||||
help='set user password in guest',
|
||||
)
|
||||
setpass.add_argument('instance')
|
||||
setpass.add_argument('username')
|
||||
setpass.add_argument('password')
|
||||
setpass.add_argument(
|
||||
'-e',
|
||||
'--encrypted',
|
||||
action='store_true',
|
||||
default=False,
|
||||
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)
|
||||
except ComputeError as e:
|
||||
sys.exit(f'error: {e}')
|
||||
except KeyboardInterrupt:
|
||||
sys.exit()
|
||||
except SystemExit as e:
|
||||
sys.exit(e)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
cli()
|
471
compute/cli/parser.py
Normal file
471
compute/cli/parser.py
Normal file
@ -0,0 +1,471 @@
|
||||
# This file is part of Compute
|
||||
#
|
||||
# Compute is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# 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 Compute. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""Command line argument parser."""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import textwrap
|
||||
from collections.abc import Callable
|
||||
from typing import NamedTuple
|
||||
|
||||
from compute import Session, __version__
|
||||
from compute.cli import commands
|
||||
from compute.exceptions import ComputeError
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log_levels = [lv.lower() for lv in logging.getLevelNamesMapping()]
|
||||
|
||||
|
||||
class Doc(NamedTuple):
|
||||
"""Parsed docstring."""
|
||||
|
||||
help: str # noqa: A003
|
||||
desc: str
|
||||
|
||||
|
||||
def get_doc(func: Callable) -> Doc:
|
||||
"""Extract help message and description from function docstring."""
|
||||
doc = func.__doc__
|
||||
if isinstance(doc, str):
|
||||
doc = textwrap.dedent(doc).strip().split('\n\n')
|
||||
return Doc(doc[0][0].lower() + doc[0][1:], '\n\n'.join(doc))
|
||||
return Doc('', '')
|
||||
|
||||
|
||||
def get_parser() -> argparse.ArgumentParser:
|
||||
"""Return command line argument parser."""
|
||||
root = argparse.ArgumentParser(
|
||||
prog='compute',
|
||||
description='Manage compute instances.',
|
||||
)
|
||||
root.add_argument(
|
||||
'-V',
|
||||
'--version',
|
||||
action='version',
|
||||
version=__version__,
|
||||
)
|
||||
root.add_argument(
|
||||
'-c',
|
||||
'--connect',
|
||||
dest='root_connect',
|
||||
metavar='URI',
|
||||
help='libvirt connection URI',
|
||||
)
|
||||
root.add_argument(
|
||||
'-l',
|
||||
'--log-level',
|
||||
dest='root_log_level',
|
||||
type=str.lower,
|
||||
choices=log_levels,
|
||||
metavar='LEVEL',
|
||||
help='log level',
|
||||
)
|
||||
|
||||
# common options
|
||||
common = argparse.ArgumentParser(add_help=False)
|
||||
common.add_argument(
|
||||
'-c',
|
||||
'--connect',
|
||||
metavar='URI',
|
||||
help='libvirt connection URI',
|
||||
)
|
||||
common.add_argument(
|
||||
'-l',
|
||||
'--log-level',
|
||||
type=str.lower,
|
||||
choices=log_levels,
|
||||
metavar='LEVEL',
|
||||
help='log level',
|
||||
)
|
||||
|
||||
subparsers = root.add_subparsers(dest='command', metavar='COMMAND')
|
||||
|
||||
# init command
|
||||
init = subparsers.add_parser(
|
||||
'init',
|
||||
parents=[common],
|
||||
formatter_class=argparse.RawTextHelpFormatter,
|
||||
help=get_doc(commands.init).help,
|
||||
description=get_doc(commands.init).desc,
|
||||
)
|
||||
init.add_argument(
|
||||
'file',
|
||||
type=argparse.FileType('r', encoding='UTF-8'),
|
||||
nargs='?',
|
||||
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',
|
||||
)
|
||||
init.set_defaults(func=commands.init)
|
||||
|
||||
# exec subcommand
|
||||
execute = subparsers.add_parser(
|
||||
'exec',
|
||||
parents=[common],
|
||||
formatter_class=argparse.RawTextHelpFormatter,
|
||||
help=get_doc(commands.exec_).help,
|
||||
description=get_doc(commands.exec_).desc,
|
||||
)
|
||||
execute.add_argument('instance')
|
||||
execute.add_argument('arguments', nargs=argparse.REMAINDER)
|
||||
execute.add_argument(
|
||||
'-t',
|
||||
'--timeout',
|
||||
type=int,
|
||||
default=60,
|
||||
help=(
|
||||
'waiting time in seconds for a command to be executed '
|
||||
'in guest [default: 60]'
|
||||
),
|
||||
)
|
||||
execute.add_argument(
|
||||
'-x',
|
||||
'--executable',
|
||||
default='/bin/sh',
|
||||
help='path to executable in guest [default: /bin/sh]',
|
||||
)
|
||||
execute.add_argument(
|
||||
'-e',
|
||||
'--env',
|
||||
type=str,
|
||||
nargs='?',
|
||||
action='append',
|
||||
help='environment variables to pass to executable in guest',
|
||||
)
|
||||
execute.add_argument(
|
||||
'-n',
|
||||
'--no-join-args',
|
||||
action='store_true',
|
||||
default=False,
|
||||
help=(
|
||||
"do not join arguments list and add '-c' option, suitable "
|
||||
'for non-shell executables and other specific cases.'
|
||||
),
|
||||
)
|
||||
execute.set_defaults(func=commands.exec_)
|
||||
|
||||
# ls subcommand
|
||||
ls = subparsers.add_parser(
|
||||
'ls',
|
||||
parents=[common],
|
||||
formatter_class=argparse.RawTextHelpFormatter,
|
||||
help=get_doc(commands.ls).help,
|
||||
description=get_doc(commands.ls).desc,
|
||||
)
|
||||
ls.set_defaults(func=commands.ls)
|
||||
|
||||
# lsdisks subcommand
|
||||
lsdisks = subparsers.add_parser(
|
||||
'lsdisks',
|
||||
parents=[common],
|
||||
formatter_class=argparse.RawTextHelpFormatter,
|
||||
help=get_doc(commands.lsdisks).help,
|
||||
description=get_doc(commands.lsdisks).desc,
|
||||
)
|
||||
lsdisks.add_argument('instance')
|
||||
lsdisks.add_argument(
|
||||
'-p',
|
||||
'--persistent',
|
||||
action='store_true',
|
||||
default=False,
|
||||
help='display only persisnent devices',
|
||||
)
|
||||
lsdisks.set_defaults(func=commands.lsdisks)
|
||||
|
||||
# start subcommand
|
||||
start = subparsers.add_parser(
|
||||
'start',
|
||||
parents=[common],
|
||||
formatter_class=argparse.RawTextHelpFormatter,
|
||||
help=get_doc(commands.start).help,
|
||||
description=get_doc(commands.start).desc,
|
||||
)
|
||||
start.add_argument('instance')
|
||||
start.set_defaults(func=commands.start)
|
||||
|
||||
# shutdown subcommand
|
||||
shutdown = subparsers.add_parser(
|
||||
'shutdown',
|
||||
parents=[common],
|
||||
formatter_class=argparse.RawTextHelpFormatter,
|
||||
help=get_doc(commands.shutdown).help,
|
||||
description=get_doc(commands.shutdown).desc,
|
||||
)
|
||||
shutdown.add_argument('instance')
|
||||
shutdown_opts = shutdown.add_mutually_exclusive_group()
|
||||
shutdown_opts.add_argument(
|
||||
'-s',
|
||||
'--soft',
|
||||
action='store_true',
|
||||
help='normal guest OS shutdown, guest agent is used',
|
||||
)
|
||||
shutdown_opts.add_argument(
|
||||
'-n',
|
||||
'--normal',
|
||||
action='store_true',
|
||||
help='shutdown with hypervisor selected method [default]',
|
||||
)
|
||||
shutdown_opts.add_argument(
|
||||
'-H',
|
||||
'--hard',
|
||||
action='store_true',
|
||||
help=(
|
||||
"gracefully destroy instance, it's like long "
|
||||
'pressing the power button'
|
||||
),
|
||||
)
|
||||
shutdown_opts.add_argument(
|
||||
'-u',
|
||||
'--unsafe',
|
||||
action='store_true',
|
||||
help=(
|
||||
'destroy instance, this is similar to a power outage '
|
||||
'and may result in data loss or corruption'
|
||||
),
|
||||
)
|
||||
shutdown.set_defaults(func=commands.shutdown)
|
||||
|
||||
# reboot subcommand
|
||||
reboot = subparsers.add_parser(
|
||||
'reboot',
|
||||
parents=[common],
|
||||
formatter_class=argparse.RawTextHelpFormatter,
|
||||
help=get_doc(commands.reboot).help,
|
||||
description=get_doc(commands.reboot).desc,
|
||||
)
|
||||
reboot.add_argument('instance')
|
||||
reboot.set_defaults(func=commands.reboot)
|
||||
|
||||
# reset subcommand
|
||||
reset = subparsers.add_parser(
|
||||
'reset',
|
||||
parents=[common],
|
||||
formatter_class=argparse.RawTextHelpFormatter,
|
||||
help=get_doc(commands.reset).help,
|
||||
description=get_doc(commands.reset).desc,
|
||||
)
|
||||
reset.add_argument('instance')
|
||||
reset.set_defaults(func=commands.reset)
|
||||
|
||||
# powrst subcommand
|
||||
powrst = subparsers.add_parser(
|
||||
'powrst',
|
||||
parents=[common],
|
||||
formatter_class=argparse.RawTextHelpFormatter,
|
||||
help=get_doc(commands.powrst).help,
|
||||
description=get_doc(commands.powrst).desc,
|
||||
)
|
||||
powrst.add_argument('instance')
|
||||
powrst.set_defaults(func=commands.powrst)
|
||||
|
||||
# pause subcommand
|
||||
pause = subparsers.add_parser(
|
||||
'pause',
|
||||
parents=[common],
|
||||
formatter_class=argparse.RawTextHelpFormatter,
|
||||
help=get_doc(commands.pause).help,
|
||||
description=get_doc(commands.pause).desc,
|
||||
)
|
||||
pause.add_argument('instance')
|
||||
pause.set_defaults(func=commands.pause)
|
||||
|
||||
# resume subcommand
|
||||
resume = subparsers.add_parser(
|
||||
'resume',
|
||||
parents=[common],
|
||||
formatter_class=argparse.RawTextHelpFormatter,
|
||||
help=get_doc(commands.resume).help,
|
||||
description=get_doc(commands.resume).desc,
|
||||
)
|
||||
resume.add_argument('instance')
|
||||
resume.set_defaults(func=commands.resume)
|
||||
|
||||
# status subcommand
|
||||
status = subparsers.add_parser(
|
||||
'status',
|
||||
parents=[common],
|
||||
formatter_class=argparse.RawTextHelpFormatter,
|
||||
help=get_doc(commands.status).help,
|
||||
description=get_doc(commands.status).desc,
|
||||
)
|
||||
status.add_argument('instance')
|
||||
status.set_defaults(func=commands.status)
|
||||
|
||||
# setvcpus subcommand
|
||||
setvcpus = subparsers.add_parser(
|
||||
'setvcpus',
|
||||
parents=[common],
|
||||
formatter_class=argparse.RawTextHelpFormatter,
|
||||
help=get_doc(commands.setvcpus).help,
|
||||
description=get_doc(commands.setvcpus).desc,
|
||||
)
|
||||
setvcpus.add_argument('instance')
|
||||
setvcpus.add_argument('nvcpus', type=int)
|
||||
setvcpus.set_defaults(func=commands.setvcpus)
|
||||
|
||||
# setmem subcommand
|
||||
setmem = subparsers.add_parser(
|
||||
'setmem',
|
||||
parents=[common],
|
||||
formatter_class=argparse.RawTextHelpFormatter,
|
||||
help=get_doc(commands.setmem).help,
|
||||
description=get_doc(commands.setmem).desc,
|
||||
)
|
||||
setmem.add_argument('instance')
|
||||
setmem.add_argument('memory', type=int, help='memory in MiB')
|
||||
setmem.set_defaults(func=commands.setmem)
|
||||
|
||||
# setpass subcommand
|
||||
setpass = subparsers.add_parser(
|
||||
'setpass',
|
||||
parents=[common],
|
||||
formatter_class=argparse.RawTextHelpFormatter,
|
||||
help=get_doc(commands.setpass).help,
|
||||
description=get_doc(commands.setpass).desc,
|
||||
)
|
||||
setpass.add_argument('instance')
|
||||
setpass.add_argument('username')
|
||||
setpass.add_argument('password')
|
||||
setpass.add_argument(
|
||||
'-e',
|
||||
'--encrypted',
|
||||
action='store_true',
|
||||
default=False,
|
||||
help='set it if password is already encrypted',
|
||||
)
|
||||
setpass.set_defaults(func=commands.setpass)
|
||||
|
||||
# setcdrom subcommand
|
||||
setcdrom = subparsers.add_parser(
|
||||
'setcdrom',
|
||||
parents=[common],
|
||||
formatter_class=argparse.RawTextHelpFormatter,
|
||||
help=get_doc(commands.setcdrom).help,
|
||||
description=get_doc(commands.setcdrom).desc,
|
||||
)
|
||||
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',
|
||||
)
|
||||
setcdrom.set_defaults(func=commands.setcdrom)
|
||||
|
||||
# setcloudinit subcommand
|
||||
setcloudinit = subparsers.add_parser(
|
||||
'setcloudinit',
|
||||
parents=[common],
|
||||
formatter_class=argparse.RawTextHelpFormatter,
|
||||
help=get_doc(commands.setcloudinit).help,
|
||||
description=get_doc(commands.setcloudinit).desc,
|
||||
)
|
||||
setcloudinit.add_argument('instance')
|
||||
setcloudinit.add_argument(
|
||||
'--user-data',
|
||||
type=argparse.FileType('r'),
|
||||
help='user-data file',
|
||||
)
|
||||
setcloudinit.add_argument(
|
||||
'--vendor-data',
|
||||
type=argparse.FileType('r'),
|
||||
help='vendor-data file',
|
||||
)
|
||||
setcloudinit.add_argument(
|
||||
'--meta-data',
|
||||
type=argparse.FileType('r'),
|
||||
help='meta-data file',
|
||||
)
|
||||
setcloudinit.add_argument(
|
||||
'--network-config',
|
||||
type=argparse.FileType('r'),
|
||||
help='network-config file',
|
||||
)
|
||||
setcloudinit.set_defaults(func=commands.setcloudinit)
|
||||
|
||||
# delete subcommand
|
||||
delete = subparsers.add_parser(
|
||||
'delete',
|
||||
parents=[common],
|
||||
formatter_class=argparse.RawTextHelpFormatter,
|
||||
help=get_doc(commands.delete).help,
|
||||
description=get_doc(commands.delete).desc,
|
||||
)
|
||||
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',
|
||||
)
|
||||
delete.set_defaults(func=commands.delete)
|
||||
|
||||
return root
|
||||
|
||||
|
||||
def run() -> None:
|
||||
"""Run argument parser."""
|
||||
parser = get_parser()
|
||||
args = parser.parse_args()
|
||||
if args.command is None:
|
||||
parser.print_help()
|
||||
sys.exit()
|
||||
log_level = args.root_log_level or 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.root_connect
|
||||
or args.connect
|
||||
or os.getenv('CMP_LIBVIRT_URI')
|
||||
or os.getenv('LIBVIRT_DEFAULT_URI')
|
||||
or 'qemu:///system'
|
||||
)
|
||||
try:
|
||||
with Session(connect_uri) as session:
|
||||
# Invoke command
|
||||
args.func(session, args)
|
||||
except ComputeError as e:
|
||||
sys.exit(f'error: {e}')
|
||||
except KeyboardInterrupt:
|
||||
sys.exit()
|
77
compute/cli/term.py
Normal file
77
compute/cli/term.py
Normal file
@ -0,0 +1,77 @@
|
||||
# This file is part of Compute
|
||||
#
|
||||
# Compute is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# 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 Compute. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""Utils for creating terminal output and interface elements."""
|
||||
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
class Table:
|
||||
"""Minimalistic text table constructor."""
|
||||
|
||||
def __init__(self, whitespace: str | None = None):
|
||||
"""Initialise Table."""
|
||||
self.whitespace = whitespace or '\t'
|
||||
self.header = []
|
||||
self.rows = []
|
||||
self.table = ''
|
||||
|
||||
def add_row(self, row: list) -> None:
|
||||
"""Add table row."""
|
||||
self.rows.append([str(col) for col in row])
|
||||
|
||||
def add_rows(self, rows: list[list]) -> None:
|
||||
"""Add multiple rows."""
|
||||
for row in rows:
|
||||
self.add_row(row)
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Return table."""
|
||||
widths = [max(map(len, col)) for col in zip(*self.rows, strict=True)]
|
||||
self.rows.insert(0, [str(h).upper() for h in self.header])
|
||||
for row in self.rows:
|
||||
widths = widths or [len(i) for i in row]
|
||||
self.table += self.whitespace.join(
|
||||
(
|
||||
val.ljust(width)
|
||||
for val, width in zip(row, widths, strict=True)
|
||||
)
|
||||
)
|
||||
self.table += '\n'
|
||||
return self.table.strip()
|
||||
|
||||
|
||||
def confirm(message: str, *, default: bool | None = None) -> None:
|
||||
"""Start yes/no interactive dialog."""
|
||||
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'")
|
Reference in New Issue
Block a user