Skip to content

Commit 8e6fcbd

Browse files
committed
Add testcloud provisioner
This MR adds provisioning of qcow2 images via testcloud. To test with a qcow2 from url: tmt run -a provision -h testcloud --image https://kojipkgs.fedoraproject.org/compose/branched/latest-Fedora-32/compose/Cloud/x86_64/images/Fedora-Cloud-Base-32-20200301.n.0.x86_64.qcow2 To test with a local qcow2: tmt run -a provision -h testcloud --image file:///home/user/fedora.qcow2 To test with latest Rawhide: tmt run -a provision -h testcloud --image fedora tmt run -a provision -h testcloud --image rawhide tmt run -a provision -h testcloud --image fedora-rawhide Other mappings will come later, as it is a bit PITA to do .... Signed-off-by: Miroslav Vadkerti <mvadkert@redhat.com>
1 parent 0aa5d09 commit 8e6fcbd

File tree

5 files changed

+227
-2
lines changed

5 files changed

+227
-2
lines changed

.travis.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ python:
33
- "3.6"
44
- "3.7"
55
before_install:
6-
- "pip install -U pip setuptools virtualenv python-coveralls fmf click mock"
6+
- "pip install -U pip setuptools virtualenv python-coveralls fmf click mock testcloud"
77
- "sudo apt-get -y install vagrant"
88
script:
99
- "coverage run --source=bin,tmt -m py.test $CAPTURE tests"

setup.py

+1
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
install_requires = [
3434
'fmf>=0.9.2',
3535
'click',
36+
'testcloud'
3637
]
3738
extras_require = {
3839
'docs': ['sphinx', 'sphinx_rtd_theme'],

tmt.spec

+9
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,15 @@ Requires: ansible podman
6363
All dependencies of the Test Management Tool required to run tests
6464
in a container environment.
6565

66+
%package testcloud
67+
Summary: Libvirt (via testcloud) provisioner for the Test Management Tool
68+
Requires: tmt == %{version}-%{release}
69+
Requires: ansible testcloud
70+
71+
%description testcloud
72+
All dependencies of the Test Management Tool required to run tests
73+
in a libvirt environment provisioned using testcloud.
74+
6675
%package all
6776
Summary: Extra dependencies for the Test Management Tool
6877
Requires: tmt == %{version}-%{release}

tmt/steps/provision/__init__.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from click import echo
99

1010
from tmt.utils import SpecificationError
11-
from tmt.steps.provision import vagrant, localhost, podman
11+
from tmt.steps.provision import vagrant, localhost, podman, testcloud
1212

1313

1414
class Provision(tmt.steps.Step):
@@ -24,6 +24,7 @@ class Provision(tmt.steps.Step):
2424
'localhost': localhost.ProvisionLocalhost,
2525
'container': podman.ProvisionPodman,
2626
'podman': podman.ProvisionPodman,
27+
'testcloud': testcloud.ProvisionTestcloud
2728
}
2829

2930
# Default implementation for provision is a virtual machine

tmt/steps/provision/testcloud.py

+214
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import os
2+
import re
3+
4+
import requests
5+
import testcloud.image
6+
import testcloud.instance
7+
8+
from tmt.steps.provision.base import ProvisionBase
9+
from tmt.utils import GeneralError, SpecificationError
10+
11+
12+
# userdata for cloud-init
13+
#
14+
CLOUDINIT_USER_DATA = """#cloud-config
15+
password: %s
16+
chpasswd:
17+
expire: false
18+
users:
19+
- default
20+
- name: {user_name}
21+
ssh_authorized_keys:
22+
- {public_key}
23+
ssh_pwauth: true
24+
disable_root: false
25+
runcmd:
26+
- sed -i -e '/^.*PermitRootLogin/s/^.*$/PermitRootLogin yes/'
27+
/etc/ssh/sshd_config
28+
- systemctl reload sshd
29+
"""
30+
31+
DEFAULT_MEMORY = 2048 # MiB
32+
DEFAULT_DISK_SIZE = 10 # GiB
33+
DEFAULT_USER = 'root'
34+
DEFAULT_BOOT_TIMEOUT = 60 # seconds
35+
36+
KOJI_URL = 'https://kojipkgs.fedoraproject.org/compose'
37+
38+
RAWHIDE_URL = f'{KOJI_URL}/rawhide/latest-Fedora-Rawhide'
39+
RAWHIDE_ID = f'{RAWHIDE_URL}/COMPOSE_ID'
40+
RAWHIDE_IMAGE_URL = f'{RAWHIDE_URL}/compose/Cloud/x86_64/images'
41+
42+
43+
def guess_image_url(name):
44+
""" Guess image url for given name """
45+
46+
def get_compose_id(compose_id_url):
47+
response = requests.get(f'{compose_id_url}')
48+
49+
if not response:
50+
raise GeneralError(
51+
f'Failed to find compose ID for '
52+
f"'{name}' at '{compose_id_url}'")
53+
54+
return response.text
55+
56+
# map fedora, rawhide or fedora-rawhide to latest rawhide image
57+
if re.match(r'^(fedora|fedora-rawhide|rawhide)$', name, re.IGNORECASE):
58+
compose_id = get_compose_id(RAWHIDE_ID)
59+
compose_name = compose_id.replace(
60+
'Fedora-Rawhide', 'Fedora-Cloud-Base-Rawhide')
61+
return f'{RAWHIDE_IMAGE_URL}/{compose_name}.x86_64.qcow2'
62+
63+
raise GeneralError("Could not map '{name}' to compose")
64+
65+
66+
class ProvisionTestcloud(ProvisionBase):
67+
""" Testcloud Provisioner """
68+
def __init__(self, data, step):
69+
super(ProvisionTestcloud, self).__init__(data, step)
70+
71+
self._prepare_map = {
72+
'ansible': self._prepare_ansible,
73+
'shell': self._prepare_shell,
74+
}
75+
76+
# Get image from provision options
77+
if not self.option('image'):
78+
raise GeneralError('No image specified')
79+
80+
# Initialize testcloud image
81+
self.image = None
82+
83+
# Testcloud instance and ip
84+
self.instance = None
85+
self.ip = None
86+
87+
# Default user
88+
self.user = self.option('user') or DEFAULT_USER
89+
90+
# Create ssh key
91+
self.ssh_key = os.path.join(self.provision_dir, 'id_rsa')
92+
self.ssh_pubkey = os.path.join(self.provision_dir, 'id_rsa.pub')
93+
94+
def option(self, key):
95+
""" Return option specified on command line """
96+
# Consider command line as priority
97+
if self.opt(key):
98+
return self.opt(key)
99+
100+
return self.data.get(key, None)
101+
102+
def go(self):
103+
super(ProvisionTestcloud, self).go()
104+
105+
# If image does not start with http/https/file, consider it a mapping
106+
# value and try to guess the URL
107+
image_url = self.option('image')
108+
109+
if not re.match(r'^(?:https?|file)://.*', image_url):
110+
image_url = guess_image_url(image_url)
111+
112+
# Initialize testcloud image
113+
self.image = testcloud.image.Image(image_url)
114+
115+
# Show which image we are using
116+
self.info('image', f'{self.image.name}', 'green')
117+
118+
status = f'{self.image.name}'
119+
if not os.path.exists(self.image.local_path):
120+
self.info('status', 'downloading', 'green')
121+
122+
config = testcloud.config.get_config()
123+
config.DOWNLOAD_PROGRESS = False
124+
125+
# prepare testcloud image
126+
try:
127+
self.image.prepare()
128+
except FileNotFoundError:
129+
raise GeneralError(
130+
f"Could not find image '{self.image.local_path}'")
131+
132+
self.instance = testcloud.instance.Instance(
133+
self.instance_name, image=self.image)
134+
135+
# generate ssh key
136+
self.run(f'ssh-keygen -f {self.ssh_key} -N ""')
137+
138+
with open(self.ssh_pubkey, 'r') as pubkey:
139+
config.USER_DATA = CLOUDINIT_USER_DATA.format(
140+
user_name=self.user, public_key=pubkey.read())
141+
142+
config.DOWNLOAD_PROGRESS = False
143+
144+
self.info('status', 'booting', 'green')
145+
self.instance.ram = self.option('memory') or DEFAULT_MEMORY
146+
self.instance.disk_size = DEFAULT_DISK_SIZE
147+
self.instance.prepare()
148+
self.instance.spawn_vm()
149+
150+
try:
151+
self.instance.start(DEFAULT_BOOT_TIMEOUT)
152+
except testcloud.exceptions.TestcloudInstanceError:
153+
# TODO: find out how to get detailed information about boot problem
154+
raise GeneralError('Failed to boot instance')
155+
156+
self.ip = self.instance.get_ip()
157+
self.instance.create_ip_file(self.ip)
158+
159+
self.info('instance', f'{self.user}@{self.instance.get_ip()}', 'green')
160+
161+
def _ssh_run(self, command, **kwargs):
162+
return self.run(
163+
f'ssh -i {self.ssh_key} {self.user}@{self.instance.get_ip()} '
164+
f'{command}', **kwargs)[0].rstrip()
165+
166+
def execute(self, *args, **kwargs):
167+
if not self.instance:
168+
raise GeneralError(
169+
'Could not execute without provisioned VM')
170+
171+
self.info('args', self.join(args), 'red')
172+
self._ssh_run(f'{self.join(args)}')
173+
174+
def _prepare_ansible(self, what):
175+
""" Prepare using ansible """
176+
# Playbook paths should be relative to the metadata tree root
177+
playbook = os.path.join(self.step.plan.run.tree.root, what)
178+
# Set collumns to 80 characters while running ansible
179+
self.run(
180+
f'stty cols 80; ansible-playbook --ssh-common-args='
181+
f'"-o StrictHostKeyChecking=no -i {self.ssh_key}" '
182+
f'-e ansible_python_interpreter=auto '
183+
f'-v -i {self.user}@{self.ip}, {playbook}')
184+
185+
def _prepare_shell(self, what):
186+
""" Prepare using shell """
187+
# Set current working directory to the test metadata root
188+
self.info('preparing shell')
189+
self.execute(what, cwd=self.step.plan.run.tree.root)
190+
191+
def prepare(self, how, what):
192+
""" Run prepare phase """
193+
try:
194+
self._prepare_map[how](what)
195+
except AttributeError as e:
196+
raise SpecificationError(
197+
f"Prepare method '{how}' is not supported.")
198+
199+
def sync_workdir_to_guest(self):
200+
""" sync on demand """
201+
self.run(
202+
f'rsync -Rvaze "ssh -i {self.ssh_key}" '
203+
f'{self.step.plan.workdir} {self.user}@{self.ip}:/')
204+
205+
def sync_workdir_from_guest(self):
206+
""" sync from guest to host """
207+
self.run(
208+
f'rsync -Rvaze "ssh -i {self.ssh_key}" '
209+
f'{self.user}@{self.ip}:{self.step.plan.workdir} /')
210+
211+
def destroy(self):
212+
""" Remove the container """
213+
self.instance.stop()
214+
self.instance.remove()

0 commit comments

Comments
 (0)