Merge lp:~jimbaker/pyjuju/ssh-passthrough into lp:pyjuju

Proposed by Jim Baker
Status: Merged
Approved by: Kapil Thangavelu
Approved revision: 412
Merged at revision: 420
Proposed branch: lp:~jimbaker/pyjuju/ssh-passthrough
Merge into: lp:pyjuju
Diff against target: 393 lines (+219/-17)
6 files modified
juju/control/__init__.py (+20/-4)
juju/control/command.py (+2/-1)
juju/control/ssh.py (+32/-9)
juju/control/tests/test_ssh.py (+64/-2)
juju/control/tests/test_utils.py (+48/-1)
juju/control/utils.py (+53/-0)
To merge this branch: bzr merge lp:~jimbaker/pyjuju/ssh-passthrough
Reviewer Review Type Date Requested Status
Kapil Thangavelu (community) Approve
William Reade (community) Approve
Review via email: mp+79888@code.launchpad.net

Description of the change

Implements the ability to passthrough args to an underlying command, in particular to support juju ssh. This support enables using juju ssh in the following ways:

$ juju ssh mysql/0 ls /

$ juju ssh -L8080:localhost:80 wordpress/0

and so forth, any options are simply passed through, along with the ssh command.

This requires working around argparse a bit, but only through its public API. In particular, main now uses parse_known_args to see if the command module in question supports the passthrough function; it if it does, that function is used to further decorate options from any extra args. Then command execution proceeds as before.

To post a comment you must log in.
Revision history for this message
William Reade (fwereade) wrote :

[0] trivial

+ "[passthru ssh options] unit_or_machine [command]"),

s/passthru/passthrough

[1] trivial

+ # workding with ssh, for example), so consume the rest and

s/workding/working

[2] less trivial

I didn't see any way to handle potential conflicts in args (specifically -o ControlMaster/ControlPath, which I think are the only ones we need to worry about at the moment). Not really a big deal, but something it might be worth logging a "low" bug against, to make it clear that the limitation is known but not a priority?

anyway, +1

review: Approve
Revision history for this message
Jim Baker (jimbaker) wrote :

> [0] trivial
>
> + "[passthru ssh options] unit_or_machine [command]"),
>
> s/passthru/passthrough

Thanks, let's keep it consistent. (Both seem to be used, but passthrough more so. Or perhaps pass-through.)

>
> [1] trivial
>
> + # workding with ssh, for example), so consume the rest and
>
> s/workding/working

Ack

>
> [2] less trivial
>
> I didn't see any way to handle potential conflicts in args (specifically -o
> ControlMaster/ControlPath, which I think are the only ones we need to worry
> about at the moment). Not really a big deal, but something it might be worth
> logging a "low" bug against, to make it clear that the limitation is known but
> not a priority?

Indeed, it's possible for the user to escape the walled garden and use ssh flags in bad ways as well as good. Passthrough flags are presented later to ssh, and can thereby override juju's default usage. In the particular example you cite, this enables the user to know what is best for a given scenario. For example, multiplexing a connection would not be so ideal if there's significant data transfer. Until now this was a moot point, but with tunnel support, this need could actually arise, and it can be simply solved by passing in -o "ControlMaster no".

I don't think it's a low bug, just simply a matter of adding to the documentation of this command.

>
> anyway, +1

Thanks!

Revision history for this message
Jim Baker (jimbaker) wrote :

> I don't think it's a low bug, just simply a matter of adding to the
> documentation of this command.

To be precise: it should be fixed in the context of this branch.

Revision history for this message
Jim Baker (jimbaker) wrote :

>
> > I don't think it's a low bug, just simply a matter of adding to the
> > documentation of this command.
>
> To be precise: it should be fixed in the context of this branch.

irc discussion:

<jimbaker> fwereade, i think i'm going to change my mind on the doc stuff related to juju ssh. the command itself is not documented. really we need to incorporate more detailed docs on each subcommand in juju
<jimbaker> fwereade, so that should be done in a separate branch/bug
<fwereade> jimbaker, sounds sensible to me

lp:~jimbaker/pyjuju/ssh-passthrough updated
410. By Jim Baker

Fix doc typo

411. By Jim Baker

Merged trunk

412. By Jim Baker

Addressed review points

Revision history for this message
Kapil Thangavelu (hazmat) wrote :

Nice work jim. lgtm, a minor

[0]

+def parse_passthrough_args(args, flags_taking_arg=frozenset()):

the frozenset seems like overkill when an empty tuple suffices.

[1]

re the same function.

Is supporting long options just a matter of an additional conditional
clause for peeked.startswith("--") ? If so it seems better to just
implement support for it now.

[2] re the conflict william brought up, experimenting with the ssh cli it doesn't actually appear to be a problem in practice the user specified option will take precedence as its specified last.

review: Approve
Revision history for this message
Jim Baker (jimbaker) wrote :

> Nice work jim. lgtm, a minor
>
> [0]
>
> +def parse_passthrough_args(args, flags_taking_arg=frozenset()):
>
> the frozenset seems like overkill when an empty tuple suffices.

Sure, definite overkill.

>
> [1]
>
> re the same function.
>
> Is supporting long options just a matter of an additional conditional
> clause for peeked.startswith("--") ? If so it seems better to just
> implement support for it now.

It's not quite so simple, and so far the universe of commands that need this passthrough support is as far as I know is just ssh/scp. So I'd suggest punting that support until we identify them.
>
>
> [2] re the conflict william brought up, experimenting with the ssh cli it
> doesn't actually appear to be a problem in practice the user specified option
> will take precedence as its specified last.

My experience too.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'juju/control/__init__.py'
--- juju/control/__init__.py 2011-10-10 20:20:20 +0000
+++ juju/control/__init__.py 2011-10-20 17:26:23 +0000
@@ -4,6 +4,7 @@
4import zookeeper4import zookeeper
55
6from .command import Commander6from .command import Commander
7from .utils import ParseError
7from juju.environment.config import EnvironmentsConfig8from juju.environment.config import EnvironmentsConfig
89
9import add_relation10import add_relation
@@ -106,13 +107,15 @@
106107
107 for module in subcommands:108 for module in subcommands:
108 configure_subparser = getattr(module, "configure_subparser", None)109 configure_subparser = getattr(module, "configure_subparser", None)
110 passthrough = getattr(module, "passthrough", None)
109 if configure_subparser:111 if configure_subparser:
110 sub_parser = configure_subparser(subparsers)112 sub_parser = configure_subparser(subparsers)
111 else:113 else:
112 sub_parser = subparsers.add_parser(114 sub_parser = subparsers.add_parser(
113 module.__name__.split('.')[-1], help=module.command.__doc__)115 module.__name__.split('.')[-1], help=module.command.__doc__)
114 sub_parser.set_defaults(command=Commander(module.command),116 sub_parser.set_defaults(
115 parser=sub_parser)117 command=Commander(module.command, passthrough=passthrough),
118 parser=sub_parser)
116119
117 return parser120 return parser
118121
@@ -157,7 +160,20 @@
157 prog="juju",160 prog="juju",
158 description="juju cloud orchestration admin")161 description="juju cloud orchestration admin")
159 parser.set_defaults(environments=env_config, log=log)162 parser.set_defaults(environments=env_config, log=log)
160 options = parser.parse_args(args)163
164 # Some commands, like juju ssh, do a further parse on options by
165 # delegating to another command (such as the underlying ssh). But
166 # first need to parse nonstrictly all args to even determine what
167 # command is even being used.
168 options, extra = parser.parse_known_args(args)
169 if options.command.passthrough:
170 try:
171 # Augments options with subparser specific passthrough parsing
172 options.command.passthrough(options, extra)
173 except ParseError, e:
174 options.parser.error(str(e))
175 else:
176 # Otherwise, do be strict
177 options = parser.parse_args(args)
161 setup_logging(options)178 setup_logging(options)
162
163 options.command(options)179 options.command(options)
164180
=== modified file 'juju/control/command.py'
--- juju/control/command.py 2011-09-15 18:50:23 +0000
+++ juju/control/command.py 2011-10-20 17:26:23 +0000
@@ -19,12 +19,13 @@
1919
20 """20 """
2121
22 def __init__(self, callback):22 def __init__(self, callback, passthrough=False):
23 if not callable(callback):23 if not callable(callback):
24 raise ValueError(24 raise ValueError(
25 "Commander callback argument must be a callable")25 "Commander callback argument must be a callable")
2626
27 self.callback = callback27 self.callback = callback
28 self.passthrough = passthrough
28 self.options = None29 self.options = None
29 self.exit_code = 030 self.exit_code = 0
3031
3132
=== modified file 'juju/control/ssh.py'
--- juju/control/ssh.py 2011-09-25 14:05:19 +0000
+++ juju/control/ssh.py 2011-10-20 17:26:23 +0000
@@ -1,8 +1,10 @@
1from argparse import RawDescriptionHelpFormatter
1import os2import os
23
3from twisted.internet.defer import inlineCallbacks, returnValue4from twisted.internet.defer import inlineCallbacks, returnValue
45
5from juju.control.utils import get_environment6from juju.control.utils import (
7 get_environment, parse_passthrough_args, ParseError)
6from juju.state.errors import (8from juju.state.errors import (
7 ServiceUnitStateMachineNotAssigned, MachineStateNotFound)9 ServiceUnitStateMachineNotAssigned, MachineStateNotFound)
8from juju.state.machine import MachineStateManager10from juju.state.machine import MachineStateManager
@@ -11,14 +13,34 @@
1113
1214
13def configure_subparser(subparsers):15def configure_subparser(subparsers):
14 sub_parser = subparsers.add_parser("ssh", help=command.__doc__)16 sub_parser = subparsers.add_parser(
15 sub_parser.add_argument(17 "ssh",
16 "unit_or_machine", help="Name of unit or machine")18 help=command.__doc__,
19 usage=("%(prog)s [-h] [--environment ENVIRONMENT] "
20 "[passthrough ssh options] unit_or_machine [command]"),
21 formatter_class=RawDescriptionHelpFormatter,
22 description=(
23 "positional arguments:\n"
24 " unit_or_machine Name of unit or machine\n"
25 " [command] Optional command to run on machine"))
17 sub_parser.add_argument(26 sub_parser.add_argument(
18 "--environment", "-e", help="Environment to operate on.")27 "--environment", "-e", help="Environment to operate on.")
19 return sub_parser28 return sub_parser
2029
2130
31def passthrough(options, extra):
32 """Second parsing phase to parse `extra` to passthrough to ssh itself.
33
34 Partitions into flags, unit_or_machine, and optional ssh command.
35 """
36 flags, positional = parse_passthrough_args(extra, "bcDeFIiLlmOopRSWw")
37 if not positional:
38 raise ParseError("too few arguments")
39 options.ssh_flags = flags
40 options.unit_or_machine = positional.pop(0)
41 options.ssh_command = positional # if any
42
43
22@inlineCallbacks44@inlineCallbacks
23def get_ip_address_for_unit(client, provider, unit_name):45def get_ip_address_for_unit(client, provider, unit_name):
24 manager = ServiceStateManager(client)46 manager = ServiceStateManager(client)
@@ -41,13 +63,15 @@
41 returnValue((provider_machine.dns_name, machine_state))63 returnValue((provider_machine.dns_name, machine_state))
4264
4365
44def open_ssh(ip_address):66def open_ssh(flags, ip_address, ssh_command):
45 # XXX - TODO - Might be nice if we had the ability to get the user's67 # XXX - TODO - Might be nice if we had the ability to get the user's
46 # private key path and utilize it here, ie the symmetric end to68 # private key path and utilize it here, ie the symmetric end to
47 # get user public key.69 # get user public key.
48 args = ["ssh"]70 args = ["ssh"]
49 args.extend(prepare_ssh_sharing())71 args.extend(prepare_ssh_sharing())
72 args.extend(flags)
50 args.extend(["ubuntu@%s" % ip_address])73 args.extend(["ubuntu@%s" % ip_address])
74 args.extend(ssh_command)
51 os.execvp("ssh", args)75 os.execvp("ssh", args)
5276
5377
@@ -58,10 +82,9 @@
58 environment = get_environment(options)82 environment = get_environment(options)
59 provider = environment.get_machine_provider()83 provider = environment.get_machine_provider()
60 client = yield provider.connect()84 client = yield provider.connect()
61
62 label = machine = unit = None85 label = machine = unit = None
6386
64 # First check if its a juju machine id87 # First check if it's a juju machine id
65 if options.unit_or_machine.isdigit():88 if options.unit_or_machine.isdigit():
66 options.log.debug(89 options.log.debug(
67 "Fetching machine address using juju machine id.")90 "Fetching machine address using juju machine id.")
@@ -69,7 +92,7 @@
69 client, provider, options.unit_or_machine)92 client, provider, options.unit_or_machine)
70 machine.get_ip_address = get_ip_address_for_machine93 machine.get_ip_address = get_ip_address_for_machine
71 label = "machine"94 label = "machine"
72 # Next check if its a unit95 # Next check if it's a unit
73 elif "/" in options.unit_or_machine:96 elif "/" in options.unit_or_machine:
74 options.log.debug(97 options.log.debug(
75 "Fetching machine address using unit name.")98 "Fetching machine address using unit name.")
@@ -100,4 +123,4 @@
100123
101 options.log.info("Connecting to %s %s at %s",124 options.log.info("Connecting to %s %s at %s",
102 label, options.unit_or_machine, ip_address)125 label, options.unit_or_machine, ip_address)
103 open_ssh(ip_address)126 open_ssh(options.ssh_flags, ip_address, options.ssh_command)
104127
=== modified file 'juju/control/tests/test_ssh.py'
--- juju/control/tests/test_ssh.py 2011-09-25 14:05:19 +0000
+++ juju/control/tests/test_ssh.py 2011-10-20 17:26:23 +0000
@@ -58,7 +58,7 @@
58 @inlineCallbacks58 @inlineCallbacks
59 def test_shell_with_unit(self):59 def test_shell_with_unit(self):
60 """60 """
61 'juju shell mysql/0' will execute ssh against the machine61 'juju ssh mysql/0' will execute ssh against the machine
62 hosting the unit.62 hosting the unit.
63 """63 """
64 mock_environment = self.mocker.patch(Environment)64 mock_environment = self.mocker.patch(Environment)
@@ -92,6 +92,46 @@
92 self.output.getvalue())92 self.output.getvalue())
9393
94 @inlineCallbacks94 @inlineCallbacks
95 def test_passthrough_args(self):
96 """Verify that args are passed through to the underlying ssh command.
97
98 For example, something like the following command should be valid::
99
100 $ juju ssh -L8080:localhost:8080 -o "ConnectTimeout 60" mysql/0 ls /
101 """
102 mock_environment = self.mocker.patch(Environment)
103 mock_environment.get_machine_provider()
104 self.mocker.result(self.provider)
105
106 mock_exec = self.mocker.replace(os.execvp)
107 mock_exec("ssh", [
108 "ssh",
109 "-o",
110 "ControlPath " + self.tmp_home + "/.juju/ssh/master-%r@%h:%p",
111 "-o", "ControlMaster no",
112 "-L8080:localhost:8080", "-o", "ConnectTimeout 60",
113 "ubuntu@mysql-0.example.com", "ls *"])
114
115 # Track unwanted calls:
116 calls = []
117 mock_exec(ARGS, KWARGS)
118 self.mocker.count(0, None)
119 self.mocker.call(lambda *args, **kwargs: calls.append((args, kwargs)))
120
121 finished = self.setup_cli_reactor()
122 self.mocker.replay()
123 yield self.unit.connect_agent()
124
125 main(["ssh", "-L8080:localhost:8080", "-o", "ConnectTimeout 60",
126 self.unit.unit_name, "ls *"])
127 yield finished
128
129 self.assertEquals(calls, [])
130 self.assertIn(
131 "Connecting to unit mysql/0 at mysql-0.example.com",
132 self.output.getvalue())
133
134 @inlineCallbacks
95 def test_shell_with_unit_and_unconnected_unit_agent(self):135 def test_shell_with_unit_and_unconnected_unit_agent(self):
96 """If a unit doesn't have a connected unit agent,136 """If a unit doesn't have a connected unit agent,
97 the ssh command will wait till one exists before connecting.137 the ssh command will wait till one exists before connecting.
@@ -269,7 +309,7 @@
269 @inlineCallbacks309 @inlineCallbacks
270 def test_shell_with_machine_id(self):310 def test_shell_with_machine_id(self):
271 """311 """
272 'juju shell <machine_id>' will execute ssh against the machine312 'juju ssh <machine_id>' will execute ssh against the machine
273 with the corresponding id.313 with the corresponding id.
274 """314 """
275 mock_environment = self.mocker.patch(Environment)315 mock_environment = self.mocker.patch(Environment)
@@ -338,3 +378,25 @@
338 yield finished378 yield finished
339379
340 self.assertIn("Machine 1 was not found", self.stderr.getvalue())380 self.assertIn("Machine 1 was not found", self.stderr.getvalue())
381
382
383class ParseErrorsTest(ServiceStateManagerTestBase, ControlToolTest):
384
385 @inlineCallbacks
386 def setUp(self):
387 yield super(ParseErrorsTest, self).setUp()
388 config = {
389 "environments": {"firstenv": {"type": "dummy"}}}
390
391 self.write_config(dump(config))
392 self.config.load()
393 self.stderr = self.capture_stream("stderr")
394
395 def test_passthrough_args_parse_error(self):
396 """Verify that bad passthrough args will get an argparse error."""
397 e = self.assertRaises(
398 SystemExit, main, ["ssh", "-L", "mysql/0"])
399 self.assertEqual(e.code, 2)
400 self.assertIn("juju ssh: error: too few arguments",
401 self.stderr.getvalue())
402
341403
=== modified file 'juju/control/tests/test_utils.py'
--- juju/control/tests/test_utils.py 2011-09-15 19:24:47 +0000
+++ juju/control/tests/test_utils.py 2011-10-20 17:26:23 +0000
@@ -2,7 +2,8 @@
22
3from juju.environment.errors import EnvironmentsConfigError3from juju.environment.errors import EnvironmentsConfigError
4from juju.environment.config import EnvironmentsConfig4from juju.environment.config import EnvironmentsConfig
5from juju.control.utils import get_environment5from juju.control.utils import (
6 get_environment, parse_passthrough_args, ParseError)
6from juju.control.tests.common import ControlToolTest7from juju.control.tests.common import ControlToolTest
78
89
@@ -65,3 +66,49 @@
65 options)66 options)
6667
67 self.assertIn("Invalid environment 'volcano'", str(error))68 self.assertIn("Invalid environment 'volcano'", str(error))
69
70
71class ParsePassthroughArgsTest(ControlToolTest):
72
73 def test_parse_typical_ssh(self):
74 """Verify that flags and positional args are properly partitioned."""
75 ssh_flags = "bcDeFIiLlmOopRSWw"
76 self.assertEqual(
77 parse_passthrough_args(
78 ["-L8080:localhost:80", "-o", "Volume 11", "mysql/0", "ls a*"],
79 ssh_flags),
80 (["-L8080:localhost:80", "-o", "Volume 11"], ["mysql/0", "ls a*"]))
81
82 self.assertEqual(
83 parse_passthrough_args(
84 ["-L8080:localhost:80", "-aC26", "0", "foobar", "do", "123"],
85 ssh_flags),
86 (["-L8080:localhost:80", "-aC26"], ["0", "foobar", "do", "123"]))
87
88 self.assertEqual(
89 parse_passthrough_args(["mysql/0"], ssh_flags),
90 ([], ["mysql/0"]))
91
92 self.assertEqual(
93 parse_passthrough_args(
94 ["mysql/0", "command", "-L8080:localhost:80"], ssh_flags),
95 ([], ["mysql/0", "command", "-L8080:localhost:80"]))
96
97 def test_parse_flag_taking_args(self):
98 """Verify that arg-taking flags properly combine with args"""
99 # some sample flags, from the ssh command
100 ssh_flags = "bcDeFIiLlmOopRSWw"
101
102 for flag in ssh_flags:
103 # This flag properly combines, either of the form -Xabc or -X abc
104 self.assertEqual(
105 parse_passthrough_args(
106 ["-" + flag + "XYZ", "-1X", "-" + flag, "XYZ", "mysql/0"],
107 ssh_flags),
108 (["-" + flag + "XYZ", "-1X", "-" + flag, "XYZ"], ["mysql/0"]))
109
110 # And requires that it is combined
111 e = self.assertRaises(
112 ParseError,
113 parse_passthrough_args, ["-" + flag], ssh_flags)
114 self.assertEqual(str(e), "argument -%s: expected one argument" % flag)
68115
=== modified file 'juju/control/utils.py'
--- juju/control/utils.py 2011-09-15 18:50:23 +0000
+++ juju/control/utils.py 2011-10-20 17:26:23 +0000
@@ -1,3 +1,5 @@
1from itertools import tee
2
1from juju.environment.errors import EnvironmentsConfigError3from juju.environment.errors import EnvironmentsConfigError
24
35
@@ -9,3 +11,54 @@
9 elif environment is None:11 elif environment is None:
10 environment = options.environments.get_default()12 environment = options.environments.get_default()
11 return environment13 return environment
14
15
16class ParseError(Exception):
17 """Used to support returning custom parse errors in passthrough parsing.
18
19 Enables similar support to what is seen in argparse, without using its
20 internals.
21 """
22
23
24def parse_passthrough_args(args, flags_taking_arg=frozenset()):
25 """Scans left to right, partitioning flags and positional args.
26
27 :param args: Unparsed args from argparse
28 :param flags_taking_arg: One character flags that combine
29 with arguments.
30 :return: tuple of flags and positional args
31 :raises: :class:`juju.control.utils.ParseError`
32
33 TODO May need to support long options for other passthrough commands.
34 """
35 args = iter(args)
36 flags_taking_arg = set(flags_taking_arg)
37 flags = []
38 positional = []
39 while True:
40 args, peek_args = tee(args)
41 try:
42 peeked = peek_args.next()
43 except StopIteration:
44 break
45 if peeked.startswith("-"):
46 flags.append(args.next())
47 # Only need to consume the next arg if the flag both takes
48 # an arg (say -L) and then it has an extra arg following
49 # (8080:localhost:80), rather than being combined, such as
50 # -L8080:localhost:80
51 if len(peeked) == 2 and peeked[1] in flags_taking_arg:
52 try:
53 flags.append(args.next())
54 except StopIteration:
55 raise ParseError(
56 "argument -%s: expected one argument" % peeked[1])
57 else:
58 # At this point no more flags for the command itself (more
59 # can follow after the first positional arg, as seen in
60 # working with ssh, for example), so consume the rest and
61 # stop parsing options
62 positional = list(args)
63 break
64 return flags, positional

Subscribers

People subscribed via source and target branches

to status/vote changes: