Merge lp:~jimbaker/juju-jitsu/juju-do into lp:juju-jitsu

Proposed by Jim Baker on 2012-04-24
Status: Merged
Merged at revision: 36
Proposed branch: lp:~jimbaker/juju-jitsu/juju-do
Merge into: lp:juju-jitsu
Diff against target: 407 lines (+387/-0)
4 files modified
sub-commands/aiki/__init__.py (+1/-0)
sub-commands/aiki/introspect.py (+57/-0)
sub-commands/aiki/invoker.py (+126/-0)
sub-commands/do (+203/-0)
To merge this branch: bzr merge lp:~jimbaker/juju-jitsu/juju-do
Reviewer Review Type Date Requested Status
Juju-Jitsu Hackers 2012-04-24 Pending
Review via email: mp+103366@code.launchpad.net

Description of the Change

Adds a "juju do" subcommand. Use as follows:

$ juju do --help
usage: do [-h] [-e ENVIRONMENT] [--loglevel CRITICAL|ERROR|WARNING|INFO|DEBUG]
          [--verbose]
          SERVICE_UNIT COMMAND [ARG [ARG ...]]

Examples:

To introspect a relation setting on the db:0 relation for the mysql/0 service unit:

$ PASSWORD=$(juju do mysql/0 relation-get -r db:0 password)

Note that a relation id must be supplied when using relation hook commands. It might make sense to default this if there's only one relation; that could be done in a future branch.

Define a script refresh.sh like so:

  #!/bin/bash
  for relation_id in $(relation-ids db); do
      relation-set -r $relation_id ready=$1
  done

$ juju do --loglevel=DEBUG mysql/0 refresh.sh 32
2012-04-24 11:02:19,671 juju.ec2:WARNING txaws.client.ssl unavailable for SSL hostname verification
2012-04-24 11:02:19,671 juju.ec2:WARNING EC2 API calls encrypted but not authenticated
2012-04-24 11:02:19,671 juju.ec2:WARNING S3 API calls encrypted but not authenticated
2012-04-24 11:02:19,671 juju.ec2:WARNING Ubuntu Cloud Image lookups encrypted but not authenticated
2012-04-24 11:02:19,673 juju.common:INFO Connecting to environment...
2012-04-24 11:02:20,547 juju.common:DEBUG Connecting to environment using ec2-50-18-146-37.us-west-1.compute.amazonaws.com...
2012-04-24 11:02:20,548 juju.state.sshforward:DEBUG Spawning SSH process with remote_user="ubuntu" remote_host="ec2-50-18-146-37.us-west-1.compute.amazonaws.com" remote_port="2181" local_port="60629".
2012-04-24 11:02:22,186 juju.common:DEBUG Environment is initialized.
2012-04-24 11:02:22,186 juju.common:INFO Connected to environment.
2012-04-24 11:02:22,592 juju-do-hook-output:DEBUG Cached relation hook contexts: ['db:0', 'db:1']
2012-04-24 11:02:23,352 juju.jitsu.do.unit-agent:DEBUG Setting relation db:0
2012-04-24 11:02:23,794 juju.jitsu.do.unit-agent:DEBUG Setting relation db:1
2012-04-24 11:02:23,875 juju-do-hook-output:DEBUG hook juju-do-hook-script exited, exit code 0.
2012-04-24 11:02:24,666 juju-do-hook-output:DEBUG Flushed values for hook 'juju-do-hook-script'
    Setting changed: 'ready'=u'32' (was '42') on 'db:0'
    Setting changed: 'ready'=u'32' (was '42') on 'db:1'

"juju do" can also run on a Juju machine as well. This can make it useful for running as part of a cron job, for example.

Still needs unit testing, but it should be suitable for merging against juju-jitsu at this time.

To post a comment you must log in.
Clint Byrum (clint-fewbar) wrote :

Looks great Jim. I've not given it a line by line analysis, but I like the direction and the utility.

One thing, it should be documented as 'jitsu do' not 'juju do' since sub-commands are only added to juju if 'wrap-juju' is invoked.

Thats the only thing, otherwise, merge away!

Jim Baker (jimbaker) wrote :

I changed 'juju do' to 'jitsu do' accordingly in both the docs and the code (to keep it consistent with docs), then merged. Thanks for the review!

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added directory 'sub-commands/aiki'
2=== added file 'sub-commands/aiki/__init__.py'
3--- sub-commands/aiki/__init__.py 1970-01-01 00:00:00 +0000
4+++ sub-commands/aiki/__init__.py 2012-04-24 20:12:54 +0000
5@@ -0,0 +1,1 @@
6+"""Aiki is the blending with the attack. Aiki-jijitsu is one of the styles of jujitsu."""
7
8=== added file 'sub-commands/aiki/introspect.py'
9--- sub-commands/aiki/introspect.py 1970-01-01 00:00:00 +0000
10+++ sub-commands/aiki/introspect.py 2012-04-24 20:12:54 +0000
11@@ -0,0 +1,57 @@
12+"""Reads /etc/init/juju-machine-agent.conf to determine Juju agent config"""
13+
14+from txzookeeper.client import ZookeeperClient
15+
16+import juju
17+from juju.environment.config import EnvironmentsConfig
18+
19+
20+def get_upstart_env(conf_path="/etc/init/juju-machine-agent.conf"):
21+ # Find environment variables as follows
22+ # env JUJU_MACHINE_ID="2"
23+ # env JUJU_ZOOKEEPER="ip-10-171-74-191.us-west-1.compute.internal:2181"
24+
25+ env = {}
26+ with open(conf_path) as conf:
27+ for line in conf:
28+ if line.startswith("env "):
29+ kv = line[4:]
30+ k, v = kv.split("=")
31+ env[k] = v[1:-2] # remove quotes
32+ return env
33+
34+
35+def get_zk_client_connector(options):
36+ # Running on a Juju machine or on an admin system? Distinguish by
37+ # first checking for an ~/.juju/environments.yaml file; if not
38+ # check the machine for an upstart conf file
39+ env_config = EnvironmentsConfig()
40+ try:
41+ env_config.load()
42+ except juju.errors.FileNotFound:
43+ return get_juju_machine_zk_client_connector()
44+
45+ if options.environment is None:
46+ environment = env_config.get_default()
47+ else:
48+ environment = env_config.get(options.environment)
49+ if environment is None:
50+ raise juju.errors.EnvironmentNotFound(
51+ "Environment %r not configured in environments.yaml" % options.environment)
52+ provider = environment.get_machine_provider()
53+
54+ def connector():
55+ # Note: returns a Deferred
56+ return provider.connect()
57+
58+ return connector
59+
60+
61+def get_juju_machine_zk_client_connector():
62+ env = get_upstart_env()
63+
64+ # When principals for ACL support is added, this is the place to do it.
65+ def connector():
66+ return ZookeeperClient().connect(env["JUJU_ZOOKEEPER"])
67+
68+ return connector
69
70=== added file 'sub-commands/aiki/invoker.py'
71--- sub-commands/aiki/invoker.py 1970-01-01 00:00:00 +0000
72+++ sub-commands/aiki/invoker.py 2012-04-24 20:12:54 +0000
73@@ -0,0 +1,126 @@
74+"""Creates a unit agent-like context for invoking hook commands.
75+
76+"""
77+
78+# XXX issues
79+# still write some tests here
80+
81+
82+import logging
83+import os
84+import pipes
85+import stat
86+import sys
87+
88+from twisted.internet.defer import inlineCallbacks
89+
90+from juju.hooks.invoker import Invoker
91+from juju.hooks.protocol import UnitSettingsFactory
92+from juju.state.hook import HookContext
93+from juju.unit.lifecycle import HOOK_SOCKET_FILE
94+
95+
96+class InvokerUnitAgent(object):
97+ """Creates a unit-agent like context for running commands as hooks:
98+
99+ * Initializes a client state cache and domain socket
100+ * Dynamically creates a wrapper script for the hook command
101+ * Runs wrapper script
102+ """
103+ def __init__(self, client, charm_dir, log_file, error_file, options):
104+ self.client = client
105+ self.charm_dir = charm_dir
106+ self.log_file = log_file
107+ self.error_file = error_file
108+ self.debug_file = sys.stderr
109+ self.options = options
110+ os.environ["JUJU_UNIT_NAME"] = self.unit_name = options.unit_name
111+ self.socket_path = os.path.join(self.charm_dir, HOOK_SOCKET_FILE)
112+ os.makedirs(os.path.join(self.charm_dir, "charm"))
113+
114+ self.context = None
115+ self.invoker = None
116+ self.server_listen()
117+
118+ @inlineCallbacks
119+ def start(self):
120+ """Build an Invoker for the execution of a hook."""
121+
122+ # Output from hook commands is relayed by logging. Capture it
123+ # with this logger which is passed into the invoker. The
124+ # "juju-do-hook-output" name of the logger is completely
125+ # arbitrary, with this provision: it's not prefixed with
126+ # "juju." because otherwise if the log level for "juju" is
127+ # changed to above INFO, this capture doesn't actually occur!
128+ # (Hope that's clear.)
129+ logger = logging.getLogger("juju-do-hook-output")
130+ logger.propagate = False
131+ output_handler = logging.StreamHandler(self.log_file)
132+ output_handler.setLevel(logging.INFO)
133+ logger.addHandler(output_handler)
134+
135+ # Capture any errors separately
136+ error_handler = logging.StreamHandler(self.error_file)
137+ error_handler.setLevel(logging.ERROR)
138+ logger.addHandler(error_handler)
139+
140+ # Also grab debug output from unit agent if loglevel is below INFO
141+ if self.options.loglevel < logging.INFO:
142+ debug_handler = logging.StreamHandler(self.debug_file)
143+ debug_handler.setLevel(logging.DEBUG)
144+ debug_handler.setFormatter(logging.Formatter("%(asctime)s %(name)s:%(levelname)s %(message)s"))
145+ logger.addHandler(debug_handler)
146+
147+ self.context = HookContext(self.client, self.unit_name)
148+ self.invoker = Invoker(
149+ self.context, None, "client_id", self.socket_path,
150+ self.charm_dir, logger)
151+
152+ # When working with relation hook commands, setting
153+ # JUJU_REMOTE_UNIT to the same value as JUJU_UNIT_NAME
154+ # provides a useful default. A relation must still be
155+ # specified, however.
156+ self.invoker.environment["JUJU_REMOTE_UNIT"] = self.unit_name
157+ yield self.invoker.start()
158+
159+ def get_context(self, client_id):
160+ """Required by the factory, always return this context"""
161+ return self.context
162+
163+ def lookup_invoker(self, client_id):
164+ """Required by the factory, always return this invoker"""
165+ return self.invoker
166+
167+ def server_listen(self):
168+ """Start Unix domain socket server for the constructed hook"""
169+ from twisted.internet import reactor
170+
171+ self.server_factory = UnitSettingsFactory(
172+ self.get_context, self.lookup_invoker,
173+ logging.getLogger("juju.jitsu.do.unit-agent"))
174+ self.server_socket = reactor.listenUNIX(
175+ self.socket_path, self.server_factory)
176+
177+ def stop(self):
178+ """Stop the process invocation."""
179+ self.server_socket.stopListening()
180+
181+ def run_hook(self, command, args):
182+ """Given `commmand` and any `args`, builds a script that can be invoked.
183+
184+ Both `command` and `args` are protected by shell quoting, as
185+ necessary.
186+
187+ Returns a `Deferred` that can be yield upon to get the result,
188+ an exit code. Note that this exit code is always 0 for hook
189+ commands that complete, even if there's an error in their
190+ execution.
191+ """
192+ hook_path = os.path.join(self.charm_dir, "juju-do-hook-script")
193+ with open(hook_path, "w") as f:
194+ f.writelines([
195+ "#!/bin/sh\n",
196+ pipes.quote(command), " ",
197+ " ".join(pipes.quote(arg) for arg in args), "\n"])
198+ os.chmod(hook_path, stat.S_IEXEC | stat.S_IREAD)
199+ return self.invoker(hook_path)
200
201=== added file 'sub-commands/do'
202--- sub-commands/do 1970-01-01 00:00:00 +0000
203+++ sub-commands/do 2012-04-24 20:12:54 +0000
204@@ -0,0 +1,203 @@
205+#!/usr/bin/env python
206+
207+"""
208+Examples:
209+
210+$ PASSWORD=$(./juju-do mysql/0 relation-get -r db:0 password)
211+
212+Note that a relation id must be supplied for relation hook commands.
213+
214+Define a script refresh.sh like so::
215+
216+ #!/bin/bash
217+ for relation_id in $(relation-ids db); do
218+ relation-set -r $relation_id ready=$1
219+ done
220+
221+$ ./juju-do --loglevel=DEBUG mysql/0 test.sh 32
222+2012-04-24 11:02:19,671 juju.ec2:WARNING txaws.client.ssl unavailable for SSL hostname verification
223+2012-04-24 11:02:19,671 juju.ec2:WARNING EC2 API calls encrypted but not authenticated
224+2012-04-24 11:02:19,671 juju.ec2:WARNING S3 API calls encrypted but not authenticated
225+2012-04-24 11:02:19,671 juju.ec2:WARNING Ubuntu Cloud Image lookups encrypted but not authenticated
226+2012-04-24 11:02:19,673 juju.common:INFO Connecting to environment...
227+2012-04-24 11:02:20,547 juju.common:DEBUG Connecting to environment using ec2-50-18-146-37.us-west-1.compute.amazonaws.com...
228+2012-04-24 11:02:20,548 juju.state.sshforward:DEBUG Spawning SSH process with remote_user="ubuntu" remote_host="ec2-50-18-146-37.us-west-1.compute.amazonaws.com" remote_port="2181" local_port="60629".
229+2012-04-24 11:02:22,186 juju.common:DEBUG Environment is initialized.
230+2012-04-24 11:02:22,186 juju.common:INFO Connected to environment.
231+2012-04-24 11:02:22,592 juju-do-hook-output:DEBUG Cached relation hook contexts: ['db:0', 'db:1']
232+2012-04-24 11:02:23,352 juju.jitsu.do.unit-agent:DEBUG Setting relation db:0
233+2012-04-24 11:02:23,794 juju.jitsu.do.unit-agent:DEBUG Setting relation db:1
234+2012-04-24 11:02:23,875 juju-do-hook-output:DEBUG hook juju-do-hook-script exited, exit code 0.
235+2012-04-24 11:02:24,666 juju-do-hook-output:DEBUG Flushed values for hook 'juju-do-hook-script'
236+ Setting changed: 'ready'=u'32' (was '42') on 'db:0'
237+ Setting changed: 'ready'=u'32' (was '42') on 'db:1'
238+"""
239+
240+from StringIO import StringIO
241+import argparse
242+import zookeeper
243+import logging
244+import os
245+import shutil
246+import sys
247+import tempfile
248+import traceback
249+
250+from twisted.internet import reactor
251+from twisted.internet.defer import inlineCallbacks
252+
253+from aiki.invoker import InvokerUnitAgent
254+from aiki.introspect import get_zk_client_connector
255+import juju
256+
257+
258+def main():
259+ loglevels=dict(
260+ CRITICAL=logging.CRITICAL,
261+ ERROR=logging.ERROR,
262+ WARNING=logging.WARNING,
263+ INFO=logging.INFO,
264+ DEBUG=logging.DEBUG)
265+
266+ parser = argparse.ArgumentParser(
267+ description="expose a service unit(s) port")
268+ parser.add_argument(
269+ "-e", "--environment", default=None,
270+ help="Environment to act upon (otherwise uses default)")
271+ parser.add_argument(
272+ "--loglevel", default=None, choices=loglevels,
273+ help="Log level",
274+ metavar="CRITICAL|ERROR|WARNING|INFO|DEBUG")
275+ parser.add_argument(
276+ "--verbose", "-v", default=False, action="store_true",
277+ help="Enable verbose logging")
278+ parser.add_argument("unit_name", help="Local unit name for running command", metavar="SERVICE_UNIT")
279+ parser.add_argument("command", help="Command", metavar="COMMAND")
280+ parser.add_argument("arg", nargs="*", help="Optional args", metavar="ARG")
281+
282+ # Collect additional args to be passed through, such as arguments like -r db:0
283+ options, extra = parser.parse_known_args()
284+ extra.extend(options.arg)
285+ options.extra = extra
286+
287+ # Prefix command with path if necessary
288+ if os.path.exists(os.path.join(os.getcwd(), options.command)):
289+ options.command = os.path.abspath(options.command)
290+
291+ # Set logging defaults with respect to verbose
292+ options.loglevel = loglevels.get(options.loglevel)
293+ if options.verbose:
294+ if options.loglevel is None:
295+ options.loglevel = logging.DEBUG
296+ else:
297+ # Avoid potentially voluminous ZK debug logging
298+ zookeeper.set_debug_level(0)
299+ if options.loglevel is None:
300+ options.loglevel = logging.WARNING
301+
302+ # Some extra complexity here in logging level setup vs a normal
303+ # root logger; see the comments on the juju-do-hook-output logging
304+ # for the motivation on why.
305+ log_options = {
306+ "level": min(options.loglevel, logging.INFO),
307+ "format": "%(asctime)s %(name)s:%(levelname)s %(message)s"
308+ }
309+ logging.basicConfig(**log_options)
310+ juju_root_log = logging.getLogger("juju") # NB Cannot do this with the root logger!
311+ juju_root_log.setLevel(logging.getLevelName(options.loglevel))
312+
313+ # Introspect to find the right client connector; this will be
314+ # called, then yielded upon, to get a ZK client once async code is
315+ # entered
316+ try:
317+ connector = get_zk_client_connector(options)
318+ except Exception, e:
319+ print >> sys.stderr, e
320+ sys.exit(1)
321+
322+ # Setup temporary unit agent to invoke command + any args as a
323+ # hook and run it
324+ temp_dir = tempfile.mkdtemp(prefix="juju-do-")
325+ result = [0]
326+ reactor.callWhenRunning(
327+ run_one, juju_do, options.verbose, result,
328+ connector, temp_dir, options)
329+ reactor.run()
330+ shutil.rmtree(temp_dir)
331+ sys.exit(result[0])
332+
333+
334+@inlineCallbacks
335+def run_one(func, verbose, result, *args, **kw):
336+ try:
337+ yield func(result, *args, **kw)
338+ except Exception, e:
339+ result[0] = 1
340+ if verbose:
341+ traceback.print_exc() # Writes to stderr
342+ else:
343+ print >> sys.stderr, e
344+ finally:
345+ reactor.stop()
346+
347+
348+@inlineCallbacks
349+def juju_do(result, connector, temp_dir, options):
350+ """Runs the hook in the reactor, as if it were a unit agent.
351+
352+ `result` is a list of one object that is used to communicate back
353+ the exit code. Otherwise stdout/stderr are used for the unit agent
354+ output and error text, if any.
355+ """
356+
357+ # Most of this code is to handle the many, many ways that the hook
358+ # can fail, while capturing useful exit codes and and error text
359+ # (and omitting useless junk).
360+
361+ # Connect to ZK, either from admin machine or on a Juju machine
362+ client = yield connector()
363+
364+ # Unit agent output/error
365+ log_file = StringIO()
366+ error_file = StringIO()
367+
368+ # And actually run the hook
369+ invoker_ua = InvokerUnitAgent(client, temp_dir, log_file, error_file, options)
370+ yield invoker_ua.start()
371+ try:
372+ result[0] = yield invoker_ua.run_hook(options.command, options.extra)
373+ except juju.errors.CharmInvocationError, e:
374+ # Get exit code from this exception, but pick up the actual
375+ # error text from the error_file, since it is more detailed;
376+ # see below
377+ result[0] = e.exit_code
378+ invoker_ua.stop()
379+
380+ # Write any error to stderr, otherwise write hook result to stdout
381+ error = error_file.getvalue().rstrip()
382+ if error:
383+ error_printed = False
384+ # Unless verbose, ignore the traceback produced by hook commands
385+ if not options.verbose:
386+ lines = error.split("\n")
387+ if lines[0] == "Traceback (most recent call last):":
388+ lines.pop(0)
389+ print >> sys.stderr, lines[0]
390+ error_printed = True
391+ # Omit wrapper script from error
392+ prefix = temp_dir + "/juju-do-hook-script: 2: "
393+ if lines[0].startswith(prefix):
394+ print >> sys.stderr, lines[0][len(prefix):]
395+ error_printed = True
396+ if not error_printed:
397+ print >> sys.stderr, error
398+ if result[0] == 0:
399+ result[0] = 1 # Return a nonzero status code result
400+ else:
401+ value = log_file.getvalue().rstrip()
402+ if value:
403+ print value
404+
405+
406+if __name__ == '__main__':
407+ main()

Subscribers

People subscribed via source and target branches

to all changes: