Merge lp:~corey.bryant/charms/trusty/swift-storage/amulet-updates into lp:~openstack-charmers-archive/charms/trusty/swift-storage/next

Proposed by Corey Bryant
Status: Merged
Merged at revision: 45
Proposed branch: lp:~corey.bryant/charms/trusty/swift-storage/amulet-updates
Merge into: lp:~openstack-charmers-archive/charms/trusty/swift-storage/next
Diff against target: 1218 lines (+585/-137)
21 files modified
Makefile (+2/-1)
hooks/charmhelpers/contrib/hahelpers/apache.py (+10/-3)
hooks/charmhelpers/contrib/hahelpers/cluster.py (+1/-2)
hooks/charmhelpers/contrib/network/ip.py (+100/-16)
hooks/charmhelpers/contrib/openstack/amulet/deployment.py (+38/-8)
hooks/charmhelpers/contrib/openstack/amulet/utils.py (+5/-4)
hooks/charmhelpers/contrib/openstack/context.py (+75/-24)
hooks/charmhelpers/contrib/openstack/templates/haproxy.cfg (+9/-0)
hooks/charmhelpers/contrib/openstack/templates/openstack_https_frontend (+9/-8)
hooks/charmhelpers/contrib/openstack/templates/openstack_https_frontend.conf (+9/-8)
hooks/charmhelpers/core/hookenv.py (+17/-4)
hooks/charmhelpers/core/host.py (+30/-5)
hooks/charmhelpers/core/services/helpers.py (+119/-5)
hooks/charmhelpers/fetch/__init__.py (+19/-5)
hooks/charmhelpers/fetch/archiveurl.py (+49/-4)
tests/00-setup (+5/-5)
tests/README (+6/-0)
tests/basic_deployment.py (+20/-10)
tests/charmhelpers/contrib/amulet/deployment.py (+19/-13)
tests/charmhelpers/contrib/openstack/amulet/deployment.py (+38/-8)
tests/charmhelpers/contrib/openstack/amulet/utils.py (+5/-4)
To merge this branch: bzr merge lp:~corey.bryant/charms/trusty/swift-storage/amulet-updates
Reviewer Review Type Date Requested Status
OpenStack Charmers Pending
Review via email: mp+236518@code.launchpad.net
To post a comment you must log in.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'Makefile'
2--- Makefile 2014-08-13 13:13:37 +0000
3+++ Makefile 2014-09-30 13:42:56 +0000
4@@ -15,7 +15,8 @@
5 # coreycb note: The -v should only be temporary until Amulet sends
6 # raise_status() messages to stderr:
7 # https://bugs.launchpad.net/amulet/+bug/1320357
8- @juju test -v -p AMULET_HTTP_PROXY
9+ @juju test -v -p AMULET_HTTP_PROXY --timeout 900 \
10+ 00-setup 14-basic-precise-icehouse 15-basic-trusty-icehouse
11
12 bin/charm_helpers_sync.py:
13 @mkdir -p bin
14
15=== modified file 'hooks/charmhelpers/contrib/hahelpers/apache.py'
16--- hooks/charmhelpers/contrib/hahelpers/apache.py 2014-03-27 10:20:58 +0000
17+++ hooks/charmhelpers/contrib/hahelpers/apache.py 2014-09-30 13:42:56 +0000
18@@ -20,20 +20,27 @@
19 )
20
21
22-def get_cert():
23+def get_cert(cn=None):
24+ # TODO: deal with multiple https endpoints via charm config
25 cert = config_get('ssl_cert')
26 key = config_get('ssl_key')
27 if not (cert and key):
28 log("Inspecting identity-service relations for SSL certificate.",
29 level=INFO)
30 cert = key = None
31+ if cn:
32+ ssl_cert_attr = 'ssl_cert_{}'.format(cn)
33+ ssl_key_attr = 'ssl_key_{}'.format(cn)
34+ else:
35+ ssl_cert_attr = 'ssl_cert'
36+ ssl_key_attr = 'ssl_key'
37 for r_id in relation_ids('identity-service'):
38 for unit in relation_list(r_id):
39 if not cert:
40- cert = relation_get('ssl_cert',
41+ cert = relation_get(ssl_cert_attr,
42 rid=r_id, unit=unit)
43 if not key:
44- key = relation_get('ssl_key',
45+ key = relation_get(ssl_key_attr,
46 rid=r_id, unit=unit)
47 return (cert, key)
48
49
50=== modified file 'hooks/charmhelpers/contrib/hahelpers/cluster.py'
51--- hooks/charmhelpers/contrib/hahelpers/cluster.py 2014-08-13 13:13:37 +0000
52+++ hooks/charmhelpers/contrib/hahelpers/cluster.py 2014-09-30 13:42:56 +0000
53@@ -139,10 +139,9 @@
54 return True
55 for r_id in relation_ids('identity-service'):
56 for unit in relation_list(r_id):
57+ # TODO - needs fixing for new helper as ssl_cert/key suffixes with CN
58 rel_state = [
59 relation_get('https_keystone', rid=r_id, unit=unit),
60- relation_get('ssl_cert', rid=r_id, unit=unit),
61- relation_get('ssl_key', rid=r_id, unit=unit),
62 relation_get('ca_cert', rid=r_id, unit=unit),
63 ]
64 # NOTE: works around (LP: #1203241)
65
66=== modified file 'hooks/charmhelpers/contrib/network/ip.py'
67--- hooks/charmhelpers/contrib/network/ip.py 2014-08-13 13:13:37 +0000
68+++ hooks/charmhelpers/contrib/network/ip.py 2014-09-30 13:42:56 +0000
69@@ -1,10 +1,11 @@
70+import glob
71 import sys
72
73 from functools import partial
74
75 from charmhelpers.fetch import apt_install
76 from charmhelpers.core.hookenv import (
77- ERROR, log, config,
78+ ERROR, log,
79 )
80
81 try:
82@@ -156,19 +157,102 @@
83 get_netmask_for_address = partial(_get_for_address, key='netmask')
84
85
86-def get_ipv6_addr(iface="eth0"):
87+def format_ipv6_addr(address):
88+ """
89+ IPv6 needs to be wrapped with [] in url link to parse correctly.
90+ """
91+ if is_ipv6(address):
92+ address = "[%s]" % address
93+ else:
94+ log("Not an valid ipv6 address: %s" % address,
95+ level=ERROR)
96+ address = None
97+ return address
98+
99+
100+def get_iface_addr(iface='eth0', inet_type='AF_INET', inc_aliases=False, fatal=True, exc_list=None):
101+ """
102+ Return the assigned IP address for a given interface, if any, or [].
103+ """
104+ # Extract nic if passed /dev/ethX
105+ if '/' in iface:
106+ iface = iface.split('/')[-1]
107+ if not exc_list:
108+ exc_list = []
109 try:
110- iface_addrs = netifaces.ifaddresses(iface)
111- if netifaces.AF_INET6 not in iface_addrs:
112- raise Exception("Interface '%s' doesn't have an ipv6 address." % iface)
113-
114- addresses = netifaces.ifaddresses(iface)[netifaces.AF_INET6]
115- ipv6_addr = [a['addr'] for a in addresses if not a['addr'].startswith('fe80')
116- and config('vip') != a['addr']]
117- if not ipv6_addr:
118- raise Exception("Interface '%s' doesn't have global ipv6 address." % iface)
119-
120- return ipv6_addr[0]
121-
122- except ValueError:
123- raise ValueError("Invalid interface '%s'" % iface)
124+ inet_num = getattr(netifaces, inet_type)
125+ except AttributeError:
126+ raise Exception('Unknown inet type ' + str(inet_type))
127+
128+ interfaces = netifaces.interfaces()
129+ if inc_aliases:
130+ ifaces = []
131+ for _iface in interfaces:
132+ if iface == _iface or _iface.split(':')[0] == iface:
133+ ifaces.append(_iface)
134+ if fatal and not ifaces:
135+ raise Exception("Invalid interface '%s'" % iface)
136+ ifaces.sort()
137+ else:
138+ if iface not in interfaces:
139+ if fatal:
140+ raise Exception("%s not found " % (iface))
141+ else:
142+ return []
143+ else:
144+ ifaces = [iface]
145+
146+ addresses = []
147+ for netiface in ifaces:
148+ net_info = netifaces.ifaddresses(netiface)
149+ if inet_num in net_info:
150+ for entry in net_info[inet_num]:
151+ if 'addr' in entry and entry['addr'] not in exc_list:
152+ addresses.append(entry['addr'])
153+ if fatal and not addresses:
154+ raise Exception("Interface '%s' doesn't have any %s addresses." % (iface, inet_type))
155+ return addresses
156+
157+get_ipv4_addr = partial(get_iface_addr, inet_type='AF_INET')
158+
159+
160+def get_ipv6_addr(iface='eth0', inc_aliases=False, fatal=True, exc_list=None):
161+ """
162+ Return the assigned IPv6 address for a given interface, if any, or [].
163+ """
164+ addresses = get_iface_addr(iface=iface, inet_type='AF_INET6',
165+ inc_aliases=inc_aliases, fatal=fatal,
166+ exc_list=exc_list)
167+ remotly_addressable = []
168+ for address in addresses:
169+ if not address.startswith('fe80'):
170+ remotly_addressable.append(address)
171+ if fatal and not remotly_addressable:
172+ raise Exception("Interface '%s' doesn't have global ipv6 address." % iface)
173+ return remotly_addressable
174+
175+
176+def get_bridges(vnic_dir='/sys/devices/virtual/net'):
177+ """
178+ Return a list of bridges on the system or []
179+ """
180+ b_rgex = vnic_dir + '/*/bridge'
181+ return [x.replace(vnic_dir, '').split('/')[1] for x in glob.glob(b_rgex)]
182+
183+
184+def get_bridge_nics(bridge, vnic_dir='/sys/devices/virtual/net'):
185+ """
186+ Return a list of nics comprising a given bridge on the system or []
187+ """
188+ brif_rgex = "%s/%s/brif/*" % (vnic_dir, bridge)
189+ return [x.split('/')[-1] for x in glob.glob(brif_rgex)]
190+
191+
192+def is_bridge_member(nic):
193+ """
194+ Check if a given nic is a member of a bridge
195+ """
196+ for bridge in get_bridges():
197+ if nic in get_bridge_nics(bridge):
198+ return True
199+ return False
200
201=== modified file 'hooks/charmhelpers/contrib/openstack/amulet/deployment.py'
202--- hooks/charmhelpers/contrib/openstack/amulet/deployment.py 2014-08-13 13:13:37 +0000
203+++ hooks/charmhelpers/contrib/openstack/amulet/deployment.py 2014-09-30 13:42:56 +0000
204@@ -10,32 +10,62 @@
205 that is specifically for use by OpenStack charms.
206 """
207
208- def __init__(self, series=None, openstack=None, source=None):
209+ def __init__(self, series=None, openstack=None, source=None, stable=True):
210 """Initialize the deployment environment."""
211 super(OpenStackAmuletDeployment, self).__init__(series)
212 self.openstack = openstack
213 self.source = source
214+ self.stable = stable
215+ # Note(coreycb): this needs to be changed when new next branches come
216+ # out.
217+ self.current_next = "trusty"
218+
219+ def _determine_branch_locations(self, other_services):
220+ """Determine the branch locations for the other services.
221+
222+ Determine if the local branch being tested is derived from its
223+ stable or next (dev) branch, and based on this, use the corresonding
224+ stable or next branches for the other_services."""
225+ base_charms = ['mysql', 'mongodb', 'rabbitmq-server']
226+
227+ if self.stable:
228+ for svc in other_services:
229+ temp = 'lp:charms/{}'
230+ svc['location'] = temp.format(svc['name'])
231+ else:
232+ for svc in other_services:
233+ if svc['name'] in base_charms:
234+ temp = 'lp:charms/{}'
235+ svc['location'] = temp.format(svc['name'])
236+ else:
237+ temp = 'lp:~openstack-charmers/charms/{}/{}/next'
238+ svc['location'] = temp.format(self.current_next,
239+ svc['name'])
240+ return other_services
241
242 def _add_services(self, this_service, other_services):
243- """Add services to the deployment and set openstack-origin."""
244+ """Add services to the deployment and set openstack-origin/source."""
245+ other_services = self._determine_branch_locations(other_services)
246+
247 super(OpenStackAmuletDeployment, self)._add_services(this_service,
248 other_services)
249- name = 0
250+
251 services = other_services
252 services.append(this_service)
253- use_source = ['mysql', 'mongodb', 'rabbitmq-server', 'ceph']
254+ use_source = ['mysql', 'mongodb', 'rabbitmq-server', 'ceph',
255+ 'ceph-osd', 'ceph-radosgw']
256
257 if self.openstack:
258 for svc in services:
259- if svc[name] not in use_source:
260+ if svc['name'] not in use_source:
261 config = {'openstack-origin': self.openstack}
262- self.d.configure(svc[name], config)
263+ self.d.configure(svc['name'], config)
264
265 if self.source:
266 for svc in services:
267- if svc[name] in use_source:
268+ if svc['name'] in use_source:
269 config = {'source': self.source}
270- self.d.configure(svc[name], config)
271+ self.d.configure(svc['name'], config)
272
273 def _configure_services(self, configs):
274 """Configure all of the services."""
275
276=== modified file 'hooks/charmhelpers/contrib/openstack/amulet/utils.py'
277--- hooks/charmhelpers/contrib/openstack/amulet/utils.py 2014-08-13 13:13:37 +0000
278+++ hooks/charmhelpers/contrib/openstack/amulet/utils.py 2014-09-30 13:42:56 +0000
279@@ -187,15 +187,16 @@
280
281 f = opener.open("http://download.cirros-cloud.net/version/released")
282 version = f.read().strip()
283- cirros_img = "tests/cirros-{}-x86_64-disk.img".format(version)
284+ cirros_img = "cirros-{}-x86_64-disk.img".format(version)
285+ local_path = os.path.join('tests', cirros_img)
286
287- if not os.path.exists(cirros_img):
288+ if not os.path.exists(local_path):
289 cirros_url = "http://{}/{}/{}".format("download.cirros-cloud.net",
290 version, cirros_img)
291- opener.retrieve(cirros_url, cirros_img)
292+ opener.retrieve(cirros_url, local_path)
293 f.close()
294
295- with open(cirros_img) as f:
296+ with open(local_path) as f:
297 image = glance.images.create(name=image_name, is_public=True,
298 disk_format='qcow2',
299 container_format='bare', data=f)
300
301=== modified file 'hooks/charmhelpers/contrib/openstack/context.py'
302--- hooks/charmhelpers/contrib/openstack/context.py 2014-08-13 13:13:37 +0000
303+++ hooks/charmhelpers/contrib/openstack/context.py 2014-09-30 13:42:56 +0000
304@@ -8,7 +8,6 @@
305 check_call
306 )
307
308-
309 from charmhelpers.fetch import (
310 apt_install,
311 filter_installed_packages,
312@@ -28,6 +27,11 @@
313 INFO
314 )
315
316+from charmhelpers.core.host import (
317+ mkdir,
318+ write_file
319+)
320+
321 from charmhelpers.contrib.hahelpers.cluster import (
322 determine_apache_port,
323 determine_api_port,
324@@ -38,6 +42,7 @@
325 from charmhelpers.contrib.hahelpers.apache import (
326 get_cert,
327 get_ca_cert,
328+ install_ca_cert,
329 )
330
331 from charmhelpers.contrib.openstack.neutron import (
332@@ -47,6 +52,7 @@
333 from charmhelpers.contrib.network.ip import (
334 get_address_in_network,
335 get_ipv6_addr,
336+ is_address_in_network
337 )
338
339 CA_CERT_PATH = '/usr/local/share/ca-certificates/keystone_juju_ca_cert.crt'
340@@ -421,6 +427,11 @@
341 'units': cluster_hosts,
342 }
343
344+ if config('haproxy-server-timeout'):
345+ ctxt['haproxy_server_timeout'] = config('haproxy-server-timeout')
346+ if config('haproxy-client-timeout'):
347+ ctxt['haproxy_client_timeout'] = config('haproxy-client-timeout')
348+
349 if config('prefer-ipv6'):
350 ctxt['local_host'] = 'ip6-localhost'
351 ctxt['haproxy_host'] = '::'
352@@ -490,22 +501,36 @@
353 cmd = ['a2enmod', 'ssl', 'proxy', 'proxy_http']
354 check_call(cmd)
355
356- def configure_cert(self):
357- if not os.path.isdir('/etc/apache2/ssl'):
358- os.mkdir('/etc/apache2/ssl')
359+ def configure_cert(self, cn=None):
360 ssl_dir = os.path.join('/etc/apache2/ssl/', self.service_namespace)
361- if not os.path.isdir(ssl_dir):
362- os.mkdir(ssl_dir)
363- cert, key = get_cert()
364- with open(os.path.join(ssl_dir, 'cert'), 'w') as cert_out:
365- cert_out.write(b64decode(cert))
366- with open(os.path.join(ssl_dir, 'key'), 'w') as key_out:
367- key_out.write(b64decode(key))
368+ mkdir(path=ssl_dir)
369+ cert, key = get_cert(cn)
370+ if cn:
371+ cert_filename = 'cert_{}'.format(cn)
372+ key_filename = 'key_{}'.format(cn)
373+ else:
374+ cert_filename = 'cert'
375+ key_filename = 'key'
376+ write_file(path=os.path.join(ssl_dir, cert_filename),
377+ content=b64decode(cert))
378+ write_file(path=os.path.join(ssl_dir, key_filename),
379+ content=b64decode(key))
380+
381+ def configure_ca(self):
382 ca_cert = get_ca_cert()
383 if ca_cert:
384- with open(CA_CERT_PATH, 'w') as ca_out:
385- ca_out.write(b64decode(ca_cert))
386- check_call(['update-ca-certificates'])
387+ install_ca_cert(b64decode(ca_cert))
388+
389+ def canonical_names(self):
390+ '''Figure out which canonical names clients will access this service'''
391+ cns = []
392+ for r_id in relation_ids('identity-service'):
393+ for unit in related_units(r_id):
394+ rdata = relation_get(rid=r_id, unit=unit)
395+ for k in rdata:
396+ if k.startswith('ssl_key_'):
397+ cns.append(k.lstrip('ssl_key_'))
398+ return list(set(cns))
399
400 def __call__(self):
401 if isinstance(self.external_ports, basestring):
402@@ -513,21 +538,47 @@
403 if (not self.external_ports or not https()):
404 return {}
405
406- self.configure_cert()
407+ self.configure_ca()
408 self.enable_modules()
409
410 ctxt = {
411 'namespace': self.service_namespace,
412- 'private_address': unit_get('private-address'),
413- 'endpoints': []
414+ 'endpoints': [],
415+ 'ext_ports': []
416 }
417- if is_clustered():
418- ctxt['private_address'] = config('vip')
419- for api_port in self.external_ports:
420- ext_port = determine_apache_port(api_port)
421- int_port = determine_api_port(api_port)
422- portmap = (int(ext_port), int(int_port))
423- ctxt['endpoints'].append(portmap)
424+
425+ for cn in self.canonical_names():
426+ self.configure_cert(cn)
427+
428+ addresses = []
429+ vips = []
430+ if config('vip'):
431+ vips = config('vip').split()
432+
433+ for network_type in ['os-internal-network',
434+ 'os-admin-network',
435+ 'os-public-network']:
436+ address = get_address_in_network(config(network_type),
437+ unit_get('private-address'))
438+ if len(vips) > 0 and is_clustered():
439+ for vip in vips:
440+ if is_address_in_network(config(network_type),
441+ vip):
442+ addresses.append((address, vip))
443+ break
444+ elif is_clustered():
445+ addresses.append((address, config('vip')))
446+ else:
447+ addresses.append((address, address))
448+
449+ for address, endpoint in set(addresses):
450+ for api_port in self.external_ports:
451+ ext_port = determine_apache_port(api_port)
452+ int_port = determine_api_port(api_port)
453+ portmap = (address, endpoint, int(ext_port), int(int_port))
454+ ctxt['endpoints'].append(portmap)
455+ ctxt['ext_ports'].append(int(ext_port))
456+ ctxt['ext_ports'] = list(set(ctxt['ext_ports']))
457 return ctxt
458
459
460
461=== modified file 'hooks/charmhelpers/contrib/openstack/templates/haproxy.cfg'
462--- hooks/charmhelpers/contrib/openstack/templates/haproxy.cfg 2014-08-13 13:13:37 +0000
463+++ hooks/charmhelpers/contrib/openstack/templates/haproxy.cfg 2014-09-30 13:42:56 +0000
464@@ -14,8 +14,17 @@
465 retries 3
466 timeout queue 1000
467 timeout connect 1000
468+{% if haproxy_client_timeout -%}
469+ timeout client {{ haproxy_client_timeout }}
470+{% else -%}
471 timeout client 30000
472+{% endif -%}
473+
474+{% if haproxy_server_timeout -%}
475+ timeout server {{ haproxy_server_timeout }}
476+{% else -%}
477 timeout server 30000
478+{% endif -%}
479
480 listen stats {{ stat_port }}
481 mode http
482
483=== modified file 'hooks/charmhelpers/contrib/openstack/templates/openstack_https_frontend'
484--- hooks/charmhelpers/contrib/openstack/templates/openstack_https_frontend 2013-08-16 20:34:18 +0000
485+++ hooks/charmhelpers/contrib/openstack/templates/openstack_https_frontend 2014-09-30 13:42:56 +0000
486@@ -1,16 +1,18 @@
487 {% if endpoints -%}
488-{% for ext, int in endpoints -%}
489-Listen {{ ext }}
490-NameVirtualHost *:{{ ext }}
491-<VirtualHost *:{{ ext }}>
492- ServerName {{ private_address }}
493+{% for ext_port in ext_ports -%}
494+Listen {{ ext_port }}
495+{% endfor -%}
496+{% for address, endpoint, ext, int in endpoints -%}
497+<VirtualHost {{ address }}:{{ ext }}>
498+ ServerName {{ endpoint }}
499 SSLEngine on
500- SSLCertificateFile /etc/apache2/ssl/{{ namespace }}/cert
501- SSLCertificateKeyFile /etc/apache2/ssl/{{ namespace }}/key
502+ SSLCertificateFile /etc/apache2/ssl/{{ namespace }}/cert_{{ endpoint }}
503+ SSLCertificateKeyFile /etc/apache2/ssl/{{ namespace }}/key_{{ endpoint }}
504 ProxyPass / http://localhost:{{ int }}/
505 ProxyPassReverse / http://localhost:{{ int }}/
506 ProxyPreserveHost on
507 </VirtualHost>
508+{% endfor -%}
509 <Proxy *>
510 Order deny,allow
511 Allow from all
512@@ -19,5 +21,4 @@
513 Order allow,deny
514 Allow from all
515 </Location>
516-{% endfor -%}
517 {% endif -%}
518
519=== modified file 'hooks/charmhelpers/contrib/openstack/templates/openstack_https_frontend.conf'
520--- hooks/charmhelpers/contrib/openstack/templates/openstack_https_frontend.conf 2013-09-27 16:33:06 +0000
521+++ hooks/charmhelpers/contrib/openstack/templates/openstack_https_frontend.conf 2014-09-30 13:42:56 +0000
522@@ -1,16 +1,18 @@
523 {% if endpoints -%}
524-{% for ext, int in endpoints -%}
525-Listen {{ ext }}
526-NameVirtualHost *:{{ ext }}
527-<VirtualHost *:{{ ext }}>
528- ServerName {{ private_address }}
529+{% for ext_port in ext_ports -%}
530+Listen {{ ext_port }}
531+{% endfor -%}
532+{% for address, endpoint, ext, int in endpoints -%}
533+<VirtualHost {{ address }}:{{ ext }}>
534+ ServerName {{ endpoint }}
535 SSLEngine on
536- SSLCertificateFile /etc/apache2/ssl/{{ namespace }}/cert
537- SSLCertificateKeyFile /etc/apache2/ssl/{{ namespace }}/key
538+ SSLCertificateFile /etc/apache2/ssl/{{ namespace }}/cert_{{ endpoint }}
539+ SSLCertificateKeyFile /etc/apache2/ssl/{{ namespace }}/key_{{ endpoint }}
540 ProxyPass / http://localhost:{{ int }}/
541 ProxyPassReverse / http://localhost:{{ int }}/
542 ProxyPreserveHost on
543 </VirtualHost>
544+{% endfor -%}
545 <Proxy *>
546 Order deny,allow
547 Allow from all
548@@ -19,5 +21,4 @@
549 Order allow,deny
550 Allow from all
551 </Location>
552-{% endfor -%}
553 {% endif -%}
554
555=== modified file 'hooks/charmhelpers/core/hookenv.py'
556--- hooks/charmhelpers/core/hookenv.py 2014-09-02 13:21:15 +0000
557+++ hooks/charmhelpers/core/hookenv.py 2014-09-30 13:42:56 +0000
558@@ -203,6 +203,17 @@
559 if os.path.exists(self.path):
560 self.load_previous()
561
562+ def __getitem__(self, key):
563+ """For regular dict lookups, check the current juju config first,
564+ then the previous (saved) copy. This ensures that user-saved values
565+ will be returned by a dict lookup.
566+
567+ """
568+ try:
569+ return dict.__getitem__(self, key)
570+ except KeyError:
571+ return (self._prev_dict or {})[key]
572+
573 def load_previous(self, path=None):
574 """Load previous copy of config from disk.
575
576@@ -475,9 +486,10 @@
577 hooks.execute(sys.argv)
578 """
579
580- def __init__(self):
581+ def __init__(self, config_save=True):
582 super(Hooks, self).__init__()
583 self._hooks = {}
584+ self._config_save = config_save
585
586 def register(self, name, function):
587 """Register a hook"""
588@@ -488,9 +500,10 @@
589 hook_name = os.path.basename(args[0])
590 if hook_name in self._hooks:
591 self._hooks[hook_name]()
592- cfg = config()
593- if cfg.implicit_save:
594- cfg.save()
595+ if self._config_save:
596+ cfg = config()
597+ if cfg.implicit_save:
598+ cfg.save()
599 else:
600 raise UnregisteredHookError(hook_name)
601
602
603=== modified file 'hooks/charmhelpers/core/host.py'
604--- hooks/charmhelpers/core/host.py 2014-08-26 13:19:40 +0000
605+++ hooks/charmhelpers/core/host.py 2014-09-30 13:42:56 +0000
606@@ -68,8 +68,8 @@
607 """Determine whether a system service is available"""
608 try:
609 subprocess.check_output(['service', service_name, 'status'], stderr=subprocess.STDOUT)
610- except subprocess.CalledProcessError:
611- return False
612+ except subprocess.CalledProcessError as e:
613+ return 'unrecognized service' not in e.output
614 else:
615 return True
616
617@@ -209,10 +209,15 @@
618 return system_mounts
619
620
621-def file_hash(path):
622- """Generate a md5 hash of the contents of 'path' or None if not found """
623+def file_hash(path, hash_type='md5'):
624+ """
625+ Generate a hash checksum of the contents of 'path' or None if not found.
626+
627+ :param str hash_type: Any hash alrgorithm supported by :mod:`hashlib`,
628+ such as md5, sha1, sha256, sha512, etc.
629+ """
630 if os.path.exists(path):
631- h = hashlib.md5()
632+ h = getattr(hashlib, hash_type)()
633 with open(path, 'r') as source:
634 h.update(source.read()) # IGNORE:E1101 - it does have update
635 return h.hexdigest()
636@@ -220,6 +225,26 @@
637 return None
638
639
640+def check_hash(path, checksum, hash_type='md5'):
641+ """
642+ Validate a file using a cryptographic checksum.
643+
644+ :param str checksum: Value of the checksum used to validate the file.
645+ :param str hash_type: Hash algorithm used to generate `checksum`.
646+ Can be any hash alrgorithm supported by :mod:`hashlib`,
647+ such as md5, sha1, sha256, sha512, etc.
648+ :raises ChecksumError: If the file fails the checksum
649+
650+ """
651+ actual_checksum = file_hash(path, hash_type)
652+ if checksum != actual_checksum:
653+ raise ChecksumError("'%s' != '%s'" % (checksum, actual_checksum))
654+
655+
656+class ChecksumError(ValueError):
657+ pass
658+
659+
660 def restart_on_change(restart_map, stopstart=False):
661 """Restart services based on configuration files changing
662
663
664=== modified file 'hooks/charmhelpers/core/services/helpers.py'
665--- hooks/charmhelpers/core/services/helpers.py 2014-08-13 13:13:37 +0000
666+++ hooks/charmhelpers/core/services/helpers.py 2014-09-30 13:42:56 +0000
667@@ -1,3 +1,5 @@
668+import os
669+import yaml
670 from charmhelpers.core import hookenv
671 from charmhelpers.core import templating
672
673@@ -19,15 +21,21 @@
674 the `name` attribute that are complete will used to populate the dictionary
675 values (see `get_data`, below).
676
677- The generated context will be namespaced under the interface type, to prevent
678- potential naming conflicts.
679+ The generated context will be namespaced under the relation :attr:`name`,
680+ to prevent potential naming conflicts.
681+
682+ :param str name: Override the relation :attr:`name`, since it can vary from charm to charm
683+ :param list additional_required_keys: Extend the list of :attr:`required_keys`
684 """
685 name = None
686 interface = None
687 required_keys = []
688
689- def __init__(self, *args, **kwargs):
690- super(RelationContext, self).__init__(*args, **kwargs)
691+ def __init__(self, name=None, additional_required_keys=None):
692+ if name is not None:
693+ self.name = name
694+ if additional_required_keys is not None:
695+ self.required_keys.extend(additional_required_keys)
696 self.get_data()
697
698 def __bool__(self):
699@@ -101,9 +109,115 @@
700 return {}
701
702
703+class MysqlRelation(RelationContext):
704+ """
705+ Relation context for the `mysql` interface.
706+
707+ :param str name: Override the relation :attr:`name`, since it can vary from charm to charm
708+ :param list additional_required_keys: Extend the list of :attr:`required_keys`
709+ """
710+ name = 'db'
711+ interface = 'mysql'
712+ required_keys = ['host', 'user', 'password', 'database']
713+
714+
715+class HttpRelation(RelationContext):
716+ """
717+ Relation context for the `http` interface.
718+
719+ :param str name: Override the relation :attr:`name`, since it can vary from charm to charm
720+ :param list additional_required_keys: Extend the list of :attr:`required_keys`
721+ """
722+ name = 'website'
723+ interface = 'http'
724+ required_keys = ['host', 'port']
725+
726+ def provide_data(self):
727+ return {
728+ 'host': hookenv.unit_get('private-address'),
729+ 'port': 80,
730+ }
731+
732+
733+class RequiredConfig(dict):
734+ """
735+ Data context that loads config options with one or more mandatory options.
736+
737+ Once the required options have been changed from their default values, all
738+ config options will be available, namespaced under `config` to prevent
739+ potential naming conflicts (for example, between a config option and a
740+ relation property).
741+
742+ :param list *args: List of options that must be changed from their default values.
743+ """
744+
745+ def __init__(self, *args):
746+ self.required_options = args
747+ self['config'] = hookenv.config()
748+ with open(os.path.join(hookenv.charm_dir(), 'config.yaml')) as fp:
749+ self.config = yaml.load(fp).get('options', {})
750+
751+ def __bool__(self):
752+ for option in self.required_options:
753+ if option not in self['config']:
754+ return False
755+ current_value = self['config'][option]
756+ default_value = self.config[option].get('default')
757+ if current_value == default_value:
758+ return False
759+ if current_value in (None, '') and default_value in (None, ''):
760+ return False
761+ return True
762+
763+ def __nonzero__(self):
764+ return self.__bool__()
765+
766+
767+class StoredContext(dict):
768+ """
769+ A data context that always returns the data that it was first created with.
770+
771+ This is useful to do a one-time generation of things like passwords, that
772+ will thereafter use the same value that was originally generated, instead
773+ of generating a new value each time it is run.
774+ """
775+ def __init__(self, file_name, config_data):
776+ """
777+ If the file exists, populate `self` with the data from the file.
778+ Otherwise, populate with the given data and persist it to the file.
779+ """
780+ if os.path.exists(file_name):
781+ self.update(self.read_context(file_name))
782+ else:
783+ self.store_context(file_name, config_data)
784+ self.update(config_data)
785+
786+ def store_context(self, file_name, config_data):
787+ if not os.path.isabs(file_name):
788+ file_name = os.path.join(hookenv.charm_dir(), file_name)
789+ with open(file_name, 'w') as file_stream:
790+ os.fchmod(file_stream.fileno(), 0600)
791+ yaml.dump(config_data, file_stream)
792+
793+ def read_context(self, file_name):
794+ if not os.path.isabs(file_name):
795+ file_name = os.path.join(hookenv.charm_dir(), file_name)
796+ with open(file_name, 'r') as file_stream:
797+ data = yaml.load(file_stream)
798+ if not data:
799+ raise OSError("%s is empty" % file_name)
800+ return data
801+
802+
803 class TemplateCallback(ManagerCallback):
804 """
805- Callback class that will render a template, for use as a ready action.
806+ Callback class that will render a Jinja2 template, for use as a ready action.
807+
808+ :param str source: The template source file, relative to `$CHARM_DIR/templates`
809+ :param str target: The target to write the rendered template to
810+ :param str owner: The owner of the rendered file
811+ :param str group: The group of the rendered file
812+ :param int perms: The permissions of the rendered file
813 """
814 def __init__(self, source, target, owner='root', group='root', perms=0444):
815 self.source = source
816
817=== modified file 'hooks/charmhelpers/fetch/__init__.py'
818--- hooks/charmhelpers/fetch/__init__.py 2014-08-26 13:19:40 +0000
819+++ hooks/charmhelpers/fetch/__init__.py 2014-09-30 13:42:56 +0000
820@@ -208,7 +208,8 @@
821 """Add a package source to this system.
822
823 @param source: a URL or sources.list entry, as supported by
824- add-apt-repository(1). Examples:
825+ add-apt-repository(1). Examples::
826+
827 ppa:charmers/example
828 deb https://stub:key@private.example.com/ubuntu trusty main
829
830@@ -311,22 +312,35 @@
831 apt_update(fatal=True)
832
833
834-def install_remote(source):
835+def install_remote(source, *args, **kwargs):
836 """
837 Install a file tree from a remote source
838
839 The specified source should be a url of the form:
840 scheme://[host]/path[#[option=value][&...]]
841
842- Schemes supported are based on this modules submodules
843- Options supported are submodule-specific"""
844+ Schemes supported are based on this modules submodules.
845+ Options supported are submodule-specific.
846+ Additional arguments are passed through to the submodule.
847+
848+ For example::
849+
850+ dest = install_remote('http://example.com/archive.tgz',
851+ checksum='deadbeef',
852+ hash_type='sha1')
853+
854+ This will download `archive.tgz`, validate it using SHA1 and, if
855+ the file is ok, extract it and return the directory in which it
856+ was extracted. If the checksum fails, it will raise
857+ :class:`charmhelpers.core.host.ChecksumError`.
858+ """
859 # We ONLY check for True here because can_handle may return a string
860 # explaining why it can't handle a given source.
861 handlers = [h for h in plugins() if h.can_handle(source) is True]
862 installed_to = None
863 for handler in handlers:
864 try:
865- installed_to = handler.install(source)
866+ installed_to = handler.install(source, *args, **kwargs)
867 except UnhandledSource:
868 pass
869 if not installed_to:
870
871=== modified file 'hooks/charmhelpers/fetch/archiveurl.py'
872--- hooks/charmhelpers/fetch/archiveurl.py 2014-03-20 13:47:54 +0000
873+++ hooks/charmhelpers/fetch/archiveurl.py 2014-09-30 13:42:56 +0000
874@@ -1,6 +1,8 @@
875 import os
876 import urllib2
877+from urllib import urlretrieve
878 import urlparse
879+import hashlib
880
881 from charmhelpers.fetch import (
882 BaseFetchHandler,
883@@ -10,11 +12,19 @@
884 get_archive_handler,
885 extract,
886 )
887-from charmhelpers.core.host import mkdir
888+from charmhelpers.core.host import mkdir, check_hash
889
890
891 class ArchiveUrlFetchHandler(BaseFetchHandler):
892- """Handler for archives via generic URLs"""
893+ """
894+ Handler to download archive files from arbitrary URLs.
895+
896+ Can fetch from http, https, ftp, and file URLs.
897+
898+ Can install either tarballs (.tar, .tgz, .tbz2, etc) or zip files.
899+
900+ Installs the contents of the archive in $CHARM_DIR/fetched/.
901+ """
902 def can_handle(self, source):
903 url_parts = self.parse_url(source)
904 if url_parts.scheme not in ('http', 'https', 'ftp', 'file'):
905@@ -24,6 +34,12 @@
906 return False
907
908 def download(self, source, dest):
909+ """
910+ Download an archive file.
911+
912+ :param str source: URL pointing to an archive file.
913+ :param str dest: Local path location to download archive file to.
914+ """
915 # propogate all exceptions
916 # URLError, OSError, etc
917 proto, netloc, path, params, query, fragment = urlparse.urlparse(source)
918@@ -48,7 +64,30 @@
919 os.unlink(dest)
920 raise e
921
922- def install(self, source):
923+ # Mandatory file validation via Sha1 or MD5 hashing.
924+ def download_and_validate(self, url, hashsum, validate="sha1"):
925+ tempfile, headers = urlretrieve(url)
926+ check_hash(tempfile, hashsum, validate)
927+ return tempfile
928+
929+ def install(self, source, dest=None, checksum=None, hash_type='sha1'):
930+ """
931+ Download and install an archive file, with optional checksum validation.
932+
933+ The checksum can also be given on the `source` URL's fragment.
934+ For example::
935+
936+ handler.install('http://example.com/file.tgz#sha1=deadbeef')
937+
938+ :param str source: URL pointing to an archive file.
939+ :param str dest: Local destination path to install to. If not given,
940+ installs to `$CHARM_DIR/archives/archive_file_name`.
941+ :param str checksum: If given, validate the archive file after download.
942+ :param str hash_type: Algorithm used to generate `checksum`.
943+ Can be any hash alrgorithm supported by :mod:`hashlib`,
944+ such as md5, sha1, sha256, sha512, etc.
945+
946+ """
947 url_parts = self.parse_url(source)
948 dest_dir = os.path.join(os.environ.get('CHARM_DIR'), 'fetched')
949 if not os.path.exists(dest_dir):
950@@ -60,4 +99,10 @@
951 raise UnhandledSource(e.reason)
952 except OSError as e:
953 raise UnhandledSource(e.strerror)
954- return extract(dld_file)
955+ options = urlparse.parse_qs(url_parts.fragment)
956+ for key, value in options.items():
957+ if key in hashlib.algorithms:
958+ check_hash(dld_file, value, key)
959+ if checksum:
960+ check_hash(dld_file, checksum, hash_type)
961+ return extract(dld_file, dest)
962
963=== modified file 'tests/00-setup'
964--- tests/00-setup 2014-07-11 16:41:12 +0000
965+++ tests/00-setup 2014-09-30 13:42:56 +0000
966@@ -4,8 +4,8 @@
967
968 sudo add-apt-repository --yes ppa:juju/stable
969 sudo apt-get update --yes
970-sudo apt-get install --yes python-amulet
971-sudo apt-get install --yes python-swiftclient
972-sudo apt-get install --yes python-glanceclient
973-sudo apt-get install --yes python-keystoneclient
974-sudo apt-get install --yes python-novaclient
975+sudo apt-get install --yes python-amulet \
976+ python-swiftclient \
977+ python-glanceclient \
978+ python-keystoneclient \
979+ python-novaclient
980
981=== modified file 'tests/README'
982--- tests/README 2014-07-11 16:41:12 +0000
983+++ tests/README 2014-09-30 13:42:56 +0000
984@@ -1,6 +1,12 @@
985 This directory provides Amulet tests that focus on verification of swift-storage
986 deployments.
987
988+In order to run tests, you'll need charm-tools installed (in addition to
989+juju, of course):
990+ sudo add-apt-repository ppa:juju/stable
991+ sudo apt-get update
992+ sudo apt-get install charm-tools
993+
994 If you use a web proxy server to access the web, you'll need to set the
995 AMULET_HTTP_PROXY environment variable to the http URL of the proxy server.
996
997
998=== modified file 'tests/basic_deployment.py'
999--- tests/basic_deployment.py 2014-07-11 16:41:12 +0000
1000+++ tests/basic_deployment.py 2014-09-30 13:42:56 +0000
1001@@ -20,10 +20,10 @@
1002 class SwiftStorageBasicDeployment(OpenStackAmuletDeployment):
1003 """Amulet tests on a basic swift-storage deployment."""
1004
1005- def __init__(self, series, openstack=None, source=None):
1006+ def __init__(self, series, openstack=None, source=None, stable=False):
1007 """Deploy the entire test environment."""
1008 super(SwiftStorageBasicDeployment, self).__init__(series, openstack,
1009- source)
1010+ source, stable)
1011 self._add_services()
1012 self._add_relations()
1013 self._configure_services()
1014@@ -31,12 +31,15 @@
1015 self._initialize_tests()
1016
1017 def _add_services(self):
1018- """Add the service that we're testing, including the number of units,
1019- where swift-storage is local, and the other charms are from
1020- the charm store."""
1021- this_service = ('swift-storage', 1)
1022- other_services = [('mysql', 1),
1023- ('keystone', 1), ('glance', 1), ('swift-proxy', 1)]
1024+ """Add services
1025+
1026+ Add the services that we're testing, where swift-storage is local,
1027+ and the rest of the service are from lp branches that are
1028+ compatible with the local charm (e.g. stable or next).
1029+ """
1030+ this_service = {'name': 'swift-storage'}
1031+ other_services = [{'name': 'mysql'}, {'name': 'keystone'},
1032+ {'name': 'glance'}, {'name': 'swift-proxy'}]
1033 super(SwiftStorageBasicDeployment, self)._add_services(this_service,
1034 other_services)
1035
1036@@ -249,9 +252,14 @@
1037 message = u.relation_error('swift-proxy swift-storage', ret)
1038 amulet.raise_status(amulet.FAIL, msg=message)
1039
1040- def test_restart_on_config_change(self):
1041+ def test_z_restart_on_config_change(self):
1042 """Verify that the specified services are restarted when the config
1043- is changed."""
1044+ is changed.
1045+
1046+ Note(coreycb): The method name with the _z_ is a little odd
1047+ but it forces the test to run last. It just makes things
1048+ easier because restarting services requires re-authorization.
1049+ """
1050 # NOTE(coreycb): Skipping failing test on until resolved. This test
1051 # fails because the config file's last mod time is
1052 # slightly after the process' last mod time.
1053@@ -282,6 +290,8 @@
1054 config = '/etc/swift/{}'.format(conf)
1055 if not u.service_restarted(self.swift_storage_sentry, s, config,
1056 pgrep_full=True, sleep_time=time):
1057+ self.d.configure('swift-storage',
1058+ {'object-server-threads-per-disk': '4'})
1059 msg = "service {} didn't restart after config change".format(s)
1060 amulet.raise_status(amulet.FAIL, msg=msg)
1061 time = 0
1062
1063=== modified file 'tests/charmhelpers/contrib/amulet/deployment.py'
1064--- tests/charmhelpers/contrib/amulet/deployment.py 2014-08-13 13:13:37 +0000
1065+++ tests/charmhelpers/contrib/amulet/deployment.py 2014-09-30 13:42:56 +0000
1066@@ -24,25 +24,31 @@
1067 """Add services.
1068
1069 Add services to the deployment where this_service is the local charm
1070- that we're focused on testing and other_services are the other
1071- charms that come from the charm store.
1072+ that we're testing and other_services are the other services that
1073+ are being used in the local amulet tests.
1074 """
1075- name, units = range(2)
1076-
1077- if this_service[name] != os.path.basename(os.getcwd()):
1078- s = this_service[name]
1079+ if this_service['name'] != os.path.basename(os.getcwd()):
1080+ s = this_service['name']
1081 msg = "The charm's root directory name needs to be {}".format(s)
1082 amulet.raise_status(amulet.FAIL, msg=msg)
1083
1084- self.d.add(this_service[name], units=this_service[units])
1085+ if 'units' not in this_service:
1086+ this_service['units'] = 1
1087+
1088+ self.d.add(this_service['name'], units=this_service['units'])
1089
1090 for svc in other_services:
1091- if self.series:
1092- self.d.add(svc[name],
1093- charm='cs:{}/{}'.format(self.series, svc[name]),
1094- units=svc[units])
1095+ if 'location' in svc:
1096+ branch_location = svc['location']
1097+ elif self.series:
1098+ branch_location = 'cs:{}/{}'.format(self.series, svc['name']),
1099 else:
1100- self.d.add(svc[name], units=svc[units])
1101+ branch_location = None
1102+
1103+ if 'units' not in svc:
1104+ svc['units'] = 1
1105+
1106+ self.d.add(svc['name'], charm=branch_location, units=svc['units'])
1107
1108 def _add_relations(self, relations):
1109 """Add all of the relations for the services."""
1110@@ -57,7 +63,7 @@
1111 def _deploy(self):
1112 """Deploy environment and wait for all hooks to finish executing."""
1113 try:
1114- self.d.setup()
1115+ self.d.setup(timeout=900)
1116 self.d.sentry.wait(timeout=900)
1117 except amulet.helpers.TimeoutError:
1118 amulet.raise_status(amulet.FAIL, msg="Deployment timed out")
1119
1120=== modified file 'tests/charmhelpers/contrib/openstack/amulet/deployment.py'
1121--- tests/charmhelpers/contrib/openstack/amulet/deployment.py 2014-08-13 13:13:37 +0000
1122+++ tests/charmhelpers/contrib/openstack/amulet/deployment.py 2014-09-30 13:42:56 +0000
1123@@ -10,32 +10,62 @@
1124 that is specifically for use by OpenStack charms.
1125 """
1126
1127- def __init__(self, series=None, openstack=None, source=None):
1128+ def __init__(self, series=None, openstack=None, source=None, stable=True):
1129 """Initialize the deployment environment."""
1130 super(OpenStackAmuletDeployment, self).__init__(series)
1131 self.openstack = openstack
1132 self.source = source
1133+ self.stable = stable
1134+ # Note(coreycb): this needs to be changed when new next branches come
1135+ # out.
1136+ self.current_next = "trusty"
1137+
1138+ def _determine_branch_locations(self, other_services):
1139+ """Determine the branch locations for the other services.
1140+
1141+ Determine if the local branch being tested is derived from its
1142+ stable or next (dev) branch, and based on this, use the corresonding
1143+ stable or next branches for the other_services."""
1144+ base_charms = ['mysql', 'mongodb', 'rabbitmq-server']
1145+
1146+ if self.stable:
1147+ for svc in other_services:
1148+ temp = 'lp:charms/{}'
1149+ svc['location'] = temp.format(svc['name'])
1150+ else:
1151+ for svc in other_services:
1152+ if svc['name'] in base_charms:
1153+ temp = 'lp:charms/{}'
1154+ svc['location'] = temp.format(svc['name'])
1155+ else:
1156+ temp = 'lp:~openstack-charmers/charms/{}/{}/next'
1157+ svc['location'] = temp.format(self.current_next,
1158+ svc['name'])
1159+ return other_services
1160
1161 def _add_services(self, this_service, other_services):
1162- """Add services to the deployment and set openstack-origin."""
1163+ """Add services to the deployment and set openstack-origin/source."""
1164+ other_services = self._determine_branch_locations(other_services)
1165+
1166 super(OpenStackAmuletDeployment, self)._add_services(this_service,
1167 other_services)
1168- name = 0
1169+
1170 services = other_services
1171 services.append(this_service)
1172- use_source = ['mysql', 'mongodb', 'rabbitmq-server', 'ceph']
1173+ use_source = ['mysql', 'mongodb', 'rabbitmq-server', 'ceph',
1174+ 'ceph-osd', 'ceph-radosgw']
1175
1176 if self.openstack:
1177 for svc in services:
1178- if svc[name] not in use_source:
1179+ if svc['name'] not in use_source:
1180 config = {'openstack-origin': self.openstack}
1181- self.d.configure(svc[name], config)
1182+ self.d.configure(svc['name'], config)
1183
1184 if self.source:
1185 for svc in services:
1186- if svc[name] in use_source:
1187+ if svc['name'] in use_source:
1188 config = {'source': self.source}
1189- self.d.configure(svc[name], config)
1190+ self.d.configure(svc['name'], config)
1191
1192 def _configure_services(self, configs):
1193 """Configure all of the services."""
1194
1195=== modified file 'tests/charmhelpers/contrib/openstack/amulet/utils.py'
1196--- tests/charmhelpers/contrib/openstack/amulet/utils.py 2014-08-13 13:13:37 +0000
1197+++ tests/charmhelpers/contrib/openstack/amulet/utils.py 2014-09-30 13:42:56 +0000
1198@@ -187,15 +187,16 @@
1199
1200 f = opener.open("http://download.cirros-cloud.net/version/released")
1201 version = f.read().strip()
1202- cirros_img = "tests/cirros-{}-x86_64-disk.img".format(version)
1203+ cirros_img = "cirros-{}-x86_64-disk.img".format(version)
1204+ local_path = os.path.join('tests', cirros_img)
1205
1206- if not os.path.exists(cirros_img):
1207+ if not os.path.exists(local_path):
1208 cirros_url = "http://{}/{}/{}".format("download.cirros-cloud.net",
1209 version, cirros_img)
1210- opener.retrieve(cirros_url, cirros_img)
1211+ opener.retrieve(cirros_url, local_path)
1212 f.close()
1213
1214- with open(cirros_img) as f:
1215+ with open(local_path) as f:
1216 image = glance.images.create(name=image_name, is_public=True,
1217 disk_format='qcow2',
1218 container_format='bare', data=f)

Subscribers

People subscribed via source and target branches