Merge lp:~tvansteenburgh/python-jujuclient/1625632 into lp:python-jujuclient

Proposed by Tim Van Steenburgh
Status: Merged
Merged at revision: 97
Proposed branch: lp:~tvansteenburgh/python-jujuclient/1625632
Merge into: lp:python-jujuclient
Diff against target: 293 lines (+116/-26)
6 files modified
jujuclient/connector.py (+47/-15)
jujuclient/juju1/connector.py (+5/-2)
jujuclient/juju2/connector.py (+6/-3)
jujuclient/juju2/rpc.py (+53/-5)
jujuclient/rpc.py (+3/-0)
tests/test_juju2.py (+2/-1)
To merge this branch: bzr merge lp:~tvansteenburgh/python-jujuclient/1625632
Reviewer Review Type Date Requested Status
Tim Van Steenburgh (community) Approve
Review via email: mp+319483@code.launchpad.net

Description of the change

Add support for macaroon auth

Makes python-jujuclient usable with a shared controller or model
by automatically attempting macaroon auth using ~/.go-cookies.

Does not contain support for fetching or discharging new
macaroons. In other words, if you don't already have a discharged
macaroon (e.g. the ones created by the juju cli in ~/.go-cookies),
this won't work for you.

Also fixes a KeyError that could occur if accounts.yaml is missing
a password field.

To post a comment you must log in.
Revision history for this message
Tim Van Steenburgh (tvansteenburgh) wrote :

Tested by deej.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'jujuclient/connector.py'
2--- jujuclient/connector.py 2016-08-30 21:54:13 +0000
3+++ jujuclient/connector.py 2017-03-09 17:33:04 +0000
4@@ -1,4 +1,5 @@
5 import errno
6+import logging
7 import os
8 import re
9 import socket
10@@ -8,6 +9,8 @@
11
12 import websocket
13
14+from .exc import EnvError
15+
16 try:
17 SSL_VERSION = ssl.PROTOCOL_TLSv1_2
18 except AttributeError:
19@@ -17,6 +20,8 @@
20 '2.7.9+ or 3.4+ instead. Attempting to use TLSv1 - may not work with '
21 'all versions of Juju.', RuntimeWarning)
22
23+log = logging.getLogger(__name__)
24+
25
26 class BaseConnector(object):
27 """Abstract out the details of connecting to state servers.
28@@ -33,16 +38,16 @@
29 def url_root(self):
30 raise NotImplementedError()
31
32+ def juju_home(self):
33+ raise NotImplementedError()
34+
35 def parse_env(self, env_name):
36 raise NotImplementedError()
37
38 def run(self, cls, env_name):
39 """Given an environment name, return an authenticated client to it."""
40 jhome, data = self.parse_env(env_name)
41- cert_dir = os.path.join(jhome, 'jclient')
42- if not os.path.exists(cert_dir):
43- os.mkdir(cert_dir)
44- cert_path = self.write_ca(cert_dir, env_name, data)
45+ cert_path = self.write_ca(env_name, data.get('ca-cert'))
46 address = self.get_state_server(data)
47 if not address:
48 return
49@@ -59,12 +64,35 @@
50 env = cls(endpoint, name=name, ca_cert=cert_path, env_uuid=env_uuid)
51 if not user.startswith('user-'):
52 user = "user-%s" % user
53- env.login(user=user, password=password)
54- return env
55+
56+ redirect_info = env.redirect_info()
57+ if not redirect_info:
58+ env.login(user=user, password=password)
59+ return env
60+
61+ cert_path = self.write_ca(name, redirect_info['ca-cert'])
62+ servers = [
63+ s for servers in redirect_info['servers']
64+ for s in servers if s["scope"] == 'public'
65+ ]
66+ for server in servers:
67+ endpoint = "wss://{value}:{port}".format(**server)
68+ if env_uuid:
69+ endpoint += self.url_root() + "/%s/api" % env_uuid
70+ env = cls(
71+ endpoint, name=name, ca_cert=cert_path, env_uuid=env_uuid)
72+ try:
73+ result = env.login(user=user, password=password)
74+ if 'discharge-required-error' in result:
75+ continue
76+ return env
77+ except EnvError:
78+ continue
79
80 @classmethod
81 def connect_socket(cls, endpoint, cert_path=None):
82 """Return a websocket connection to an endpoint."""
83+ log.debug("Connecting to %s", endpoint)
84
85 sslopt = cls.get_ssl_config(cert_path)
86 return websocket.create_connection(
87@@ -97,24 +125,28 @@
88 time.sleep(1)
89 continue
90
91- def write_ca(self, cert_dir, cert_name, data):
92- """Write ssl ca to the given."""
93+ def write_ca(self, cert_name, cert):
94+ """Write ssl ca to the given directory.
95+
96+ """
97+ if not cert:
98+ return None
99+
100+ cert_dir = os.path.join(self.juju_home(), 'jclient')
101+ if not os.path.exists(cert_dir):
102+ os.mkdir(cert_dir)
103+
104 cert_name = cert_name.replace(os.path.sep, '_')
105 cert_path = os.path.join(cert_dir, '%s-cacert.pem' % cert_name)
106 with open(cert_path, 'w') as ca_fh:
107- ca_fh.write(data['ca-cert'])
108+ ca_fh.write(cert)
109 return cert_path
110
111 def get_state_server(self, data):
112 """Given a list of state servers, return one that's listening."""
113- found = False
114 for s in data['state-servers']:
115 if self.is_server_available(s):
116- found = True
117- break
118- if not found:
119- return
120- return s
121+ return s
122
123 @staticmethod
124 def split_host_port(server):
125
126=== modified file 'jujuclient/juju1/connector.py'
127--- jujuclient/juju1/connector.py 2016-07-12 02:28:46 +0000
128+++ jujuclient/juju1/connector.py 2017-03-09 17:33:04 +0000
129@@ -20,9 +20,12 @@
130 def url_root(self):
131 return "/environment"
132
133+ def juju_home(self):
134+ return os.path.expanduser(
135+ os.environ.get('JUJU_HOME', '~/.juju'))
136+
137 def parse_env(self, env_name):
138- jhome = os.path.expanduser(
139- os.environ.get('JUJU_HOME', '~/.juju'))
140+ jhome = self.juju_home()
141
142 # Look in the cache file first.
143 cache_file = os.path.join(jhome, 'environments', 'cache.yaml')
144
145=== modified file 'jujuclient/juju2/connector.py'
146--- jujuclient/juju2/connector.py 2016-09-21 20:05:58 +0000
147+++ jujuclient/juju2/connector.py 2017-03-09 17:33:04 +0000
148@@ -20,6 +20,10 @@
149 def url_root(self):
150 return "/model"
151
152+ def juju_home(self):
153+ return os.path.expanduser(
154+ os.environ.get('JUJU_DATA', '~/.local/share/juju'))
155+
156 def _parse_env_name(self, env_name):
157 """Given an environment name such as is returned from `juju switch`,
158 return a tuple of (controller_name, model_name, owner). In many cases
159@@ -51,8 +55,7 @@
160 # The juju2 model access parameters are spread across multiple
161 # locations. Use commands to collect as much of this data as
162 # possible and use files only when required.
163- jhome = os.path.expanduser(
164- os.environ.get('JUJU_DATA', '~/.local/share/juju'))
165+ jhome = self.juju_home()
166
167 controller_name, model_name, owner = self._parse_env_name(env_name)
168 controller = self.get_controller(controller_name)
169@@ -61,7 +64,7 @@
170
171 return jhome, {
172 'user': account['user'],
173- 'password': account['password'],
174+ 'password': account.get('password', ''),
175 'environ-uuid': model['model-uuid'],
176 'server-uuid': controller['uuid'],
177 'state-servers': controller['api-endpoints'],
178
179=== modified file 'jujuclient/juju2/rpc.py'
180--- jujuclient/juju2/rpc.py 2016-07-12 02:28:46 +0000
181+++ jujuclient/juju2/rpc.py 2017-03-09 17:33:04 +0000
182@@ -1,7 +1,10 @@
183+import base64
184+import json
185 import logging
186+import os
187
188 from ..rpc import BaseRPC
189-from ..exc import LoginRequired
190+from ..exc import LoginRequired, EnvError
191
192 log = logging.getLogger(__name__)
193
194@@ -9,7 +12,9 @@
195 class RPC(BaseRPC):
196
197 def check_op(self, op):
198- if not self._auth and not op.get("request") == "Login":
199+ if (not self._auth and
200+ op.get("request") not in (
201+ "Login", "RedirectInfo")):
202 raise LoginRequired()
203
204 if 'params' not in op:
205@@ -32,9 +37,52 @@
206 return result['response']
207
208 def login_args(self, user, password):
209- return {
210+ d = {
211 "type": "Admin",
212 "request": "Login",
213 "version": 3,
214- "params": {"auth-tag": user,
215- "credentials": password}}
216+ "params": {
217+ "auth-tag": user,
218+ "credentials": password,
219+ "macaroons": [],
220+ }
221+ }
222+
223+ # If we have a user and password, proceed directly to
224+ # user/pass authentication with the api server.
225+ if user and password:
226+ return d
227+
228+ # Otherwise (in a shared controller scenario, password
229+ # will be blank), try macaroon authentication.
230+ with open(os.path.expanduser('~/.go-cookies'), 'r') as f:
231+ cookies = json.load(f)
232+
233+ base64_macaroons = [
234+ c['Value'] for c in cookies
235+ if c['Name'].startswith('macaroon-') and c['Value'] and
236+ c['CanonicalHost'] in self.endpoint
237+ ]
238+
239+ json_macaroons = [
240+ json.loads(base64.b64decode(value).decode('utf-8'))
241+ for value in base64_macaroons
242+ ]
243+ d["params"]["macaroons"] = json_macaroons
244+ if json_macaroons:
245+ d["params"]["auth-tag"] = ''
246+
247+ return d
248+
249+ def redirect_info(self):
250+ d = {
251+ "type": "Admin",
252+ "request": "RedirectInfo",
253+ "version": 3,
254+ }
255+ try:
256+ return self._rpc(d)
257+ except EnvError as e:
258+ if e.message == 'not redirected':
259+ return None
260+ raise
261
262=== modified file 'jujuclient/rpc.py'
263--- jujuclient/rpc.py 2016-07-12 23:44:17 +0000
264+++ jujuclient/rpc.py 2017-03-09 17:33:04 +0000
265@@ -33,6 +33,9 @@
266 def login_args(self, user, password):
267 raise NotImplementedError()
268
269+ def redirect_info(self):
270+ return None
271+
272 def _rpc(self, op):
273 op = self.check_op(op)
274 result = self._rpc_retry_if_upgrading(op)
275
276=== modified file 'tests/test_juju2.py'
277--- tests/test_juju2.py 2016-08-30 21:54:13 +0000
278+++ tests/test_juju2.py 2017-03-09 17:33:04 +0000
279@@ -452,12 +452,13 @@
280 def test_juju_info(self):
281 info_keys = list(sorted(self.env.info().keys()))
282 control = [
283- 'cloud',
284 'cloud-credential-tag',
285 'cloud-region',
286+ 'cloud-tag',
287 'controller-uuid',
288 'default-series',
289 'life',
290+ 'machines',
291 'name',
292 'owner-tag',
293 'provider-type',

Subscribers

People subscribed via source and target branches