Merge lp:~gz/juju-ci-tools/winrm_copy into lp:juju-ci-tools
- winrm_copy
- Merge into trunk
| 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 |
| Related bugs: |
| 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:
|
|||
Commit Message
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.
| 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.
- 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
| 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) |

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