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

Proposed by Dave Walker on 2012-04-10
Status: Rejected
Rejected by: Andres Rodriguez on 2016-02-23
Proposed branch: lp:~smoser/maas/maas-cli
Merge into: lp: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 2012-04-10 Pending
Review via email: mp+101440@code.launchpad.net
To post a comment you must log in.
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.

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 on 2012-03-30

functional release node

371. By Scott Moser on 2012-03-30

fix maascli, removing twisted

370. By Scott Moser on 2012-03-30

change from twisted client to urllib2

369. By Scott Moser on 2012-03-30

merge trunk

368. By Scott Moser on 2012-03-29

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