Merge lp:~gz/juju-ci-tools/winrm_copy into lp:juju-ci-tools

Proposed by Martin Packman on 2015-06-21
Status: Merged
Merged at revision: 1009
Proposed branch: lp:~gz/juju-ci-tools/winrm_copy
Merge into: lp:juju-ci-tools
Prerequisite: lp:~gz/juju-ci-tools/wimrm_remote
Diff against target: 810 lines (+332/-143)
5 files modified
deploy_stack.py (+64/-56)
remote.py (+99/-13)
substrate.py (+13/-0)
test_deploy_stack.py (+109/-74)
test_remote.py (+47/-0)
To merge this branch: bzr merge lp:~gz/juju-ci-tools/winrm_copy
Reviewer Review Type Date Requested Status
Curtis Hovey (community) code 2015-06-21 Approve on 2015-06-24
Gabriel Samfira 2015-06-21 Pending
Review via email: mp+262548@code.launchpad.net

Description of the Change

Enable windows deployment testing and log collection

Debugging the windows deploy test was annoying without easy access to the artifacts, so this branch adds a copy implementation on top of winrm. Also needed a bunch of refactoring of log collection methods to pass sufficient information to handle remote access correctly.

The copy scheme is mostly straight forward, there's a big blob of powershell that creates one line per file matching the given blobs, with the filename followed by the compressed-base64'd file contents. That's then parsed in python and splatted to disk.

On the powershell:
* Not easy to unit test under python on linux, I have not attempted.
* Has two functions for doing the copy, only the Binary one is used. My first pass was getting utf-16 encoded contents hence the Text version which does encoding handling. By the time I got back to comparing them, that wrinkle had mysteriously vanished, so just adapting the line-endings is not worth while.
* Needs to handle exceptions in some fashion, I want help with this. Currently you get stderr output but still a 0 return code, and unreadable files would break things.

The decode in python is pretty dumb, it would ideally stream, but that's not well supported in the pywinrm library yet. Currently it holds all files deflated but only one file inflated in memory at once, so is not completely terrible. We redo the compression later in the chain as gzip for logs, but that's fine especially as we have little control on the ratio under windows.

I do not like the way I've changed the maas address special-casing. The first attempt introduced a bug because changing the address attribute of a WinRmRemote object did not actually work, and it's still icky. Doing only one maas lookup rather than one per machine is a requirement though.

To post a comment you must log in.
Curtis Hovey (sinzui) wrote :

Thank you Martin. I have a few questions and suggestions inline.

review: Needs Information (code)
Martin Packman (gz) wrote :

Responded to comments, new rev with updates is following.

Curtis Hovey (sinzui) wrote :

Oops. I forgot to add a comment to make my inline comments visible. Thank your for answering my questions and commitment to make some changes. You can merge when you are satisfied.

review: Approve (code)
lp:~gz/juju-ci-tools/winrm_copy updated on 2015-07-02
999. By Martin Packman on 2015-07-02

Update with extra tests and addresses review comments by sinzui

1000. By Martin Packman on 2015-07-02

Always skip assess_juju_run for windows charms

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'deploy_stack.py'
2--- deploy_stack.py 2015-07-01 23:08:17 +0000
3+++ deploy_stack.py 2015-07-02 00:56:56 +0000
4@@ -36,6 +36,7 @@
5 destroy_job_instances,
6 LIBVIRT_DOMAIN_RUNNING,
7 MAASAccount,
8+ resolve_remote_dns_names,
9 run_instances,
10 start_libvirt_domain,
11 stop_libvirt_domain,
12@@ -128,6 +129,8 @@
13 # package updates.
14 logging.info('Retrieving token.')
15 remote = remote_from_unit(client, "dummy-sink/0")
16+ # Update remote with real address if needed.
17+ resolve_remote_dns_names(client.env, [remote])
18 start = time.time()
19 while True:
20 if remote.is_windows():
21@@ -150,14 +153,15 @@
22
23
24 def dump_env_logs(client, bootstrap_host, directory, jenv_path=None):
25- machine_addrs = get_machines_for_logs(client, bootstrap_host)
26+ remote_machines = get_remote_machines(client, bootstrap_host)
27
28- for machine_id, addr in machine_addrs.iteritems():
29- logging.info("Retrieving logs for machine-%s", machine_id)
30+ for machine_id, remote in remote_machines.iteritems():
31+ logging.info("Retrieving logs for machine-%s using %r", machine_id,
32+ remote)
33 machine_directory = os.path.join(directory, "machine-%s" % machine_id)
34 os.mkdir(machine_directory)
35 local_state_server = client.env.local and machine_id == '0'
36- dump_logs(client, addr, machine_directory,
37+ dump_logs(client.env, remote, machine_directory,
38 local_state_server=local_state_server)
39 retain_jenv(jenv_path, directory)
40
41@@ -175,47 +179,43 @@
42 return False
43
44
45-def get_machines_for_logs(client, bootstrap_host):
46- """Return a dict of machine_id and address.
47+def get_remote_machines(client, bootstrap_host):
48+ """Return a dict of machine_id to remote machines.
49
50- With a maas environment, the maas api will be queried for the real
51- ip addresses. Otherwise, bootstrap_host may be provided to
52- override the address of machine 0.
53+ A bootstrap_host address may be provided as a fallback for machine 0 if
54+ status fails. For some providers such as MAAS the dns-name will be
55+ resolved to a real ip address using the substrate api.
56 """
57 # Try to get machine details from environment if possible.
58- machine_addrs = dict(get_machine_addrs(client))
59-
60- if client.env.config['type'] == 'maas':
61- # Maas hostnames are not resolvable, but we can adapt them to IPs.
62- with MAASAccount.manager_from_config(client.env.config) as account:
63- allocated_ips = account.get_allocated_ips()
64- for machine_id, hostname in machine_addrs.items():
65- machine_addrs[machine_id] = allocated_ips.get(hostname, hostname)
66- elif bootstrap_host:
67- # The bootstrap host overrides the status output if provided in case
68- # status failed.
69- machine_addrs['0'] = bootstrap_host
70- return machine_addrs
71-
72-
73-def get_machine_addrs(client):
74+ machines = dict(iter_remote_machines(client))
75+ # The bootstrap host is added as a fallback in case status failed.
76+ if bootstrap_host and '0' not in machines:
77+ machines['0'] = remote_from_address(bootstrap_host)
78+ # Update remote machines in place with real addresses if substrate needs.
79+ resolve_remote_dns_names(client.env, machines.itervalues())
80+ return machines
81+
82+
83+def iter_remote_machines(client):
84 try:
85 status = client.get_status()
86 except Exception as err:
87 logging.warning("Failed to retrieve status for dumping logs: %s", err)
88 return
89+
90 for machine_id, machine in status.iter_machines():
91 hostname = machine.get('dns-name')
92 if hostname:
93- yield machine_id, hostname
94-
95-
96-def dump_logs(client, host, directory, local_state_server=False):
97+ remote = remote_from_address(hostname, machine.get('series'))
98+ yield machine_id, remote
99+
100+
101+def dump_logs(env, remote, directory, local_state_server=False):
102 try:
103 if local_state_server:
104- copy_local_logs(directory, client)
105+ copy_local_logs(env, directory)
106 else:
107- copy_remote_logs(host, directory)
108+ copy_remote_logs(remote, directory)
109 subprocess.check_call(
110 ['gzip', '-f'] +
111 glob.glob(os.path.join(directory, '*.log')))
112@@ -227,8 +227,8 @@
113 lxc_template_glob = '/var/lib/juju/containers/juju-*-lxc-template/*.log'
114
115
116-def copy_local_logs(directory, client):
117- local = get_local_root(get_juju_home(), client.env)
118+def copy_local_logs(env, directory):
119+ local = get_local_root(get_juju_home(), env)
120 log_names = [os.path.join(local, 'cloud-init-output.log')]
121 log_names.extend(glob.glob(os.path.join(local, 'log', '*.log')))
122 log_names.extend(glob.glob(lxc_template_glob))
123@@ -237,28 +237,33 @@
124 subprocess.check_call(['cp'] + log_names + [directory])
125
126
127-def copy_remote_logs(host, directory):
128+def copy_remote_logs(remote, directory):
129 """Copy as many logs from the remote host as possible to the directory."""
130 # This list of names must be in the order of creation to ensure they
131 # are retrieved.
132- log_paths = [
133- '/var/log/cloud-init*.log',
134- '/var/log/juju/*.log',
135- ]
136- remote = remote_from_address(address=host)
137-
138- try:
139- wait_for_port(host, 22, timeout=60)
140- except PortTimeoutError:
141- logging.warning("Could not dump logs because port 22 was closed.")
142- return
143-
144- try:
145- remote.run('sudo chmod go+r /var/log/juju/*')
146- except subprocess.CalledProcessError as e:
147- # The juju log dir is not created until after cloud-init succeeds.
148- logging.warning("Could not change the permission of the juju logs:")
149- logging.warning(e.output)
150+ if remote.is_windows():
151+ log_paths = [
152+ "%ProgramFiles(x86)%\\Cloudbase Solutions\\Cloudbase-Init\\log\\*",
153+ "C:\\Juju\\log\\juju\\*.log",
154+ ]
155+ else:
156+ log_paths = [
157+ '/var/log/cloud-init*.log',
158+ '/var/log/juju/*.log',
159+ ]
160+
161+ try:
162+ wait_for_port(remote.address, 22, timeout=60)
163+ except PortTimeoutError:
164+ logging.warning("Could not dump logs because port 22 was closed.")
165+ return
166+
167+ try:
168+ remote.run('sudo chmod go+r /var/log/juju/*')
169+ except subprocess.CalledProcessError as e:
170+ # The juju log dir is not created until after cloud-init succeeds.
171+ logging.warning("Could not allow access to the juju logs:")
172+ logging.warning(e.output)
173
174 try:
175 remote.copy(directory, log_paths)
176@@ -284,7 +289,7 @@
177 return responses
178
179
180-def assess_upgrade(old_client, juju_path):
181+def assess_upgrade(old_client, juju_path, skip_juju_run=False):
182 client = EnvJujuClient.by_version(old_client.env, juju_path,
183 old_client.debug)
184 upgrade_juju(client)
185@@ -293,7 +298,8 @@
186 else:
187 timeout = 600
188 client.wait_for_version(client.get_matching_agent_version(), timeout)
189- assess_juju_run(client)
190+ if not skip_juju_run:
191+ assess_juju_run(client)
192 token = get_random_string()
193 client.juju('set', ('dummy-source', 'token=%s' % token))
194 check_token(client, token)
195@@ -502,10 +508,12 @@
196 return
197 client.juju('status', ())
198 deploy_dummy_stack(client, charm_prefix)
199- assess_juju_run(client)
200+ is_windows_charm = charm_prefix.startswith("local:win")
201+ if not is_windows_charm:
202+ assess_juju_run(client)
203 if upgrade:
204 client.juju('status', ())
205- assess_upgrade(client, juju_path)
206+ assess_upgrade(client, juju_path, skip_juju_run=is_windows_charm)
207
208
209 def run_deployer():
210
211=== modified file 'remote.py'
212--- remote.py 2015-06-24 15:32:05 +0000
213+++ remote.py 2015-07-02 00:56:56 +0000
214@@ -4,11 +4,12 @@
215
216 import abc
217 import logging
218+import os
219 import subprocess
220+import zlib
221
222 import winrm
223
224-from substrate import MAASAccount
225 import utility
226
227
228@@ -38,7 +39,7 @@
229
230
231 class _Remote:
232- """Remote represents a juju machine to access over the network."""
233+ """_Remote represents a juju machine to access over the network."""
234
235 __metaclass__ = abc.ABCMeta
236
237@@ -79,6 +80,15 @@
238 """Returns True if remote machine is running windows."""
239 return self.series and self.series.startswith("win")
240
241+ def get_address(self):
242+ """Gives the address of the remote machine."""
243+ self._ensure_address()
244+ return self.address
245+
246+ def update_address(self, address):
247+ """Change address of remote machine."""
248+ self.address = address
249+
250 def _get_status(self):
251 if self.status is None:
252 self.status = self.client.get_status()
253@@ -92,17 +102,10 @@
254 status = self._get_status()
255 unit = status.get_unit(self.unit)
256 self.address = unit['public-address']
257- # TODO(gz): Avoid special casing MAAS here and in deploy_stack
258- if self.client.env.config['type'] == 'maas':
259- config = self.client.env.config
260- with MAASAccount.manager_from_config(config) as account:
261- allocated_ips = account.get_allocated_ips()
262- if self.address in allocated_ips:
263- self.address = allocated_ips[self.address]
264
265
266 class SSHRemote(_Remote):
267- """Remote represents a juju machine to access over the network."""
268+ """SSHRemote represents a juju machine to access using ssh."""
269
270 _ssh_opts = [
271 "-o", "User ubuntu",
272@@ -158,13 +161,60 @@
273 cert_key_pem=key, cert_pem=cert)
274
275
276+_ps_copy_script = """\
277+function CopyText {
278+ param([String]$file, [IO.Stream]$outstream)
279+ $enc = New-Object Text.UTF8Encoding($False)
280+ $us = New-Object IO.StreamWriter($outstream, $enc)
281+ $us.NewLine = "\n"
282+ $uin = New-Object IO.StreamReader($file)
283+ while (($line = $uin.ReadLine()) -ne $Null) {
284+ $us.WriteLine($line)
285+ }
286+ $uin.Close()
287+ $us.Close()
288+}
289+
290+function CopyBinary {
291+ param([String]$file, [IO.Stream]$outstream)
292+ $in = New-Object IO.FileStream($file, [IO.FileMode]::Open,
293+ [IO.FileAccess]::Read, [IO.FileShare]"ReadWrite,Delete")
294+ $in.CopyTo($outstream)
295+ $in.Close()
296+}
297+
298+ForEach ($pattern in %s) {
299+ $path = [Environment]::ExpandEnvironmentVariables($pattern)
300+ $files = Get-Item -path $path
301+ ForEach ($file in $files) {
302+ [Console]::Out.Write($file.name + "|")
303+ $trans = New-Object Security.Cryptography.ToBase64Transform
304+ $out = [Console]::OpenStandardOutput()
305+ $bs = New-Object Security.Cryptography.CryptoStream($out, $trans,
306+ [Security.Cryptography.CryptoStreamMode]::Write)
307+ $zs = New-Object IO.Compression.DeflateStream($bs,
308+ [IO.Compression.CompressionMode]::Compress)
309+ CopyBinary -file $file -outstream $zs
310+ $zs.Close()
311+ [Console]::Out.Write("\n")
312+ }
313+}
314+"""
315+
316+
317 class WinRmRemote(_Remote):
318+ """WinRmRemote represents a juju machine to access using winrm."""
319
320 def __init__(self, *args, **kwargs):
321 super(WinRmRemote, self).__init__(*args, **kwargs)
322 self._ensure_address()
323- certs = utility.get_winrm_certs()
324- self.session = _SSLSession(self.address, certs)
325+ self.certs = utility.get_winrm_certs()
326+ self.session = _SSLSession(self.address, self.certs)
327+
328+ def update_address(self, address):
329+ """Change address of remote machine, refreshes the winrm session."""
330+ self.address = address
331+ self.session = _SSLSession(self.address, self.certs)
332
333 _escape = staticmethod(subprocess.list2cmdline)
334
335@@ -196,4 +246,40 @@
336
337 def copy(self, destination_dir, source_globs):
338 """Copy files from the remote machine."""
339- raise NotImplementedError
340+ # Encode globs into script to run on remote machine and return result.
341+ script = _ps_copy_script % ",".join(s.join('""') for s in source_globs)
342+ result = self.run_ps(script)
343+ if result.std_err:
344+ logging.warning("winrm copy stderr:\n%s", result.std_err)
345+ if result.status_code:
346+ raise subprocess.CalledProcessError(result.status_code,
347+ "powershell", result)
348+ self._encoded_copy_to_dir(destination_dir, result.std_out)
349+
350+ @staticmethod
351+ def _encoded_copy_to_dir(destination_dir, output):
352+ """Write remote files from powershell script to disk.
353+
354+ The given output from the powershell script is one line per file, with
355+ the filename first, then a pipe, then the base64 encoded deflated file
356+ contents. This method reverses that process and creates the files in
357+ the given destination_dir.
358+ """
359+ start = 0
360+ while True:
361+ end = output.find("\n", start)
362+ if end == -1:
363+ break
364+ mid = output.find("|", start, end)
365+ if mid == -1:
366+ if not output[start:end].rstrip("\r\n"):
367+ break
368+ raise ValueError("missing filename in encoded copy data")
369+ filename = output[start:mid]
370+ if "/" in filename:
371+ # Just defense against path traversal bugs, should never reach.
372+ raise ValueError("path not filename {!r}".format(filename))
373+ with open(os.path.join(destination_dir, filename), "wb") as f:
374+ f.write(zlib.decompress(output[mid+1:end].decode("base64"),
375+ -zlib.MAX_WBITS))
376+ start = end + 1
377
378=== modified file 'substrate.py'
379--- substrate.py 2015-07-01 21:30:33 +0000
380+++ substrate.py 2015-07-02 00:56:56 +0000
381@@ -588,3 +588,16 @@
382 if len(instances) == 0:
383 return
384 subprocess.check_call(['euca-terminate-instances'] + instances)
385+
386+
387+def resolve_remote_dns_names(env, remote_machines):
388+ """Update addresses of given remote_machines as needed by providers."""
389+ if env.config['type'] != 'maas':
390+ # Only MAAS requires special handling at prsent.
391+ return
392+ # MAAS hostnames are not resolvable, but we can adapt them to IPs.
393+ with MAASAccount.manager_from_config(env.config) as account:
394+ allocated_ips = account.get_allocated_ips()
395+ for remote in remote_machines:
396+ if remote.get_address() in allocated_ips:
397+ remote.update_address(allocated_ips[remote.address])
398
399=== modified file 'test_deploy_stack.py'
400--- test_deploy_stack.py 2015-07-01 23:08:17 +0000
401+++ test_deploy_stack.py 2015-07-02 00:56:56 +0000
402@@ -30,8 +30,8 @@
403 dump_logs,
404 get_juju_path,
405 get_log_level,
406- get_machine_addrs,
407- get_machines_for_logs,
408+ iter_remote_machines,
409+ get_remote_machines,
410 GET_TOKEN_SCRIPT,
411 prepare_environment,
412 assess_upgrade,
413@@ -47,6 +47,9 @@
414 SimpleEnvironment,
415 Status,
416 )
417+from remote import (
418+ remote_from_address,
419+)
420 from test_jujupy import (
421 assert_juju_call,
422 )
423@@ -65,14 +68,6 @@
424 return write_dumped_files
425
426
427-def get_machine_addresses():
428- return {
429- '0': '10.10.0.1',
430- '1': '10.10.0.11',
431- '2': '10.10.0.22',
432- }
433-
434-
435 class ArgParserTestCase(TestCase):
436
437 def test_add_path_args(self):
438@@ -228,14 +223,24 @@
439 def setUp(self):
440 setup_test_logging(self, level=logging.DEBUG)
441
442+ def assert_machines(self, expected, got):
443+ self.assertEqual(expected, dict((k, got[k].address) for k in got))
444+
445+ r0 = remote_from_address('10.10.0.1')
446+ r1 = remote_from_address('10.10.0.11')
447+ r2 = remote_from_address('10.10.0.22')
448+
449+ @classmethod
450+ def fake_remote_machines(cls):
451+ return {'0': cls.r0, '1': cls.r1, '2': cls.r2}
452+
453 def test_dump_env_logs_non_local_env(self):
454 with temp_dir() as artifacts_dir:
455- with patch('deploy_stack.get_machines_for_logs', autospec=True,
456- return_value=get_machine_addresses()) as gm_mock:
457+ with patch('deploy_stack.get_remote_machines', autospec=True,
458+ return_value=self.fake_remote_machines()) as gm_mock:
459 with patch('deploy_stack.dump_logs', autospec=True) as dl_mock:
460- client = EnvJujuClient(
461- SimpleEnvironment(
462- 'foo', {'type': 'nonlocal'}), '1.234-76', None)
463+ env = SimpleEnvironment('foo', {'type': 'nonlocal'})
464+ client = EnvJujuClient(env, '1.234-76', None)
465 dump_env_logs(client, '10.10.0.1', artifacts_dir)
466 self.assertEqual(
467 ['machine-0', 'machine-1', 'machine-2'],
468@@ -244,75 +249,73 @@
469 (client, '10.10.0.1'), gm_mock.call_args[0])
470 call_list = sorted((cal[0], cal[1]) for cal in dl_mock.call_args_list)
471 self.assertEqual(
472- [((client, '10.10.0.1', '%s/machine-0' % artifacts_dir),
473- {'local_state_server': False}),
474- ((client, '10.10.0.11', '%s/machine-1' % artifacts_dir),
475- {'local_state_server': False}),
476- ((client, '10.10.0.22', '%s/machine-2' % artifacts_dir),
477+ [((env, self.r0, '%s/machine-0' % artifacts_dir),
478+ {'local_state_server': False}),
479+ ((env, self.r1, '%s/machine-1' % artifacts_dir),
480+ {'local_state_server': False}),
481+ ((env, self.r2, '%s/machine-2' % artifacts_dir),
482 {'local_state_server': False})],
483 call_list)
484 self.assertEqual(
485- ['INFO Retrieving logs for machine-0',
486- 'INFO Retrieving logs for machine-1',
487- 'INFO Retrieving logs for machine-2'],
488+ ['INFO Retrieving logs for machine-0 using ' + repr(self.r0),
489+ 'INFO Retrieving logs for machine-1 using ' + repr(self.r1),
490+ 'INFO Retrieving logs for machine-2 using ' + repr(self.r2)],
491 sorted(self.log_stream.getvalue().splitlines()))
492
493 def test_dump_env_logs_local_env(self):
494 with temp_dir() as artifacts_dir:
495- with patch('deploy_stack.get_machines_for_logs', autospec=True,
496- return_value=get_machine_addresses()):
497+ with patch('deploy_stack.get_remote_machines', autospec=True,
498+ return_value=self.fake_remote_machines()):
499 with patch('deploy_stack.dump_logs', autospec=True) as dl_mock:
500- client = EnvJujuClient(
501- SimpleEnvironment(
502- 'foo', {'type': 'local'}), '1.234-76', None)
503+ env = SimpleEnvironment('foo', {'type': 'local'})
504+ client = EnvJujuClient(env, '1.234-76', None)
505 dump_env_logs(client, '10.10.0.1', artifacts_dir)
506 call_list = sorted((cal[0], cal[1]) for cal in dl_mock.call_args_list)
507 self.assertEqual(
508- [((client, '10.10.0.1', '%s/machine-0' % artifacts_dir),
509+ [((env, self.r0, '%s/machine-0' % artifacts_dir),
510 {'local_state_server': True}),
511- ((client, '10.10.0.11', '%s/machine-1' % artifacts_dir),
512+ ((env, self.r1, '%s/machine-1' % artifacts_dir),
513 {'local_state_server': False}),
514- ((client, '10.10.0.22', '%s/machine-2' % artifacts_dir),
515+ ((env, self.r2, '%s/machine-2' % artifacts_dir),
516 {'local_state_server': False})],
517 call_list)
518
519 def test_dump_logs_with_local_state_server_false(self):
520 # copy_remote_logs is called for non-local envs.
521- client = EnvJujuClient(
522- SimpleEnvironment('foo', {'type': 'nonlocal'}), '1.234-76', None)
523+ env = SimpleEnvironment('foo', {'type': 'nonlocal'})
524+ remote = object()
525 with temp_dir() as log_dir:
526 with patch('deploy_stack.copy_local_logs',
527 autospec=True) as cll_mock:
528 with patch('deploy_stack.copy_remote_logs', autospec=True,
529 side_effect=make_logs(log_dir)) as crl_mock:
530- dump_logs(client, '10.10.0.1', log_dir,
531+ dump_logs(env, remote, log_dir,
532 local_state_server=False)
533 self.assertEqual(['cloud.log.gz', 'extra'],
534 sorted(os.listdir(log_dir)))
535 self.assertEqual(0, cll_mock.call_count)
536- self.assertEqual(('10.10.0.1', log_dir), crl_mock.call_args[0])
537+ self.assertEqual((remote, log_dir), crl_mock.call_args[0])
538
539 def test_dump_logs_with_local_state_server_true(self):
540 # copy_local_logs is called for machine 0 in a local env.
541- client = EnvJujuClient(
542- SimpleEnvironment('foo', {'type': 'local'}), '1.234-76', None)
543+ env = SimpleEnvironment('foo', {'type': 'local'})
544+ remote = object()
545 with temp_dir() as log_dir:
546 with patch('deploy_stack.copy_local_logs', autospec=True,
547 side_effect=make_logs(log_dir)) as cll_mock:
548 with patch('deploy_stack.copy_remote_logs',
549 autospec=True) as crl_mock:
550- dump_logs(client, '10.10.0.1', log_dir,
551+ dump_logs(env, remote, log_dir,
552 local_state_server=True)
553 self.assertEqual(['cloud.log.gz', 'extra'],
554 sorted(os.listdir(log_dir)))
555- self.assertEqual((log_dir, client), cll_mock.call_args[0])
556+ self.assertEqual((env, log_dir), cll_mock.call_args[0])
557 self.assertEqual(0, crl_mock.call_count)
558
559 def test_copy_local_logs(self):
560 # Relevent local log files are copied, after changing their permissions
561 # to allow access by non-root user.
562- client = EnvJujuClient(
563- SimpleEnvironment('a-local', {'type': 'local'}), '1.234-76', None)
564+ env = SimpleEnvironment('a-local', {'type': 'local'})
565 with temp_dir() as juju_home_dir:
566 log_dir = os.path.join(juju_home_dir, "a-local", "log")
567 os.makedirs(log_dir)
568@@ -325,7 +328,7 @@
569 with patch('deploy_stack.lxc_template_glob',
570 os.path.join(template_dir, "*.log")):
571 with patch('subprocess.check_call') as cc_mock:
572- copy_local_logs('/destination/dir', client)
573+ copy_local_logs(env, '/destination/dir')
574 expected_files = [os.path.join(juju_home_dir, *p) for p in (
575 ('a-local', 'cloud-init-output.log'),
576 ('a-local', 'log', 'all-machines.log'),
577@@ -342,7 +345,7 @@
578 # to ensure errors do not prevent some logs from being retrieved.
579 with patch('deploy_stack.wait_for_port', autospec=True):
580 with patch('subprocess.check_output') as cc_mock:
581- copy_remote_logs('10.10.0.1', '/foo')
582+ copy_remote_logs(remote_from_address('10.10.0.1'), '/foo')
583 self.assertEqual(
584 (['timeout', '5m', 'ssh',
585 '-o', 'User ubuntu',
586@@ -361,6 +364,16 @@
587 '/foo'],),
588 cc_mock.call_args_list[1][0])
589
590+ def test_copy_remote_logs_windows(self):
591+ remote = remote_from_address('10.10.0.1', series="win2012hvr2")
592+ with patch.object(remote, "copy", autospec=True) as copy_mock:
593+ copy_remote_logs(remote, '/foo')
594+ paths = [
595+ "%ProgramFiles(x86)%\\Cloudbase Solutions\\Cloudbase-Init\\log\\*",
596+ "C:\\Juju\\log\\juju\\*.log",
597+ ]
598+ copy_mock.assert_called_once_with("/foo", paths)
599+
600 def test_copy_remote_logs_with_errors(self):
601 # Ssh and scp errors will happen when /var/log/juju doesn't exist yet,
602 # but we log the case anc continue to retrieve as much as we can.
603@@ -372,10 +385,10 @@
604
605 with patch('subprocess.check_output', side_effect=remote_op) as co:
606 with patch('deploy_stack.wait_for_port', autospec=True):
607- copy_remote_logs('10.10.0.1', '/foo')
608+ copy_remote_logs(remote_from_address('10.10.0.1'), '/foo')
609 self.assertEqual(2, co.call_count)
610 self.assertEqual(
611- ['WARNING Could not change the permission of the juju logs:',
612+ ['WARNING Could not allow access to the juju logs:',
613 'WARNING None',
614 'WARNING Could not retrieve some or all logs:',
615 'WARNING None'],
616@@ -393,9 +406,9 @@
617 """)
618 with patch.object(client, 'get_status', autospec=True,
619 return_value=status):
620- machine_addrs = get_machines_for_logs(client, None)
621- self.assertEqual(
622- {'0': '10.11.12.13', '1': '10.11.12.14'}, machine_addrs)
623+ machines = get_remote_machines(client, None)
624+ self.assert_machines(
625+ {'0': '10.11.12.13', '1': '10.11.12.14'}, machines)
626
627 def test_get_machines_for_logs_with_boostrap_host(self):
628 client = EnvJujuClient(
629@@ -403,23 +416,23 @@
630 status = Status.from_text("""\
631 machines:
632 "0":
633- dns-name: 10.11.12.13
634+ instance-id: pending
635 """)
636 with patch.object(client, 'get_status', autospec=True,
637 return_value=status):
638- machine_addrs = get_machines_for_logs(client, '10.11.111.222')
639- self.assertEqual({'0': '10.11.111.222'}, machine_addrs)
640+ machines = get_remote_machines(client, '10.11.111.222')
641+ self.assert_machines({'0': '10.11.111.222'}, machines)
642
643 def test_get_machines_for_logs_with_no_addresses(self):
644 client = EnvJujuClient(
645 SimpleEnvironment('cloud', {'type': 'ec2'}), '1.23.4', None)
646 with patch.object(client, 'get_status', autospec=True,
647 side_effect=Exception):
648- machine_addrs = get_machines_for_logs(client, '10.11.111.222')
649- self.assertEqual({'0': '10.11.111.222'}, machine_addrs)
650+ machines = get_remote_machines(client, '10.11.111.222')
651+ self.assert_machines({'0': '10.11.111.222'}, machines)
652
653 @patch('subprocess.check_call')
654- def test_get_machines_for_logs_with_maas(self, cc_mock):
655+ def test_get_remote_machines_with_maas(self, cc_mock):
656 config = {
657 'type': 'maas',
658 'name': 'foo',
659@@ -442,25 +455,46 @@
660 }
661 with patch('deploy_stack.MAASAccount.get_allocated_ips',
662 autospec=True, return_value=allocated_ips):
663- machine_addrs = get_machines_for_logs(client, 'node1.maas')
664- self.assertEqual(
665- {'0': '10.11.12.13', '1': '10.11.12.14'}, machine_addrs)
666-
667- def test_get_machine_addrs(self):
668- client = EnvJujuClient(
669- SimpleEnvironment('cloud', {'type': 'ec2'}), '1.23.4', None)
670- status = Status.from_text("""\
671- machines:
672- "0":
673- dns-name: 10.11.12.13
674- "1":
675- dns-name: 10.11.12.14
676- """)
677- with patch.object(client, 'get_status', autospec=True,
678- return_value=status):
679- machine_addrs = [ma for ma in get_machine_addrs(client)]
680- self.assertEqual(
681- [('0', '10.11.12.13'), ('1', '10.11.12.14')], machine_addrs)
682+ machines = get_remote_machines(client, 'node1.maas')
683+ self.assert_machines(
684+ {'0': '10.11.12.13', '1': '10.11.12.14'}, machines)
685+
686+ def test_iter_remote_machines(self):
687+ client = EnvJujuClient(
688+ SimpleEnvironment('cloud', {'type': 'ec2'}), '1.23.4', None)
689+ status = Status.from_text("""\
690+ machines:
691+ "0":
692+ dns-name: 10.11.12.13
693+ "1":
694+ dns-name: 10.11.12.14
695+ """)
696+ with patch.object(client, 'get_status', autospec=True,
697+ return_value=status):
698+ machines = [(m, r.address)
699+ for m, r in iter_remote_machines(client)]
700+ self.assertEqual(
701+ [('0', '10.11.12.13'), ('1', '10.11.12.14')], machines)
702+
703+ def test_iter_remote_machines_with_series(self):
704+ client = EnvJujuClient(
705+ SimpleEnvironment('cloud', {'type': 'ec2'}), '1.23.4', None)
706+ status = Status.from_text("""\
707+ machines:
708+ "0":
709+ dns-name: 10.11.12.13
710+ series: trusty
711+ "1":
712+ dns-name: 10.11.12.14
713+ series: win2012hvr2
714+ """)
715+ with patch.object(client, 'get_status', autospec=True,
716+ return_value=status):
717+ machines = [(m, r.address, r.series)
718+ for m, r in iter_remote_machines(client)]
719+ self.assertEqual(
720+ [('0', '10.11.12.13', 'trusty'),
721+ ('1', '10.11.12.14', 'win2012hvr2')], machines)
722
723 def test_retain_jenv(self):
724 with temp_dir() as jenv_dir:
725@@ -485,7 +519,8 @@
726 setup_test_logging(self)
727
728 def test_deploy_dummy_stack(self):
729- client = EnvJujuClient(SimpleEnvironment('foo', {}), None, '/foo/juju')
730+ env = SimpleEnvironment('foo', {'type': 'nonlocal'})
731+ client = EnvJujuClient(env, None, '/foo/juju')
732 status = yaml.safe_dump({
733 'machines': {'0': {'agent-state': 'started'}},
734 'services': {
735
736=== modified file 'test_remote.py'
737--- test_remote.py 2015-06-20 17:32:27 +0000
738+++ test_remote.py 2015-07-02 00:56:56 +0000
739@@ -2,6 +2,7 @@
740
741 import logging
742 from mock import patch
743+import os
744 from StringIO import StringIO
745 import subprocess
746 import unittest
747@@ -16,6 +17,10 @@
748 from remote import (
749 remote_from_address,
750 remote_from_unit,
751+ WinRmRemote,
752+)
753+from utility import (
754+ temp_dir,
755 )
756
757
758@@ -198,6 +203,27 @@
759 "/local/path",
760 ])
761
762+ def test_copy_on_windows(self):
763+ env = SimpleEnvironment("an-env", {"type": "nonlocal"})
764+ client = EnvJujuClient(env, None, None)
765+ unit = "a-service/0"
766+ dest = "/local/path"
767+ with patch.object(client, "get_status", autospec=True) as st:
768+ st.return_value = Status.from_text(self.win2012hvr2_status_output)
769+ response = winrm.Response(("fake output", "", 0))
770+ remote = remote_from_unit(client, unit)
771+ with patch.object(remote.session, "run_ps", autospec=True,
772+ return_value=response) as mock_run:
773+ with patch.object(remote, "_encoded_copy_to_dir",
774+ autospec=True) as mock_cpdir:
775+ remote.copy(dest, ["C:\\logs\\*", "%APPDATA%\\*.log"])
776+ mock_cpdir.assert_called_once_with(dest, "fake output")
777+ st.assert_called_once_with()
778+ self.assertEquals(mock_run.call_count, 1)
779+ self.assertRegexpMatches(
780+ mock_run.call_args[0][0],
781+ r'.*"C:\\logs\\[*]","%APPDATA%\\[*].log".*')
782+
783 def test_run_cmd(self):
784 env = SimpleEnvironment("an-env", {"type": "nonlocal"})
785 client = EnvJujuClient(env, None, None)
786@@ -214,3 +240,24 @@
787 st.assert_called_once_with()
788 mock_run.assert_called_once_with(
789 '"C:\\Program Files\\bin.exe"', ['/IN "Bob\'s Stuff"'])
790+
791+ def test_encoded_copy_to_dir_one(self):
792+ output = "testfile|K0ktLuECAA==\r\n"
793+ with temp_dir() as dest:
794+ WinRmRemote._encoded_copy_to_dir(dest, output)
795+ with open(os.path.join(dest, "testfile")) as f:
796+ self.assertEqual(f.read(), "test\n")
797+
798+ def test_encoded_copy_to_dir_many(self):
799+ output = "test one|K0ktLuECAA==\r\ntest two|K0ktLuECAA==\r\n\r\n"
800+ with temp_dir() as dest:
801+ WinRmRemote._encoded_copy_to_dir(dest, output)
802+ for name in ("test one", "test two"):
803+ with open(os.path.join(dest, name)) as f:
804+ self.assertEqual(f.read(), "test\n")
805+
806+ def test_encoded_copy_traversal_guard(self):
807+ output = "../../../etc/passwd|K0ktLuECAA==\r\n"
808+ with temp_dir() as dest:
809+ with self.assertRaises(ValueError):
810+ WinRmRemote._encoded_copy_to_dir(dest, output)

Subscribers

People subscribed via source and target branches