Merge lp:~smoser/maas/maas-cli into lp:~maas-committers/maas/trunk

Proposed by Dave Walker
Status: Rejected
Rejected by: Andres Rodriguez
Proposed branch: lp:~smoser/maas/maas-cli
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 425 lines (+376/-1)
6 files modified
.bzrignore (+1/-0)
maas-cli/maascli.py (+116/-0)
maas-cli/maasclient/auth.py (+60/-0)
maas-cli/maasclient/maas.py (+194/-0)
maas-cli/my.cfg.example (+3/-0)
src/maas/settings.py (+2/-1)
To merge this branch: bzr merge lp:~smoser/maas/maas-cli
Reviewer Review Type Date Requested Status
MAAS Maintainers Pending
Review via email: mp+101440@code.launchpad.net
To post a comment you must log in.
Revision history for this message
Julian Edwards (julian-edwards) wrote :

This is going to be very useful! I know you said to ignore but here's some pre-review help:

 * the oauth code has been cargo culted from juju and still contains juju references
 * please don't pollute the top-level dir, add this to contrib/ instead.

Revision history for this message
Scott Moser (smoser) wrote :

On Wed, 11 Apr 2012, Julian Edwards wrote:

> This is going to be very useful! I know you said to ignore but here's some pre-review help:
>
> * the oauth code has been cargo culted from juju and still contains juju references

ok. i'll wipe that.

> * please don't pollute the top-level dir, add this to contrib/ instead.

yeah, i agree. I knew it wasn't right when I did it. but just started
somewhere.

Unmerged revisions

372. By Scott Moser

functional release node

371. By Scott Moser

fix maascli, removing twisted

370. By Scott Moser

change from twisted client to urllib2

369. By Scott Moser

merge trunk

368. By Scott Moser

initial commit of maas-cli

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file '.bzrignore'
2--- .bzrignore 2012-03-14 15:52:49 +0000
3+++ .bzrignore 2012-04-10 20:11:22 +0000
4@@ -20,3 +20,4 @@
5 ./TAGS
6 ./tags
7 ./twisted/plugins/dropin.cache
8+my.cfg
9
10=== added directory 'maas-cli'
11=== added file 'maas-cli/maascli.py'
12--- maas-cli/maascli.py 1970-01-01 00:00:00 +0000
13+++ maas-cli/maascli.py 2012-04-10 20:11:22 +0000
14@@ -0,0 +1,116 @@
15+#!/usr/bin/python
16+
17+import argparse
18+import pprint
19+from maasclient.maas import MAASClient
20+
21+def get_config(args):
22+ """ takes in an args, returns the maas config,
23+ handles command line overriding args.config """
24+ cfg = {'maas-server': args.server, 'maas-oauth': args.oauth,
25+ 'admin-secret': args.secret}
26+
27+ if args.config:
28+ import yaml
29+ with open(args.config) as fp:
30+ filecfg = yaml.load(fp)
31+ for key in cfg.keys():
32+ if key in filecfg and cfg[key] == None:
33+ cfg[key] = filecfg[key]
34+
35+ # oauth is ':' delimited consumer_key, resource_token, resource_secret
36+ cfg['maas-oauth'] = cfg['maas-oauth'].split(":")
37+ return cfg
38+
39+def search_nodelist(match):
40+ pass
41+
42+def print_nodes(result):
43+ import pprint
44+ pprint.pprint(result)
45+
46+
47+def node_list_acquired(client, args):
48+ """ list the nodes """
49+ data = client.get_allocated_nodes(resource_uris=args.systems)
50+ pprint.pprint(data)
51+
52+def node_list(client, args):
53+ """ list the nodes """
54+ print_nodes(client.get_nodes(resource_uris=args.systems))
55+
56+def node_create(client, args):
57+ """ create a new node """
58+ pass
59+
60+def node_release(client, args):
61+ """ request/claim a node """
62+ for sysuri in args.systems:
63+ data = client.release_node(sysuri)
64+ print data
65+
66+def node_release_all(client, args):
67+ data = client.release_node()
68+
69+def node_start(client, args):
70+ """ start an acuired node """
71+ pass
72+
73+def node_acquire(client, args):
74+ """ acquire a node """
75+ print_nodes(client.acquire_node())
76+
77+def node_delete(client, args):
78+ """ delete a node """
79+ pass
80+
81+def main():
82+ """
83+ Call with single argument of directory or http or https url.
84+ If url is given additional arguments are allowed, which will be
85+ interpreted as consumer_key, token_key, token_secret, consumer_secret
86+ """
87+
88+ parser = argparse.ArgumentParser(description='maas client')
89+ parser.add_argument("--config", metavar="file",
90+ help="specify yaml config file", default=None)
91+ parser.add_argument("--server", metavar="url",
92+ help="the maas server", default=None)
93+ parser.add_argument("--oauth", metavar="oauth",
94+ help="the oauth credentials string", default=None)
95+ parser.add_argument("--secret", metavar="secret",
96+ help="the admin secret", default=None)
97+
98+ subcmds = parser.add_subparsers(title="subcommands", dest="subcmd")
99+
100+ p_list = subcmds.add_parser('node-list', help="list nodes")
101+ p_list.add_argument('systems', metavar='uri', nargs='*')
102+ p_list.set_defaults(func=node_list)
103+
104+ p_list_acquired = subcmds.add_parser('node-list-acquired',
105+ help="list aquired nodes")
106+ p_list_acquired.add_argument('systems', metavar='uri', nargs='*')
107+ p_list_acquired.set_defaults(func=node_list_acquired)
108+
109+ p_create = subcmds.add_parser('node-create', help="create a node")
110+ p_create.set_defaults(func=node_create)
111+
112+ p_request = subcmds.add_parser('node-acquire', help="acquire a node")
113+ p_request.set_defaults(func=node_acquire)
114+
115+ p_release = subcmds.add_parser('node-release', help="release a node")
116+ p_release.add_argument('systems', metavar='uri', nargs='*')
117+ p_release.set_defaults(func=node_release)
118+
119+ p_delete = subcmds.add_parser('node-delete', help="delete a node")
120+ p_delete.add_argument('systems', metavar='uri', nargs='*')
121+ p_delete.set_defaults(func=node_delete)
122+
123+ args = parser.parse_args()
124+
125+ client = MAASClient(get_config(args))
126+ args.func(client, args)
127+
128+
129+if __name__ == "__main__":
130+ main()
131
132=== added directory 'maas-cli/maasclient'
133=== added file 'maas-cli/maasclient/__init__.py'
134=== added file 'maas-cli/maasclient/auth.py'
135--- maas-cli/maasclient/auth.py 1970-01-01 00:00:00 +0000
136+++ maas-cli/maasclient/auth.py 2012-04-10 20:11:22 +0000
137@@ -0,0 +1,60 @@
138+# Copyright 2012 Canonical Ltd. This software is licensed under the
139+# GNU Affero General Public License version 3 (see the file LICENSE).
140+
141+"""OAuth functions for authorisation against a maas server."""
142+
143+import oauth.oauth as oauth
144+from urlparse import urlparse
145+import urllib2
146+
147+
148+def _ascii_url(url):
149+ """Ensure that the given URL is ASCII, encoding if necessary."""
150+ if isinstance(url, unicode):
151+ urlparts = urlparse(url)
152+ urlparts = urlparts._replace(
153+ netloc=urlparts.netloc.encode("idna"))
154+ url = urlparts.geturl()
155+ return url.encode("ascii")
156+
157+
158+class MAASOAuthConnection(object):
159+ """Helper class to provide an OAuth auth'd connection to MAAS."""
160+
161+ def __init__(self, oauth_info):
162+ consumer_key, resource_token, resource_secret = oauth_info
163+ resource_tok_string = "oauth_token_secret=%s&oauth_token=%s" % (
164+ resource_secret, resource_token)
165+ self.resource_token = oauth.OAuthToken.from_string(resource_tok_string)
166+ self.consumer_token = oauth.OAuthConsumer(consumer_key, "")
167+
168+ def oauth_sign_request(self, url, headers):
169+ """Sign a request.
170+
171+ @param url: The URL to which the request is to be sent.
172+ @param headers: The headers in the request.
173+ """
174+ oauth_request = oauth.OAuthRequest.from_consumer_and_token(
175+ self.consumer_token, token=self.resource_token, http_url=url)
176+ oauth_request.sign_request(
177+ oauth.OAuthSignatureMethod_PLAINTEXT(), self.consumer_token,
178+ self.resource_token)
179+ headers.update(oauth_request.to_header())
180+
181+
182+ def request(self, request_url, method="GET",
183+ data=None, headers=None):
184+ """Dispatch an OAuth-signed request to L{request_url}.
185+
186+ @param request_url: The URL to which the request is to be sent.
187+ @param method: The HTTP method, e.g. C{GET}, C{POST}, etc.
188+ @param data: The data to send, if any.
189+ @type data: A byte string.
190+ @param headers: Headers to including in the request.
191+ """
192+ if headers is None:
193+ headers = {}
194+ self.oauth_sign_request(request_url, headers)
195+ req = urllib2.Request(request_url,
196+ data=data, headers=headers)
197+ return(urllib2.urlopen(req).read())
198
199=== added file 'maas-cli/maasclient/maas.py'
200--- maas-cli/maasclient/maas.py 1970-01-01 00:00:00 +0000
201+++ maas-cli/maasclient/maas.py 2012-04-10 20:11:22 +0000
202@@ -0,0 +1,194 @@
203+# Copyright 2012 Canonical Ltd. This software is licensed under the
204+# GNU Affero General Public License version 3 (see the file LICENSE).
205+
206+"""MAAS API client for Juju"""
207+
208+from base64 import b64encode
209+import json
210+import random
211+import string
212+import re
213+from urllib import urlencode
214+from urlparse import urljoin
215+
216+from auth import MAASOAuthConnection
217+
218+
219+def convert_unknown_error(error):
220+ print "Error: %s" % error
221+ return error
222+
223+CONSUMER_SECRET = ""
224+
225+
226+_re_resource_uri = re.compile(
227+ '/api/(?P<version>[^/]+)/nodes/(?P<system_id>[^/]+)/?')
228+
229+def _encode_field(field_name, data, boundary):
230+ return ('--' + boundary,
231+ 'Content-Disposition: form-data; name="%s"' % field_name,
232+ '', str(data))
233+
234+
235+def _encode_file(name, fileObj, boundary):
236+ return ('--' + boundary,
237+ 'Content-Disposition: form-data; name="%s"; filename="%s"' %
238+ (name, name),
239+ 'Content-Type: %s' % _get_content_type(name),
240+ '', fileObj.read())
241+
242+def _random_string(length):
243+ return ''.join(random.choice(string.letters) for ii in range(length + 1))
244+
245+
246+def encode_multipart_data(data, files):
247+ """Create a MIME multipart payload from L{data} and L{files}.
248+
249+ @param data: A mapping of names (ASCII strings) to data (byte string).
250+ @param files: A mapping of names (ASCII strings) to file objects ready to
251+ be read.
252+ @return: A 2-tuple of C{(body, headers)}, where C{body} is a a byte string
253+ and C{headers} is a dict of headers to add to the enclosing request in
254+ which this payload will travel.
255+ """
256+ boundary = _random_string(30)
257+
258+ lines = []
259+ for name in data:
260+ lines.extend(_encode_field(name, data[name], boundary))
261+ for name in files:
262+ lines.extend(_encode_file(name, files[name], boundary))
263+ lines.extend(('--%s--' % boundary, ''))
264+ body = '\r\n'.join(lines)
265+
266+ headers = {'content-type': 'multipart/form-data; boundary=' + boundary,
267+ 'content-length': str(len(body))}
268+
269+ return body, headers
270+
271+
272+def extract_system_id(resource_uri):
273+ """Extract a system ID from a resource URI.
274+
275+ This is fairly unforgiving; an exception is raised if the URI given does
276+ not look like a MAAS node resource URI.
277+
278+ :param resource_uri: A URI that corresponds to a MAAS node resource.
279+ :raises: :exc:`juju.errors.ProviderError` when `resource_uri` does not
280+ resemble a MAAS node resource URI.
281+ """
282+ match = _re_resource_uri.search(resource_uri)
283+ if match is None:
284+ raise TypeError(
285+ "%r does not resemble a MAAS resource URI." % (resource_uri,))
286+ else:
287+ return match.group("system_id")
288+
289+
290+class MAASClient(MAASOAuthConnection):
291+
292+ def __init__(self, config):
293+ """Initialise an API client for MAAS.
294+
295+ :param config: a dict of configuration values; must contain
296+ 'maas-server', 'maas-oauth', 'admin-secret'
297+ """
298+ self.url = config["maas-server"]
299+ if not self.url.endswith('/'):
300+ self.url += "/"
301+ self.oauth_info = config["maas-oauth"]
302+ self.admin_secret = config["admin-secret"]
303+ super(MAASClient, self).__init__(self.oauth_info)
304+
305+ def get(self, path, params):
306+ """Dispatch a C{GET} call to a MAAS server.
307+
308+ :param uri: The MAAS path for the endpoint to call.
309+ :param params: A C{dict} of parameters - or sequence of 2-tuples - to
310+ encode into the request.
311+ :return: A Deferred which fires with the result of the call.
312+ """
313+ url = "%s?%s" % (urljoin(self.url, path), urlencode(params))
314+ d = self.request(url)
315+ return json.loads(self.request(url))
316+
317+ def post(self, path, params):
318+ """Dispatch a C{POST} call to a MAAS server.
319+
320+ :param uri: The MAAS path for the endpoint to call.
321+ :param params: A C{dict} of parameters to encode into the request.
322+ :return: A Deferred which fires with the result of the call.
323+ """
324+ url = urljoin(self.url, path)
325+ body, headers = encode_multipart_data(params, {})
326+ return json.loads(
327+ self.request(url, "POST", headers=headers, data=body))
328+
329+ def get_allocated_nodes(self, resource_uris=None):
330+ """Ask MAAS to return a list of all the nodes it knows about.
331+
332+ :param resource_uris: The MAAS URIs for the nodes you want to get.
333+ :return: A Deferred whose value is the list of nodes.
334+ """
335+ params = [("op", "list_allocated")]
336+ if resource_uris is not None:
337+ params.extend(
338+ ("id", extract_system_id(resource_uri))
339+ for resource_uri in resource_uris)
340+ return self.get("api/1.0/nodes/", params)
341+
342+ def get_nodes(self, resource_uris=None):
343+ """Ask MAAS to return a list of all the nodes it knows about.
344+
345+ :param resource_uris: The MAAS URIs for the nodes you want to get.
346+ :return: A Deferred whose value is the list of nodes.
347+ """
348+ params = [("op", "list")]
349+ if resource_uris is not None:
350+ params.extend(
351+ ("id", extract_system_id(resource_uri))
352+ for resource_uri in resource_uris)
353+ return self.get("api/1.0/nodes/", params)
354+
355+ def acquire_node(self):
356+ """Ask MAAS to assign a node to us.
357+
358+ :return: A Deferred whose value is the resource URI to the node
359+ that was acquired.
360+ """
361+ params = {"op": "acquire"}
362+ return self.post("api/1.0/nodes/", params)
363+
364+ def start_node(self, resource_uri, user_data):
365+ """Ask MAAS to start a node.
366+
367+ :param resource_uri: The MAAS URI for the node you want to start.
368+ :param user_data: Any blob of data to be passed to MAAS. Must be
369+ possible to encode as base64.
370+ :return: A Deferred whose value is the resource data for the node
371+ as returned by get_nodes().
372+ """
373+ assert isinstance(user_data, str), (
374+ "User data must be a byte string.")
375+ params = {"op": "start", "user_data": b64encode(user_data)}
376+ return self.post(resource_uri, params)
377+
378+ def stop_node(self, resource_uri):
379+ """Ask maas to shut down a node.
380+
381+ :param resource_uri: The MAAS URI for the node you want to stop.
382+ :return: A Deferred whose value is the resource data for the node
383+ as returned by get_nodes().
384+ """
385+ params = {"op": "stop"}
386+ return self.post(resource_uri, params)
387+
388+ def release_node(self, resource_uri):
389+ """Ask MAAS to release a node from our ownership.
390+
391+ :param resource_uri: The URI in MAAS for the node you want to release.
392+ :return: A Deferred which fires with the resource data for the node
393+ just released.
394+ """
395+ params = {"op": "release"}
396+ return self.post(resource_uri, params)
397
398=== added file 'maas-cli/my.cfg.example'
399--- maas-cli/my.cfg.example 1970-01-01 00:00:00 +0000
400+++ maas-cli/my.cfg.example 2012-04-10 20:11:22 +0000
401@@ -0,0 +1,3 @@
402+maas-server: 'http://localhost:5240'
403+maas-oauth: 'vpxQGKsece8WVmT52b:KgaK2mEuq5kqJMVPuA:4KWMF4eWRGQWydccyxfMJwnqNWDjBz3z'
404+admin-secret: 'nothing'
405
406=== modified file 'src/maas/settings.py'
407--- src/maas/settings.py 2012-04-02 07:06:37 +0000
408+++ src/maas/settings.py 2012-04-10 20:11:22 +0000
409@@ -22,7 +22,7 @@
410
411 django.template.add_to_builtins('django.templatetags.future')
412
413-DEBUG = False
414+DEBUG = True
415
416 # Used to set a prefix in front of every URL.
417 FORCE_SCRIPT_NAME = None
418@@ -171,6 +171,7 @@
419 # Put strings here, like "/home/html/static" or "C:/www/django/static".
420 # Always use forward slashes, even on Windows.
421 # Don't forget to use absolute paths, not relative paths.
422+ '/home/ubuntu/src/maas/maas/src/maasserver/static/',
423 )
424
425 # List of finder classes that know how to find static files in