Skip to content

Commit

Permalink
tests: Enable the NoCloud KVM platform
Browse files Browse the repository at this point in the history
The NoCloud KVM platform includes:

  * Downloads daily Ubuntu images using streams and store in
    /srv/images
  * Image customization, if required, is done using
    mount-image-callback otherwise image is untouched
  * Launches KVM via the xkvm script, a wrapper around
    qemu-system, and sets custom port for SSH
  * Generation and inject an SSH (RSA 4096) key pair to use for
    communication with the guest to collect test artifacts
  * Add method to produce safe shell strings by base64 encoding
    the command

Additional Changes:

  * Set default backend to use LXD
  * Verify not running script as root in order to prevent images
    from becoming owned by root
  * Removed extra quotes around that were added when collecting
    the cloud-init version from the image
  * Added info about each release as previously the lxd backend
    was able to query that information from pylxd image info,
    however, other backends will not be able to obtain the same
    information as easily
  • Loading branch information
Joshua Powers committed Sep 14, 2017
1 parent 29a9296 commit 376168e
Show file tree
Hide file tree
Showing 14 changed files with 564 additions and 7 deletions.
5 changes: 4 additions & 1 deletion tests/cloud_tests/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import argparse
import logging
import os
import sys

from tests.cloud_tests import args, bddeb, collect, manage, run_funcs, verify
Expand Down Expand Up @@ -50,7 +51,7 @@ def add_subparser(name, description, arg_sets):
return -1

# run handler
LOG.debug('running with args: %s\n', parsed)
LOG.debug('running with args: %s', parsed)
return {
'bddeb': bddeb.bddeb,
'collect': collect.collect,
Expand All @@ -63,6 +64,8 @@ def add_subparser(name, description, arg_sets):


if __name__ == "__main__":
if os.geteuid() == 0:
sys.exit('Do not run as root')
sys.exit(main())

# vi: ts=4 expandtab
4 changes: 2 additions & 2 deletions tests/cloud_tests/args.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,9 +170,9 @@ def normalize_collect_args(args):
@param args: parsed args
@return_value: updated args, or None if errors occurred
"""
# platform should default to all supported
# platform should default to lxd
if len(args.platform) == 0:
args.platform = config.ENABLED_PLATFORMS
args.platform = ['lxd']
args.platform = util.sorted_unique(args.platform)

# os name should default to all enabled
Expand Down
3 changes: 3 additions & 0 deletions tests/cloud_tests/collect.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ def collect_image(args, platform, os_name):
os_config = config.load_os_config(
platform.platform_name, os_name, require_enabled=True,
feature_overrides=args.feature_override)
LOG.debug('os config: %s', os_config)
component = PlatformComponent(
partial(images.get_image, platform, os_config))

Expand All @@ -144,6 +145,8 @@ def collect_platform(args, platform_name):

platform_config = config.load_platform_config(
platform_name, require_enabled=True)
platform_config['data_dir'] = args.data_dir
LOG.debug('platform config: %s', platform_config)
component = PlatformComponent(
partial(platforms.get_platform, platform_name, platform_config))

Expand Down
1 change: 1 addition & 0 deletions tests/cloud_tests/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ def load_os_config(platform_name, os_name, require_enabled=False,
feature_conf = main_conf['features']
feature_groups = conf.get('feature_groups', [])
overrides = merge_config(get(conf, 'features'), feature_overrides)
conf['arch'] = c_util.get_architecture()
conf['features'] = merge_feature_groups(
feature_conf, feature_groups, overrides)

Expand Down
88 changes: 88 additions & 0 deletions tests/cloud_tests/images/nocloudkvm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# This file is part of cloud-init. See LICENSE file for license information.

"""NoCloud KVM Image Base Class."""

from tests.cloud_tests.images import base
from tests.cloud_tests.snapshots import nocloudkvm as nocloud_kvm_snapshot


class NoCloudKVMImage(base.Image):
"""NoCloud KVM backed image."""

platform_name = "nocloud-kvm"

def __init__(self, platform, config, img_path):
"""Set up image.
@param platform: platform object
@param config: image configuration
@param img_path: path to the image
"""
self.modified = False
self._instance = None
self._img_path = img_path

super(NoCloudKVMImage, self).__init__(platform, config)

@property
def instance(self):
"""Returns an instance of an image."""
if not self._instance:
if not self._img_path:
raise RuntimeError()

self._instance = self.platform.create_image(
self.properties, self.config, self.features, self._img_path,
image_desc=str(self), use_desc='image-modification')
return self._instance

@property
def properties(self):
"""Dictionary containing: 'arch', 'os', 'version', 'release'."""
return {
'arch': self.config['arch'],
'os': self.config['family'],
'release': self.config['release'],
'version': self.config['version'],
}

def execute(self, *args, **kwargs):
"""Execute command in image, modifying image."""
return self.instance.execute(*args, **kwargs)

def push_file(self, local_path, remote_path):
"""Copy file at 'local_path' to instance at 'remote_path'."""
return self.instance.push_file(local_path, remote_path)

def run_script(self, *args, **kwargs):
"""Run script in image, modifying image.
@return_value: script output
"""
return self.instance.run_script(*args, **kwargs)

def snapshot(self):
"""Create snapshot of image, block until done."""
if not self._img_path:
raise RuntimeError()

instance = self.platform.create_image(
self.properties, self.config, self.features,
self._img_path, image_desc=str(self), use_desc='snapshot')

return nocloud_kvm_snapshot.NoCloudKVMSnapshot(
self.platform, self.properties, self.config,
self.features, instance)

def destroy(self):
"""Unset path to signal image is no longer used.
The removal of the images and all other items is handled by the
framework. In some cases we want to keep the images, so let the
framework decide whether to keep or destroy everything.
"""
self._img_path = None
self._instance.destroy()
super(NoCloudKVMImage, self).destroy()

# vi: ts=4 expandtab
2 changes: 1 addition & 1 deletion tests/cloud_tests/instances/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ def run_script(self, script, rcs=None, description=None):
return self.execute(
['/bin/bash', script_path], rcs=rcs, description=description)
finally:
self.execute(['rm', script_path], rcs=rcs)
self.execute(['rm', '-f', script_path], rcs=rcs)

def tmpfile(self):
"""Get a tmp file in the target.
Expand Down
216 changes: 216 additions & 0 deletions tests/cloud_tests/instances/nocloudkvm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
# This file is part of cloud-init. See LICENSE file for license information.

"""Base NoCloud KVM instance."""

import os
import paramiko
import shlex
import socket
import subprocess
import time

from cloudinit import util as c_util
from tests.cloud_tests.instances import base
from tests.cloud_tests import util


class NoCloudKVMInstance(base.Instance):
"""NoCloud KVM backed instance."""

platform_name = "nocloud-kvm"

def __init__(self, platform, name, properties, config, features,
user_data, meta_data):
"""Set up instance.
@param platform: platform object
@param name: image path
@param properties: dictionary of properties
@param config: dictionary of configuration values
@param features: dictionary of supported feature flags
"""
self.user_data = user_data
self.meta_data = meta_data
self.ssh_key_file = os.path.join(platform.config['data_dir'],
platform.config['private_key'])
self.ssh_port = None
self.pid = None
self.pid_file = None

super(NoCloudKVMInstance, self).__init__(
platform, name, properties, config, features)

def destroy(self):
"""Clean up instance."""
if self.pid:
try:
c_util.subp(['kill', '-9', self.pid])
except util.ProcessExectuionError:
pass

if self.pid_file:
os.remove(self.pid_file)

self.pid = None
super(NoCloudKVMInstance, self).destroy()

def execute(self, command, stdout=None, stderr=None, env=None,
rcs=None, description=None):
"""Execute command in instance.
Assumes functional networking and execution as root with the
target filesystem being available at /.
@param command: the command to execute as root inside the image
if command is a string, then it will be executed as:
['sh', '-c', command]
@param stdout, stderr: file handles to write output and error to
@param env: environment variables
@param rcs: allowed return codes from command
@param description: purpose of command
@return_value: tuple containing stdout data, stderr data, exit code
"""
if env is None:
env = {}

if isinstance(command, str):
command = ['sh', '-c', command]

if self.pid:
return self.ssh(command)
else:
return self.mount_image_callback(command) + (0,)

def mount_image_callback(self, cmd):
"""Run mount-image-callback."""
mic = ('sudo mount-image-callback --system-mounts --system-resolvconf '
'%s -- chroot _MOUNTPOINT_ ' % self.name)

out, err = c_util.subp(shlex.split(mic) + cmd)

return out, err

def generate_seed(self, tmpdir):
"""Generate nocloud seed from user-data"""
seed_file = os.path.join(tmpdir, '%s_seed.img' % self.name)
user_data_file = os.path.join(tmpdir, '%s_user_data' % self.name)

with open(user_data_file, "w") as ud_file:
ud_file.write(self.user_data)

c_util.subp(['cloud-localds', seed_file, user_data_file])

return seed_file

def get_free_port(self):
"""Get a free port assigned by the kernel."""
s = socket.socket()
s.bind(('', 0))
num = s.getsockname()[1]
s.close()
return num

def push_file(self, local_path, remote_path):
"""Copy file at 'local_path' to instance at 'remote_path'.
If we have a pid then SSH is up, otherwise, use
mount-image-callback.
@param local_path: path on local instance
@param remote_path: path on remote instance
"""
if self.pid:
super(NoCloudKVMInstance, self).push_file()
else:
cmd = ("sudo mount-image-callback --system-mounts "
"--system-resolvconf %s -- chroot _MOUNTPOINT_ "
"/bin/sh -c 'cat - > %s'" % (self.name, remote_path))
local_file = open(local_path)
p = subprocess.Popen(shlex.split(cmd),
stdin=local_file,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
p.wait()

def sftp_put(self, path, data):
"""SFTP put a file."""
client = self._ssh_connect()
sftp = client.open_sftp()

with sftp.open(path, 'w') as f:
f.write(data)

client.close()

def ssh(self, command):
"""Run a command via SSH."""
client = self._ssh_connect()

try:
_, out, err = client.exec_command(util.shell_pack(command))
except paramiko.SSHException:
raise util.InTargetExecuteError('', '', -1, command, self.name)

exit = out.channel.recv_exit_status()
out = ''.join(out.readlines())
err = ''.join(err.readlines())
client.close()

return out, err, exit

def _ssh_connect(self, hostname='localhost', username='ubuntu',
banner_timeout=120, retry_attempts=30):
"""Connect via SSH."""
private_key = paramiko.RSAKey.from_private_key_file(self.ssh_key_file)
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
while retry_attempts:
try:
client.connect(hostname=hostname, username=username,
port=self.ssh_port, pkey=private_key,
banner_timeout=banner_timeout)
return client
except (paramiko.SSHException, TypeError):
time.sleep(1)
retry_attempts = retry_attempts - 1

error_desc = 'Failed command to: %s@%s:%s' % (username, hostname,
self.ssh_port)
raise util.InTargetExecuteError('', '', -1, 'ssh connect',
self.name, error_desc)

def start(self, wait=True, wait_for_cloud_init=False):
"""Start instance."""
tmpdir = self.platform.config['data_dir']
seed = self.generate_seed(tmpdir)
self.pid_file = os.path.join(tmpdir, '%s.pid' % self.name)
self.ssh_port = self.get_free_port()

cmd = ('./tools/xkvm --disk %s,cache=unsafe --disk %s,cache=unsafe '
'--netdev user,hostfwd=tcp::%s-:22 '
'-- -pidfile %s -vnc none -m 2G -smp 2'
% (self.name, seed, self.ssh_port, self.pid_file))

subprocess.Popen(shlex.split(cmd), close_fds=True,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)

while not os.path.exists(self.pid_file):
time.sleep(1)

with open(self.pid_file, 'r') as pid_f:
self.pid = pid_f.readlines()[0].strip()

if wait:
self._wait_for_system(wait_for_cloud_init)

def write_data(self, remote_path, data):
"""Write data to instance filesystem.
@param remote_path: path in instance
@param data: data to write, either str or bytes
"""
self.sftp_put(remote_path, data)

# vi: ts=4 expandtab
4 changes: 4 additions & 0 deletions tests/cloud_tests/platforms.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ platforms:
{{ config_get("user.user-data", properties.default) }}
cloud-init-vendor.tpl: |
{{ config_get("user.vendor-data", properties.default) }}
nocloud-kvm:
enabled: true
private_key: id_rsa
public_key: id_rsa.pub
ec2: {}
azure: {}

Expand Down
2 changes: 2 additions & 0 deletions tests/cloud_tests/platforms/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
"""Main init."""

from tests.cloud_tests.platforms import lxd
from tests.cloud_tests.platforms import nocloudkvm

PLATFORMS = {
'nocloud-kvm': nocloudkvm.NoCloudKVMPlatform,
'lxd': lxd.LXDPlatform,
}

Expand Down
Loading

0 comments on commit 376168e

Please sign in to comment.