Merge ~powersj/cloud-init:cii-kvm into cloud-init:master

Proposed by Joshua Powers on 2017-07-18
Status: Merged
Approved by: Scott Moser on 2017-09-15
Approved revision: 376168e251a1d4f2ee3643fed6092b8907f057ec
Merged at revision: 376168e251a1d4f2ee3643fed6092b8907f057ec
Proposed branch: ~powersj/cloud-init:cii-kvm
Merge into: cloud-init:master
Diff against target: 797 lines (+564/-7)
14 files modified
tests/cloud_tests/__main__.py (+4/-1)
tests/cloud_tests/args.py (+2/-2)
tests/cloud_tests/collect.py (+3/-0)
tests/cloud_tests/config.py (+1/-0)
tests/cloud_tests/images/nocloudkvm.py (+88/-0)
tests/cloud_tests/instances/base.py (+1/-1)
tests/cloud_tests/instances/nocloudkvm.py (+216/-0)
tests/cloud_tests/platforms.yaml (+4/-0)
tests/cloud_tests/platforms/__init__.py (+2/-0)
tests/cloud_tests/platforms/nocloudkvm.py (+90/-0)
tests/cloud_tests/releases.yaml (+18/-1)
tests/cloud_tests/setup_image.py (+18/-2)
tests/cloud_tests/snapshots/nocloudkvm.py (+74/-0)
tests/cloud_tests/util.py (+43/-0)
Reviewer Review Type Date Requested Status
Scott Moser Approve on 2017-09-14
Server Team CI bot continuous-integration Approve on 2017-09-14
Chad Smith 2017-07-18 Approve on 2017-09-08
Review via email: mp+327646@code.launchpad.net

Commit Message

tests: Enable the NoCloud KVM platform

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

Description of the Change

Testing completed:

# Smoke Test: single test on both platforms
python3 -m tests.cloud_tests run --verbose --platform nocloud-kvm --os-name xenial -t modules/locale
python3 -m tests.cloud_tests run --verbose --platform lxd --os-name xenial -t modules/locale

# Specify cloud-init deb to install
python3 -m tests.cloud_tests run --verbose --platform nocloud-kvm --os-name xenial -t modules/ntp --deb *.deb
python3 -m tests.cloud_tests run --verbose --platform lxd --os-name xenial -t modules/ntp --deb *.deb

# Specify repo to pull cloud-init from
python3 -m tests.cloud_tests run --verbose --platform nocloud-kvm --os-name artful -t modules/locale --repo "deb http://us.archive.ubuntu.com/ubuntu/ artful main restricted"
python3 -m tests.cloud_tests run --verbose --platform lxd --os-name artful -t modules/locale --repo "deb http://us.archive.ubuntu.com/ubuntu/ artful main restricted"

# Specify PPA to install from
python3 -m tests.cloud_tests run --verbose --platform nocloud-kvm --os-name xenial -t modules/locale --ppa ppa:cloud-init-dev/daily
python3 -m tests.cloud_tests run --verbose --platform lxd --os-name xenial -t modules/locale --ppa ppa:cloud-init-dev/daily

# Run nightly integration test on LTS and devel releases
python3 -m tests.cloud_tests run --verbose --platform lxd --os-name xenial --os-name artful

# Run KVM on Torkoal QA Slave
python3 -m tests.cloud_tests run --verbose --platform nocloud-kvm --os-name xenial -t modules/locale

# Build local tree and inject deb
# These are blocked right now due to build issues
python3 -m tests.cloud_tests tree_run --verbose --platform nocloud-kvm --os-name zesty -t modules/bootcmd
python3 -m tests.cloud_tests tree_run --verbose --platform lxd --os-name zesty -t modules/bootcmd

Note, not all existing tests work with the nocloud-kvm. This is because lxd runs with the root user, whereas nocloud-kvm runs as a normal 'ubuntu' user. Certain assumptions were made with lxd that do not apply to nocloud-kvm. As such updated test cases will come in a later merge.

To post a comment you must log in.

FAILED: Continuous integration, rev:273226e027ba483c3ae26787a2975348ef6b07c5
https://jenkins.ubuntu.com/server/job/cloud-init-ci/52/
Executed test runs:
    SUCCESS: Checkout
    SUCCESS: Unit & Style Tests
    SUCCESS: Ubuntu LTS: Build
    FAILED: Ubuntu LTS: Integration

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/52/rebuild

review: Needs Fixing (continuous-integration)

FAILED: Continuous integration, rev:8625408dd166a837f11381c5aea683ba14898e89
https://jenkins.ubuntu.com/server/job/cloud-init-ci/53/
Executed test runs:
    SUCCESS: Checkout
    SUCCESS: Unit & Style Tests
    SUCCESS: Ubuntu LTS: Build
    FAILED: Ubuntu LTS: Integration

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/53/rebuild

review: Needs Fixing (continuous-integration)

PASSED: Continuous integration, rev:31be94b892c57d57d829b0e2c7de5bf333b53d1b
https://jenkins.ubuntu.com/server/job/cloud-init-ci/54/
Executed test runs:
    SUCCESS: Checkout
    SUCCESS: Unit & Style Tests
    SUCCESS: Ubuntu LTS: Build
    SUCCESS: Ubuntu LTS: Integration
    SUCCESS: CentOS 6 & 7: Build & Test
    IN_PROGRESS: Declarative: Post Actions

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/54/rebuild

review: Approve (continuous-integration)
Ryan Harper (raharper) wrote :

Looks pretty solid. Just a few in-line comments/fixes

PASSED: Continuous integration, rev:42536229033d6580bb8cdcaf1cdbcd8c4afeaf16
https://jenkins.ubuntu.com/server/job/cloud-init-ci/156/
Executed test runs:
    SUCCESS: Checkout
    SUCCESS: Unit & Style Tests
    SUCCESS: Ubuntu LTS: Build
    SUCCESS: Ubuntu LTS: Integration
    SUCCESS: MAAS Compatability Testing
    IN_PROGRESS: Declarative: Post Actions

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/156/rebuild

review: Approve (continuous-integration)
Chad Smith (chad.smith) wrote :

Looks good, initial comments and running through tests now.

Chad Smith (chad.smith) wrote :

Additionally, since you are importing paramiko, we probably need a dev dependency added to requirements-test.txt

Chad Smith (chad.smith) :
Joshua Powers (powersj) wrote :

I have been leaving integration test requirements out of requirements-test.txt. For example, pylxd is not in there either. I consider that file what is required for unit tests only. Thoughts?

PASSED: Continuous integration, rev:661af70598fa19c1c5cbb84880fa430b697c6d79
https://jenkins.ubuntu.com/server/job/cloud-init-ci/165/
Executed test runs:
    SUCCESS: Checkout
    SUCCESS: Unit & Style Tests
    SUCCESS: Ubuntu LTS: Build
    SUCCESS: Ubuntu LTS: Integration
    SUCCESS: MAAS Compatability Testing
    IN_PROGRESS: Declarative: Post Actions

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/165/rebuild

review: Approve (continuous-integration)

PASSED: Continuous integration, rev:80dd771ae2e9f760de9d72924e0c33381e378471
https://jenkins.ubuntu.com/server/job/cloud-init-ci/166/
Executed test runs:
    SUCCESS: Checkout
    SUCCESS: Unit & Style Tests
    SUCCESS: Ubuntu LTS: Build
    SUCCESS: Ubuntu LTS: Integration
    SUCCESS: MAAS Compatability Testing
    IN_PROGRESS: Declarative: Post Actions

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/166/rebuild

review: Approve (continuous-integration)
Ryan Harper (raharper) wrote :

There are quite a few with open() as fh: fh.read() chunks; if you're using cloudinit.util then you can just use util.load_file()

same for writing (util.write_file())

Chad Smith (chad.smith) wrote :

Thanks for working this josh. Here are a few more more comments. I'm still seeing spurious issues on my laptop trying to run this (I just hit an OOM as well) and NoValidConnectionsError from paramiko. But I have also seen successful runs. I'm +1 on this iteration with various fixes/comments inline. We can iterate and improve as we have cycles.

Joshua Powers (powersj) wrote :

Thanks for the reviews!

@chad.smith - It would be helpful to get your command line that you used where things failed or crashed.

For the record there are two design decisions that I want to clarify:

1) LXD still uses pylxd. I would like to change this over to mount-image-callback at some point, but not in this merge. As a result, there are a number of places that take advantage of pylxd operations giving a return code. This is passed around in numerous places. Using subprocess that is in cloud-init already does not have this feature, so I have to fake out the return code in a few places. With the move to MIC I will remove this entirely.

2) I prefer to pass string commands around in functions versus having arrays of strings. It is far easier to develop when modifying a string than an array of strings. It also makes far more sense to have a command as a string, as an array is not a command. The function doing the processing can run shlex.

Scott Moser (smoser) wrote :

"It also makes far more sense to have a command as a string, as an array is not a command."
That is generally false.

A shell tokenizes a string based on some very complicated rules and turns it into an array which it then passes to execve(2).

If you ever have to pass a command that has a ", ', &, *, , # in it, then you'll end up wishing you could pass an array due to fighting the shell's interpretation.

we should do a better job in cloud-init in subp or this path of printing the command so you can cut and paste it than we do (not just printing the array), but that is fairly easily done.

i'll takea poke at fixing the shell stuff to proper arrays.

Scott Moser (smoser) wrote :

fyi, regarding mount-image-callback:
 https://bugs.launchpad.net/cloud-init/+bug/1715994
:-(

Chad Smith (chad.smith) wrote :

+1 on changesets thanks Josh.

review: Approve
Scott Moser (smoser) wrote :

To satisfy your desire to pass strings (which i agree are shorter)
we can make a requirement for (instance|image).execute to take a string.

that does make simple usage simpler, i agree.

PASSED: Continuous integration, rev:6abf7c3fcce720fc9343a838d60b10fe6af1b250
https://jenkins.ubuntu.com/server/job/cloud-init-ci/274/
Executed test runs:
    SUCCESS: Checkout
    SUCCESS: Unit & Style Tests
    SUCCESS: Ubuntu LTS: Build
    SUCCESS: Ubuntu LTS: Integration
    SUCCESS: MAAS Compatability Testing
    IN_PROGRESS: Declarative: Post Actions

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/274/rebuild

review: Approve (continuous-integration)

PASSED: Continuous integration, rev:ee71778d942f4f55da5407fb0d8e0a9649a3305b
https://jenkins.ubuntu.com/server/job/cloud-init-ci/278/
Executed test runs:
    SUCCESS: Checkout
    SUCCESS: Unit & Style Tests
    SUCCESS: Ubuntu LTS: Build
    SUCCESS: Ubuntu LTS: Integration
    SUCCESS: MAAS Compatability Testing
    IN_PROGRESS: Declarative: Post Actions

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/278/rebuild

review: Approve (continuous-integration)
Scott Moser (smoser) wrote :

I'm not opposed to the string changes, but it'd be nice if you did them in a separate mp.
see my suggestion to support strings in execute at
 https://code.launchpad.net/~smoser/cloud-init/+git/cloud-init/+merge/330459

then you could do your string changes and then this, and it will look smaller.

Joshua Powers (powersj) wrote :

@smoser: few questions and answers below.

Scott Moser (smoser) wrote :

responded to comments from 09-08.

PASSED: Continuous integration, rev:f6397f8c2e7c22a7378d5d201cc37f7c6fc478d4
https://jenkins.ubuntu.com/server/job/cloud-init-ci/297/
Executed test runs:
    SUCCESS: Checkout
    SUCCESS: Unit & Style Tests
    SUCCESS: Ubuntu LTS: Build
    SUCCESS: Ubuntu LTS: Integration
    SUCCESS: MAAS Compatability Testing
    IN_PROGRESS: Declarative: Post Actions

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/297/rebuild

review: Approve (continuous-integration)

FAILED: Continuous integration, rev:b7c7f3d1053ded4840a8ee3cf683c413666d4b06
https://jenkins.ubuntu.com/server/job/cloud-init-ci/298/
Executed test runs:
    SUCCESS: Checkout
    FAILED: Unit & Style Tests

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/298/rebuild

review: Needs Fixing (continuous-integration)

PASSED: Continuous integration, rev:376168e251a1d4f2ee3643fed6092b8907f057ec
https://jenkins.ubuntu.com/server/job/cloud-init-ci/299/
Executed test runs:
    SUCCESS: Checkout
    SUCCESS: Unit & Style Tests
    SUCCESS: Ubuntu LTS: Build
    SUCCESS: Ubuntu LTS: Integration
    SUCCESS: MAAS Compatability Testing
    IN_PROGRESS: Declarative: Post Actions

Click here to trigger a rebuild:
https://jenkins.ubuntu.com/server/job/cloud-init-ci/299/rebuild

review: Approve (continuous-integration)
Scott Moser (smoser) wrote :

some comments...
I wont insist on any of these.

there is some cleanup that we need to do though.

Scott Moser (smoser) wrote :

I guess this is 'Approve' and we can fix things more later.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/tests/cloud_tests/__main__.py b/tests/cloud_tests/__main__.py
2index 260ddb3..7ee29ca 100644
3--- a/tests/cloud_tests/__main__.py
4+++ b/tests/cloud_tests/__main__.py
5@@ -4,6 +4,7 @@
6
7 import argparse
8 import logging
9+import os
10 import sys
11
12 from tests.cloud_tests import args, bddeb, collect, manage, run_funcs, verify
13@@ -50,7 +51,7 @@ def main():
14 return -1
15
16 # run handler
17- LOG.debug('running with args: %s\n', parsed)
18+ LOG.debug('running with args: %s', parsed)
19 return {
20 'bddeb': bddeb.bddeb,
21 'collect': collect.collect,
22@@ -63,6 +64,8 @@ def main():
23
24
25 if __name__ == "__main__":
26+ if os.geteuid() == 0:
27+ sys.exit('Do not run as root')
28 sys.exit(main())
29
30 # vi: ts=4 expandtab
31diff --git a/tests/cloud_tests/args.py b/tests/cloud_tests/args.py
32index 369d60d..c6c1877 100644
33--- a/tests/cloud_tests/args.py
34+++ b/tests/cloud_tests/args.py
35@@ -170,9 +170,9 @@ def normalize_collect_args(args):
36 @param args: parsed args
37 @return_value: updated args, or None if errors occurred
38 """
39- # platform should default to all supported
40+ # platform should default to lxd
41 if len(args.platform) == 0:
42- args.platform = config.ENABLED_PLATFORMS
43+ args.platform = ['lxd']
44 args.platform = util.sorted_unique(args.platform)
45
46 # os name should default to all enabled
47diff --git a/tests/cloud_tests/collect.py b/tests/cloud_tests/collect.py
48index b44e8bd..4a2422e 100644
49--- a/tests/cloud_tests/collect.py
50+++ b/tests/cloud_tests/collect.py
51@@ -120,6 +120,7 @@ def collect_image(args, platform, os_name):
52 os_config = config.load_os_config(
53 platform.platform_name, os_name, require_enabled=True,
54 feature_overrides=args.feature_override)
55+ LOG.debug('os config: %s', os_config)
56 component = PlatformComponent(
57 partial(images.get_image, platform, os_config))
58
59@@ -144,6 +145,8 @@ def collect_platform(args, platform_name):
60
61 platform_config = config.load_platform_config(
62 platform_name, require_enabled=True)
63+ platform_config['data_dir'] = args.data_dir
64+ LOG.debug('platform config: %s', platform_config)
65 component = PlatformComponent(
66 partial(platforms.get_platform, platform_name, platform_config))
67
68diff --git a/tests/cloud_tests/config.py b/tests/cloud_tests/config.py
69index 4d5dc80..52fc2bd 100644
70--- a/tests/cloud_tests/config.py
71+++ b/tests/cloud_tests/config.py
72@@ -112,6 +112,7 @@ def load_os_config(platform_name, os_name, require_enabled=False,
73 feature_conf = main_conf['features']
74 feature_groups = conf.get('feature_groups', [])
75 overrides = merge_config(get(conf, 'features'), feature_overrides)
76+ conf['arch'] = c_util.get_architecture()
77 conf['features'] = merge_feature_groups(
78 feature_conf, feature_groups, overrides)
79
80diff --git a/tests/cloud_tests/images/nocloudkvm.py b/tests/cloud_tests/images/nocloudkvm.py
81new file mode 100644
82index 0000000..a7af0e5
83--- /dev/null
84+++ b/tests/cloud_tests/images/nocloudkvm.py
85@@ -0,0 +1,88 @@
86+# This file is part of cloud-init. See LICENSE file for license information.
87+
88+"""NoCloud KVM Image Base Class."""
89+
90+from tests.cloud_tests.images import base
91+from tests.cloud_tests.snapshots import nocloudkvm as nocloud_kvm_snapshot
92+
93+
94+class NoCloudKVMImage(base.Image):
95+ """NoCloud KVM backed image."""
96+
97+ platform_name = "nocloud-kvm"
98+
99+ def __init__(self, platform, config, img_path):
100+ """Set up image.
101+
102+ @param platform: platform object
103+ @param config: image configuration
104+ @param img_path: path to the image
105+ """
106+ self.modified = False
107+ self._instance = None
108+ self._img_path = img_path
109+
110+ super(NoCloudKVMImage, self).__init__(platform, config)
111+
112+ @property
113+ def instance(self):
114+ """Returns an instance of an image."""
115+ if not self._instance:
116+ if not self._img_path:
117+ raise RuntimeError()
118+
119+ self._instance = self.platform.create_image(
120+ self.properties, self.config, self.features, self._img_path,
121+ image_desc=str(self), use_desc='image-modification')
122+ return self._instance
123+
124+ @property
125+ def properties(self):
126+ """Dictionary containing: 'arch', 'os', 'version', 'release'."""
127+ return {
128+ 'arch': self.config['arch'],
129+ 'os': self.config['family'],
130+ 'release': self.config['release'],
131+ 'version': self.config['version'],
132+ }
133+
134+ def execute(self, *args, **kwargs):
135+ """Execute command in image, modifying image."""
136+ return self.instance.execute(*args, **kwargs)
137+
138+ def push_file(self, local_path, remote_path):
139+ """Copy file at 'local_path' to instance at 'remote_path'."""
140+ return self.instance.push_file(local_path, remote_path)
141+
142+ def run_script(self, *args, **kwargs):
143+ """Run script in image, modifying image.
144+
145+ @return_value: script output
146+ """
147+ return self.instance.run_script(*args, **kwargs)
148+
149+ def snapshot(self):
150+ """Create snapshot of image, block until done."""
151+ if not self._img_path:
152+ raise RuntimeError()
153+
154+ instance = self.platform.create_image(
155+ self.properties, self.config, self.features,
156+ self._img_path, image_desc=str(self), use_desc='snapshot')
157+
158+ return nocloud_kvm_snapshot.NoCloudKVMSnapshot(
159+ self.platform, self.properties, self.config,
160+ self.features, instance)
161+
162+ def destroy(self):
163+ """Unset path to signal image is no longer used.
164+
165+ The removal of the images and all other items is handled by the
166+ framework. In some cases we want to keep the images, so let the
167+ framework decide whether to keep or destroy everything.
168+ """
169+ self._img_path = None
170+ self._instance.destroy()
171+ super(NoCloudKVMImage, self).destroy()
172+
173+# vi: ts=4 expandtab
174diff --git a/tests/cloud_tests/instances/base.py b/tests/cloud_tests/instances/base.py
175index 58f45b1..9bdda60 100644
176--- a/tests/cloud_tests/instances/base.py
177+++ b/tests/cloud_tests/instances/base.py
178@@ -90,7 +90,7 @@ class Instance(object):
179 return self.execute(
180 ['/bin/bash', script_path], rcs=rcs, description=description)
181 finally:
182- self.execute(['rm', script_path], rcs=rcs)
183+ self.execute(['rm', '-f', script_path], rcs=rcs)
184
185 def tmpfile(self):
186 """Get a tmp file in the target.
187diff --git a/tests/cloud_tests/instances/nocloudkvm.py b/tests/cloud_tests/instances/nocloudkvm.py
188new file mode 100644
189index 0000000..7abfe73
190--- /dev/null
191+++ b/tests/cloud_tests/instances/nocloudkvm.py
192@@ -0,0 +1,216 @@
193+# This file is part of cloud-init. See LICENSE file for license information.
194+
195+"""Base NoCloud KVM instance."""
196+
197+import os
198+import paramiko
199+import shlex
200+import socket
201+import subprocess
202+import time
203+
204+from cloudinit import util as c_util
205+from tests.cloud_tests.instances import base
206+from tests.cloud_tests import util
207+
208+
209+class NoCloudKVMInstance(base.Instance):
210+ """NoCloud KVM backed instance."""
211+
212+ platform_name = "nocloud-kvm"
213+
214+ def __init__(self, platform, name, properties, config, features,
215+ user_data, meta_data):
216+ """Set up instance.
217+
218+ @param platform: platform object
219+ @param name: image path
220+ @param properties: dictionary of properties
221+ @param config: dictionary of configuration values
222+ @param features: dictionary of supported feature flags
223+ """
224+ self.user_data = user_data
225+ self.meta_data = meta_data
226+ self.ssh_key_file = os.path.join(platform.config['data_dir'],
227+ platform.config['private_key'])
228+ self.ssh_port = None
229+ self.pid = None
230+ self.pid_file = None
231+
232+ super(NoCloudKVMInstance, self).__init__(
233+ platform, name, properties, config, features)
234+
235+ def destroy(self):
236+ """Clean up instance."""
237+ if self.pid:
238+ try:
239+ c_util.subp(['kill', '-9', self.pid])
240+ except util.ProcessExectuionError:
241+ pass
242+
243+ if self.pid_file:
244+ os.remove(self.pid_file)
245+
246+ self.pid = None
247+ super(NoCloudKVMInstance, self).destroy()
248+
249+ def execute(self, command, stdout=None, stderr=None, env=None,
250+ rcs=None, description=None):
251+ """Execute command in instance.
252+
253+ Assumes functional networking and execution as root with the
254+ target filesystem being available at /.
255+
256+ @param command: the command to execute as root inside the image
257+ if command is a string, then it will be executed as:
258+ ['sh', '-c', command]
259+ @param stdout, stderr: file handles to write output and error to
260+ @param env: environment variables
261+ @param rcs: allowed return codes from command
262+ @param description: purpose of command
263+ @return_value: tuple containing stdout data, stderr data, exit code
264+ """
265+ if env is None:
266+ env = {}
267+
268+ if isinstance(command, str):
269+ command = ['sh', '-c', command]
270+
271+ if self.pid:
272+ return self.ssh(command)
273+ else:
274+ return self.mount_image_callback(command) + (0,)
275+
276+ def mount_image_callback(self, cmd):
277+ """Run mount-image-callback."""
278+ mic = ('sudo mount-image-callback --system-mounts --system-resolvconf '
279+ '%s -- chroot _MOUNTPOINT_ ' % self.name)
280+
281+ out, err = c_util.subp(shlex.split(mic) + cmd)
282+
283+ return out, err
284+
285+ def generate_seed(self, tmpdir):
286+ """Generate nocloud seed from user-data"""
287+ seed_file = os.path.join(tmpdir, '%s_seed.img' % self.name)
288+ user_data_file = os.path.join(tmpdir, '%s_user_data' % self.name)
289+
290+ with open(user_data_file, "w") as ud_file:
291+ ud_file.write(self.user_data)
292+
293+ c_util.subp(['cloud-localds', seed_file, user_data_file])
294+
295+ return seed_file
296+
297+ def get_free_port(self):
298+ """Get a free port assigned by the kernel."""
299+ s = socket.socket()
300+ s.bind(('', 0))
301+ num = s.getsockname()[1]
302+ s.close()
303+ return num
304+
305+ def push_file(self, local_path, remote_path):
306+ """Copy file at 'local_path' to instance at 'remote_path'.
307+
308+ If we have a pid then SSH is up, otherwise, use
309+ mount-image-callback.
310+
311+ @param local_path: path on local instance
312+ @param remote_path: path on remote instance
313+ """
314+ if self.pid:
315+ super(NoCloudKVMInstance, self).push_file()
316+ else:
317+ cmd = ("sudo mount-image-callback --system-mounts "
318+ "--system-resolvconf %s -- chroot _MOUNTPOINT_ "
319+ "/bin/sh -c 'cat - > %s'" % (self.name, remote_path))
320+ local_file = open(local_path)
321+ p = subprocess.Popen(shlex.split(cmd),
322+ stdin=local_file,
323+ stdout=subprocess.PIPE,
324+ stderr=subprocess.PIPE)
325+ p.wait()
326+
327+ def sftp_put(self, path, data):
328+ """SFTP put a file."""
329+ client = self._ssh_connect()
330+ sftp = client.open_sftp()
331+
332+ with sftp.open(path, 'w') as f:
333+ f.write(data)
334+
335+ client.close()
336+
337+ def ssh(self, command):
338+ """Run a command via SSH."""
339+ client = self._ssh_connect()
340+
341+ try:
342+ _, out, err = client.exec_command(util.shell_pack(command))
343+ except paramiko.SSHException:
344+ raise util.InTargetExecuteError('', '', -1, command, self.name)
345+
346+ exit = out.channel.recv_exit_status()
347+ out = ''.join(out.readlines())
348+ err = ''.join(err.readlines())
349+ client.close()
350+
351+ return out, err, exit
352+
353+ def _ssh_connect(self, hostname='localhost', username='ubuntu',
354+ banner_timeout=120, retry_attempts=30):
355+ """Connect via SSH."""
356+ private_key = paramiko.RSAKey.from_private_key_file(self.ssh_key_file)
357+ client = paramiko.SSHClient()
358+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
359+ while retry_attempts:
360+ try:
361+ client.connect(hostname=hostname, username=username,
362+ port=self.ssh_port, pkey=private_key,
363+ banner_timeout=banner_timeout)
364+ return client
365+ except (paramiko.SSHException, TypeError):
366+ time.sleep(1)
367+ retry_attempts = retry_attempts - 1
368+
369+ error_desc = 'Failed command to: %s@%s:%s' % (username, hostname,
370+ self.ssh_port)
371+ raise util.InTargetExecuteError('', '', -1, 'ssh connect',
372+ self.name, error_desc)
373+
374+ def start(self, wait=True, wait_for_cloud_init=False):
375+ """Start instance."""
376+ tmpdir = self.platform.config['data_dir']
377+ seed = self.generate_seed(tmpdir)
378+ self.pid_file = os.path.join(tmpdir, '%s.pid' % self.name)
379+ self.ssh_port = self.get_free_port()
380+
381+ cmd = ('./tools/xkvm --disk %s,cache=unsafe --disk %s,cache=unsafe '
382+ '--netdev user,hostfwd=tcp::%s-:22 '
383+ '-- -pidfile %s -vnc none -m 2G -smp 2'
384+ % (self.name, seed, self.ssh_port, self.pid_file))
385+
386+ subprocess.Popen(shlex.split(cmd), close_fds=True,
387+ stdin=subprocess.PIPE,
388+ stdout=subprocess.PIPE,
389+ stderr=subprocess.PIPE)
390+
391+ while not os.path.exists(self.pid_file):
392+ time.sleep(1)
393+
394+ with open(self.pid_file, 'r') as pid_f:
395+ self.pid = pid_f.readlines()[0].strip()
396+
397+ if wait:
398+ self._wait_for_system(wait_for_cloud_init)
399+
400+ def write_data(self, remote_path, data):
401+ """Write data to instance filesystem.
402+
403+ @param remote_path: path in instance
404+ @param data: data to write, either str or bytes
405+ """
406+ self.sftp_put(remote_path, data)
407+
408+# vi: ts=4 expandtab
409diff --git a/tests/cloud_tests/platforms.yaml b/tests/cloud_tests/platforms.yaml
410index b91834a..fa4f845 100644
411--- a/tests/cloud_tests/platforms.yaml
412+++ b/tests/cloud_tests/platforms.yaml
413@@ -59,6 +59,10 @@ platforms:
414 {{ config_get("user.user-data", properties.default) }}
415 cloud-init-vendor.tpl: |
416 {{ config_get("user.vendor-data", properties.default) }}
417+ nocloud-kvm:
418+ enabled: true
419+ private_key: id_rsa
420+ public_key: id_rsa.pub
421 ec2: {}
422 azure: {}
423
424diff --git a/tests/cloud_tests/platforms/__init__.py b/tests/cloud_tests/platforms/__init__.py
425index 443f6d4..3490fe8 100644
426--- a/tests/cloud_tests/platforms/__init__.py
427+++ b/tests/cloud_tests/platforms/__init__.py
428@@ -3,8 +3,10 @@
429 """Main init."""
430
431 from tests.cloud_tests.platforms import lxd
432+from tests.cloud_tests.platforms import nocloudkvm
433
434 PLATFORMS = {
435+ 'nocloud-kvm': nocloudkvm.NoCloudKVMPlatform,
436 'lxd': lxd.LXDPlatform,
437 }
438
439diff --git a/tests/cloud_tests/platforms/nocloudkvm.py b/tests/cloud_tests/platforms/nocloudkvm.py
440new file mode 100644
441index 0000000..f1f8187
442--- /dev/null
443+++ b/tests/cloud_tests/platforms/nocloudkvm.py
444@@ -0,0 +1,90 @@
445+# This file is part of cloud-init. See LICENSE file for license information.
446+
447+"""Base NoCloud KVM platform."""
448+import glob
449+import os
450+
451+from simplestreams import filters
452+from simplestreams import mirrors
453+from simplestreams import objectstores
454+from simplestreams import util as s_util
455+
456+from cloudinit import util as c_util
457+from tests.cloud_tests.images import nocloudkvm as nocloud_kvm_image
458+from tests.cloud_tests.instances import nocloudkvm as nocloud_kvm_instance
459+from tests.cloud_tests.platforms import base
460+from tests.cloud_tests import util
461+
462+
463+class NoCloudKVMPlatform(base.Platform):
464+ """NoCloud KVM test platform."""
465+
466+ platform_name = 'nocloud-kvm'
467+
468+ def get_image(self, img_conf):
469+ """Get image using specified image configuration.
470+
471+ @param img_conf: configuration for image
472+ @return_value: cloud_tests.images instance
473+ """
474+ (url, path) = s_util.path_from_mirror_url(img_conf['mirror_url'], None)
475+
476+ filter = filters.get_filters(['arch=%s' % c_util.get_architecture(),
477+ 'release=%s' % img_conf['release'],
478+ 'ftype=disk1.img'])
479+ mirror_config = {'filters': filter,
480+ 'keep_items': False,
481+ 'max_items': 1,
482+ 'checksumming_reader': True,
483+ 'item_download': True
484+ }
485+
486+ def policy(content, path):
487+ return s_util.read_signed(content, keyring=img_conf['keyring'])
488+
489+ smirror = mirrors.UrlMirrorReader(url, policy=policy)
490+ tstore = objectstores.FileStore(img_conf['mirror_dir'])
491+ tmirror = mirrors.ObjectFilterMirror(config=mirror_config,
492+ objectstore=tstore)
493+ tmirror.sync(smirror, path)
494+
495+ search_d = os.path.join(img_conf['mirror_dir'], '**',
496+ img_conf['release'], '**', '*.img')
497+
498+ images = []
499+ for fname in glob.iglob(search_d, recursive=True):
500+ images.append(fname)
501+
502+ if len(images) != 1:
503+ raise Exception('No unique images found')
504+
505+ image = nocloud_kvm_image.NoCloudKVMImage(self, img_conf, images[0])
506+ if img_conf.get('override_templates', False):
507+ image.update_templates(self.config.get('template_overrides', {}),
508+ self.config.get('template_files', {}))
509+ return image
510+
511+ def create_image(self, properties, config, features,
512+ src_img_path, image_desc=None, use_desc=None,
513+ user_data=None, meta_data=None):
514+ """Create an image
515+
516+ @param src_img_path: image path to launch from
517+ @param properties: image properties
518+ @param config: image configuration
519+ @param features: image features
520+ @param image_desc: description of image being launched
521+ @param use_desc: description of container's use
522+ @return_value: cloud_tests.instances instance
523+ """
524+ name = util.gen_instance_name(image_desc=image_desc, use_desc=use_desc)
525+ img_path = os.path.join(self.config['data_dir'], name + '.qcow2')
526+ c_util.subp(['qemu-img', 'create', '-f', 'qcow2',
527+ '-b', src_img_path, img_path])
528+
529+ return nocloud_kvm_instance.NoCloudKVMInstance(self, img_path,
530+ properties, config,
531+ features, user_data,
532+ meta_data)
533+
534+# vi: ts=4 expandtab
535diff --git a/tests/cloud_tests/releases.yaml b/tests/cloud_tests/releases.yaml
536index c8dd142..ec7e2d5 100644
537--- a/tests/cloud_tests/releases.yaml
538+++ b/tests/cloud_tests/releases.yaml
539@@ -27,7 +27,12 @@ default_release_config:
540 # features groups and additional feature settings
541 feature_groups: []
542 features: {}
543-
544+ nocloud-kvm:
545+ mirror_url: https://cloud-images.ubuntu.com/daily
546+ mirror_dir: '/srv/citest/nocloud-kvm'
547+ keyring: /usr/share/keyrings/ubuntu-cloudimage-keyring.gpg
548+ setup_overrides: null
549+ override_templates: false
550 # lxd specific default configuration options
551 lxd:
552 # default sstreams server to use for lxd image retrieval
553@@ -121,6 +126,9 @@ releases:
554 # EOL: Jul 2018
555 default:
556 enabled: true
557+ release: artful
558+ version: 17.10
559+ family: ubuntu
560 feature_groups:
561 - base
562 - debian_base
563@@ -134,6 +142,9 @@ releases:
564 # EOL: Jan 2018
565 default:
566 enabled: true
567+ release: zesty
568+ version: 17.04
569+ family: ubuntu
570 feature_groups:
571 - base
572 - debian_base
573@@ -147,6 +158,9 @@ releases:
574 # EOL: Apr 2021
575 default:
576 enabled: true
577+ release: xenial
578+ version: 16.04
579+ family: ubuntu
580 feature_groups:
581 - base
582 - debian_base
583@@ -160,6 +174,9 @@ releases:
584 # EOL: Apr 2019
585 default:
586 enabled: true
587+ release: trusty
588+ version: 14.04
589+ family: ubuntu
590 feature_groups:
591 - base
592 - debian_base
593diff --git a/tests/cloud_tests/setup_image.py b/tests/cloud_tests/setup_image.py
594index 3c0fff6..6672ffb 100644
595--- a/tests/cloud_tests/setup_image.py
596+++ b/tests/cloud_tests/setup_image.py
597@@ -5,6 +5,7 @@
598 from functools import partial
599 import os
600
601+from cloudinit import util as c_util
602 from tests.cloud_tests import LOG
603 from tests.cloud_tests import stage, util
604
605@@ -19,7 +20,7 @@ def installed_package_version(image, package, ensure_installed=True):
606 """
607 os_family = util.get_os_family(image.properties['os'])
608 if os_family == 'debian':
609- cmd = ['dpkg-query', '-W', "--showformat='${Version}'", package]
610+ cmd = ['dpkg-query', '-W', "--showformat=${Version}", package]
611 elif os_family == 'redhat':
612 cmd = ['rpm', '-q', '--queryformat', "'%{VERSION}'", package]
613 else:
614@@ -53,7 +54,7 @@ def install_deb(args, image):
615 image.execute(cmd, description=msg)
616
617 # check installed deb version matches package
618- fmt = ['-W', "--showformat='${Version}'"]
619+ fmt = ['-W', "--showformat=${Version}"]
620 (out, err, exit) = image.execute(['dpkg-deb'] + fmt + [remote_path])
621 expected_version = out.strip()
622 found_version = installed_package_version(image, 'cloud-init')
623@@ -191,6 +192,20 @@ def enable_repo(args, image):
624 image.execute(cmd, description=msg)
625
626
627+def generate_ssh_keys(data_dir):
628+ """Generate SSH keys to be used with image."""
629+ LOG.info('generating SSH keys')
630+ filename = os.path.join(data_dir, 'id_rsa')
631+
632+ if os.path.exists(filename):
633+ c_util.del_file(filename)
634+
635+ c_util.subp(['ssh-keygen', '-t', 'rsa', '-b', '4096',
636+ '-f', filename, '-P', '',
637+ '-C', 'ubuntu@cloud_test'],
638+ capture=True)
639+
640+
641 def setup_image(args, image):
642 """Set up image as specified in args.
643
644@@ -226,6 +241,7 @@ def setup_image(args, image):
645 'set up for {}'.format(image), calls, continue_after_error=False)
646 LOG.debug('after setup complete, installed cloud-init version is: %s',
647 installed_package_version(image, 'cloud-init'))
648+ generate_ssh_keys(args.data_dir)
649 return res
650
651 # vi: ts=4 expandtab
652diff --git a/tests/cloud_tests/snapshots/nocloudkvm.py b/tests/cloud_tests/snapshots/nocloudkvm.py
653new file mode 100644
654index 0000000..0999834
655--- /dev/null
656+++ b/tests/cloud_tests/snapshots/nocloudkvm.py
657@@ -0,0 +1,74 @@
658+# This file is part of cloud-init. See LICENSE file for license information.
659+
660+"""Base NoCloud KVM snapshot."""
661+import os
662+
663+from tests.cloud_tests.snapshots import base
664+
665+
666+class NoCloudKVMSnapshot(base.Snapshot):
667+ """NoCloud KVM image copy backed snapshot."""
668+
669+ platform_name = "nocloud-kvm"
670+
671+ def __init__(self, platform, properties, config, features,
672+ instance):
673+ """Set up snapshot.
674+
675+ @param platform: platform object
676+ @param properties: image properties
677+ @param config: image config
678+ @param features: supported feature flags
679+ """
680+ self.instance = instance
681+
682+ super(NoCloudKVMSnapshot, self).__init__(
683+ platform, properties, config, features)
684+
685+ def launch(self, user_data, meta_data=None, block=True, start=True,
686+ use_desc=None):
687+ """Launch instance.
688+
689+ @param user_data: user-data for the instance
690+ @param instance_id: instance-id for the instance
691+ @param block: wait until instance is created
692+ @param start: start instance and wait until fully started
693+ @param use_desc: description of snapshot instance use
694+ @return_value: an Instance
695+ """
696+ key_file = os.path.join(self.platform.config['data_dir'],
697+ self.platform.config['public_key'])
698+ user_data = self.inject_ssh_key(user_data, key_file)
699+
700+ instance = self.platform.create_image(
701+ self.properties, self.config, self.features,
702+ self.instance.name, image_desc=str(self), use_desc=use_desc,
703+ user_data=user_data, meta_data=meta_data)
704+
705+ if start:
706+ instance.start()
707+
708+ return instance
709+
710+ def inject_ssh_key(self, user_data, key_file):
711+ """Inject the authorized key into the user_data."""
712+ with open(key_file) as f:
713+ value = f.read()
714+
715+ key = 'ssh_authorized_keys:'
716+ value = ' - %s' % value.strip()
717+ user_data = user_data.split('\n')
718+ if key in user_data:
719+ user_data.insert(user_data.index(key) + 1, '%s' % value)
720+ else:
721+ user_data.insert(-1, '%s' % key)
722+ user_data.insert(-1, '%s' % value)
723+
724+ return '\n'.join(user_data)
725+
726+ def destroy(self):
727+ """Clean up snapshot data."""
728+ self.instance.destroy()
729+ super(NoCloudKVMSnapshot, self).destroy()
730+
731+# vi: ts=4 expandtab
732diff --git a/tests/cloud_tests/util.py b/tests/cloud_tests/util.py
733index 2bbe21c..4357fbb 100644
734--- a/tests/cloud_tests/util.py
735+++ b/tests/cloud_tests/util.py
736@@ -2,12 +2,14 @@
737
738 """Utilities for re-use across integration tests."""
739
740+import base64
741 import copy
742 import glob
743 import os
744 import random
745 import shutil
746 import string
747+import subprocess
748 import tempfile
749 import yaml
750
751@@ -242,6 +244,47 @@ def update_user_data(user_data, updates, dump_to_yaml=True):
752 if dump_to_yaml else user_data)
753
754
755+def shell_safe(cmd):
756+ """Produce string safe shell string.
757+
758+ Create a string that can be passed to:
759+ set -- <string>
760+ to produce the same array that cmd represents.
761+
762+ Internally we utilize 'getopt's ability/knowledge on how to quote
763+ strings to be safe for shell. This implementation could be changed
764+ to be pure python. It is just a matter of correctly escaping
765+ or quoting characters like: ' " ^ & $ ; ( ) ...
766+
767+ @param cmd: command as a list
768+ """
769+ out = subprocess.check_output(
770+ ["getopt", "--shell", "sh", "--options", "", "--", "--"] + list(cmd))
771+ # out contains ' -- <data>\n'. drop the ' -- ' and the '\n'
772+ return out[4:-1].decode()
773+
774+
775+def shell_pack(cmd):
776+ """Return a string that can shuffled through 'sh' and execute cmd.
777+
778+ In Python subprocess terms:
779+ check_output(cmd) == check_output(shell_pack(cmd), shell=True)
780+
781+ @param cmd: list or string of command to pack up
782+ """
783+
784+ if isinstance(cmd, str):
785+ cmd = [cmd]
786+ else:
787+ cmd = list(cmd)
788+
789+ stuffed = shell_safe(cmd)
790+ # for whatever reason b64encode returns bytes when it is clearly
791+ # representable as a string by nature of being base64 encoded.
792+ b64 = base64.b64encode(stuffed.encode()).decode()
793+ return 'eval set -- "$(echo %s | base64 --decode)" && exec "$@"' % b64
794+
795+
796 class InTargetExecuteError(c_util.ProcessExecutionError):
797 """Error type for in target commands that fail."""
798

Subscribers

People subscribed via source and target branches