Browse Source

No reformat

Do not reformat devices.  A subsequent change will be necessary
to account for conditions where a reformat is still desired,
such as a set of blocking states and user-driven actions.

Partial-bug: #1698154

Depends-On: I90a866aa138d18e4242783c42d4c7c587f696d7d
Change-Id: I3a41ab38e7a1679cf4f5380a7cc56556da3aaf2b
changes/52/571452/5
Ryan Beisner 1 year ago
parent
commit
22ce311b0b
No account linked to committer's email address

+ 0
- 1
actions/add_disk.py View File

@@ -31,7 +31,6 @@ import ceph.utils
31 31
 def add_device(request, device_path, bucket=None):
32 32
     ceph.utils.osdize(dev, hookenv.config('osd-format'),
33 33
                       ceph_hooks.get_journal_devices(),
34
-                      hookenv.config('osd-reformat'),
35 34
                       hookenv.config('ignore-device-errors'),
36 35
                       hookenv.config('osd-encrypt'),
37 36
                       hookenv.config('bluestore'))

+ 0
- 11
config.yaml View File

@@ -127,17 +127,6 @@ options:
127 127
       .
128 128
       Note that despite bluestore being the default for Ceph Luminous, if this
129 129
       option is False, OSDs will still use filestore.
130
-  osd-reformat:
131
-    type: boolean
132
-    default: False
133
-    description: |
134
-      By default, the charm will not re-format a device that already looks
135
-      as if it might be an OSD device. This is a safeguard to try to
136
-      prevent data loss.
137
-      .
138
-      Enabling this option forces a reformat of any OSD devices found which
139
-      have not been processed by the unit previously or are not already
140
-      mounted.
141 130
   osd-encrypt:
142 131
     type: boolean
143 132
     default: False

+ 1
- 8
hooks/ceph_hooks.py View File

@@ -451,7 +451,7 @@ def prepare_disks_and_activate():
451 451
         emit_cephconf()
452 452
         for dev in get_devices():
453 453
             ceph.osdize(dev, config('osd-format'),
454
-                        osd_journal, config('osd-reformat'),
454
+                        osd_journal,
455 455
                         config('ignore-device-errors'),
456 456
                         config('osd-encrypt'),
457 457
                         config('bluestore'),
@@ -499,13 +499,6 @@ def get_conf(name):
499 499
     return None
500 500
 
501 501
 
502
-def reformat_osd():
503
-    if config('osd-reformat'):
504
-        return True
505
-    else:
506
-        return False
507
-
508
-
509 502
 def get_devices():
510 503
     devices = []
511 504
     if config('osd-devices'):

+ 5
- 0
hooks/charmhelpers/contrib/hahelpers/cluster.py View File

@@ -223,6 +223,11 @@ def https():
223 223
         return True
224 224
     if config_get('ssl_cert') and config_get('ssl_key'):
225 225
         return True
226
+    for r_id in relation_ids('certificates'):
227
+        for unit in relation_list(r_id):
228
+            ca = relation_get('ca', rid=r_id, unit=unit)
229
+            if ca:
230
+                return True
226 231
     for r_id in relation_ids('identity-service'):
227 232
         for unit in relation_list(r_id):
228 233
             # TODO - needs fixing for new helper as ssl_cert/key suffixes with CN

+ 5
- 5
hooks/charmhelpers/contrib/openstack/amulet/utils.py View File

@@ -544,7 +544,7 @@ class OpenStackAmuletUtils(AmuletUtils):
544 544
         return ep
545 545
 
546 546
     def get_default_keystone_session(self, keystone_sentry,
547
-                                     openstack_release=None):
547
+                                     openstack_release=None, api_version=2):
548 548
         """Return a keystone session object and client object assuming standard
549 549
            default settings
550 550
 
@@ -559,12 +559,12 @@ class OpenStackAmuletUtils(AmuletUtils):
559 559
                eyc
560 560
         """
561 561
         self.log.debug('Authenticating keystone admin...')
562
-        api_version = 2
563
-        client_class = keystone_client.Client
564 562
         # 11 => xenial_queens
565
-        if openstack_release and openstack_release >= 11:
566
-            api_version = 3
563
+        if api_version == 3 or (openstack_release and openstack_release >= 11):
567 564
             client_class = keystone_client_v3.Client
565
+            api_version = 3
566
+        else:
567
+            client_class = keystone_client.Client
568 568
         keystone_ip = keystone_sentry.info['public-address']
569 569
         session, auth = self.get_keystone_session(
570 570
             keystone_ip,

+ 227
- 0
hooks/charmhelpers/contrib/openstack/cert_utils.py View File

@@ -0,0 +1,227 @@
1
+# Copyright 2014-2018 Canonical Limited.
2
+#
3
+# Licensed under the Apache License, Version 2.0 (the "License");
4
+# you may not use this file except in compliance with the License.
5
+# You may obtain a copy of the License at
6
+#
7
+#  http://www.apache.org/licenses/LICENSE-2.0
8
+#
9
+# Unless required by applicable law or agreed to in writing, software
10
+# distributed under the License is distributed on an "AS IS" BASIS,
11
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+# See the License for the specific language governing permissions and
13
+# limitations under the License.
14
+
15
+# Common python helper functions used for OpenStack charm certificats.
16
+
17
+import os
18
+import json
19
+
20
+from charmhelpers.contrib.network.ip import (
21
+    get_hostname,
22
+    resolve_network_cidr,
23
+)
24
+from charmhelpers.core.hookenv import (
25
+    local_unit,
26
+    network_get_primary_address,
27
+    config,
28
+    relation_get,
29
+    unit_get,
30
+    NoNetworkBinding,
31
+    log,
32
+    WARNING,
33
+)
34
+from charmhelpers.contrib.openstack.ip import (
35
+    ADMIN,
36
+    resolve_address,
37
+    get_vip_in_network,
38
+    INTERNAL,
39
+    PUBLIC,
40
+    ADDRESS_MAP)
41
+
42
+from charmhelpers.core.host import (
43
+    mkdir,
44
+    write_file,
45
+)
46
+
47
+from charmhelpers.contrib.hahelpers.apache import (
48
+    install_ca_cert
49
+)
50
+
51
+
52
+class CertRequest(object):
53
+
54
+    """Create a request for certificates to be generated
55
+    """
56
+
57
+    def __init__(self, json_encode=True):
58
+        self.entries = []
59
+        self.hostname_entry = None
60
+        self.json_encode = json_encode
61
+
62
+    def add_entry(self, net_type, cn, addresses):
63
+        """Add a request to the batch
64
+
65
+        :param net_type: str netwrok space name request is for
66
+        :param cn: str Canonical Name for certificate
67
+        :param addresses: [] List of addresses to be used as SANs
68
+        """
69
+        self.entries.append({
70
+            'cn': cn,
71
+            'addresses': addresses})
72
+
73
+    def add_hostname_cn(self):
74
+        """Add a request for the hostname of the machine"""
75
+        ip = unit_get('private-address')
76
+        addresses = [ip]
77
+        # If a vip is being used without os-hostname config or
78
+        # network spaces then we need to ensure the local units
79
+        # cert has the approriate vip in the SAN list
80
+        vip = get_vip_in_network(resolve_network_cidr(ip))
81
+        if vip:
82
+            addresses.append(vip)
83
+        self.hostname_entry = {
84
+            'cn': get_hostname(ip),
85
+            'addresses': addresses}
86
+
87
+    def add_hostname_cn_ip(self, addresses):
88
+        """Add an address to the SAN list for the hostname request
89
+
90
+        :param addr: [] List of address to be added
91
+        """
92
+        for addr in addresses:
93
+            if addr not in self.hostname_entry['addresses']:
94
+                self.hostname_entry['addresses'].append(addr)
95
+
96
+    def get_request(self):
97
+        """Generate request from the batched up entries
98
+
99
+        """
100
+        if self.hostname_entry:
101
+            self.entries.append(self.hostname_entry)
102
+        request = {}
103
+        for entry in self.entries:
104
+            sans = sorted(list(set(entry['addresses'])))
105
+            request[entry['cn']] = {'sans': sans}
106
+        if self.json_encode:
107
+            return {'cert_requests': json.dumps(request, sort_keys=True)}
108
+        else:
109
+            return {'cert_requests': request}
110
+
111
+
112
+def get_certificate_request(json_encode=True):
113
+    """Generate a certificatee requests based on the network confioguration
114
+
115
+    """
116
+    req = CertRequest(json_encode=json_encode)
117
+    req.add_hostname_cn()
118
+    # Add os-hostname entries
119
+    for net_type in [INTERNAL, ADMIN, PUBLIC]:
120
+        net_config = config(ADDRESS_MAP[net_type]['override'])
121
+        try:
122
+            net_addr = resolve_address(endpoint_type=net_type)
123
+            ip = network_get_primary_address(
124
+                ADDRESS_MAP[net_type]['binding'])
125
+            addresses = [net_addr, ip]
126
+            vip = get_vip_in_network(resolve_network_cidr(ip))
127
+            if vip:
128
+                addresses.append(vip)
129
+            if net_config:
130
+                req.add_entry(
131
+                    net_type,
132
+                    net_config,
133
+                    addresses)
134
+            else:
135
+                # There is network address with no corresponding hostname.
136
+                # Add the ip to the hostname cert to allow for this.
137
+                req.add_hostname_cn_ip(addresses)
138
+        except NoNetworkBinding:
139
+            log("Skipping request for certificate for ip in {} space, no "
140
+                "local address found".format(net_type), WARNING)
141
+    return req.get_request()
142
+
143
+
144
+def create_ip_cert_links(ssl_dir, custom_hostname_link=None):
145
+    """Create symlinks for SAN records
146
+
147
+    :param ssl_dir: str Directory to create symlinks in
148
+    :param custom_hostname_link: str Additional link to be created
149
+    """
150
+    hostname = get_hostname(unit_get('private-address'))
151
+    hostname_cert = os.path.join(
152
+        ssl_dir,
153
+        'cert_{}'.format(hostname))
154
+    hostname_key = os.path.join(
155
+        ssl_dir,
156
+        'key_{}'.format(hostname))
157
+    # Add links to hostname cert, used if os-hostname vars not set
158
+    for net_type in [INTERNAL, ADMIN, PUBLIC]:
159
+        try:
160
+            addr = resolve_address(endpoint_type=net_type)
161
+            cert = os.path.join(ssl_dir, 'cert_{}'.format(addr))
162
+            key = os.path.join(ssl_dir, 'key_{}'.format(addr))
163
+            if os.path.isfile(hostname_cert) and not os.path.isfile(cert):
164
+                os.symlink(hostname_cert, cert)
165
+                os.symlink(hostname_key, key)
166
+        except NoNetworkBinding:
167
+            log("Skipping creating cert symlink for ip in {} space, no "
168
+                "local address found".format(net_type), WARNING)
169
+    if custom_hostname_link:
170
+        custom_cert = os.path.join(
171
+            ssl_dir,
172
+            'cert_{}'.format(custom_hostname_link))
173
+        custom_key = os.path.join(
174
+            ssl_dir,
175
+            'key_{}'.format(custom_hostname_link))
176
+        if os.path.isfile(hostname_cert) and not os.path.isfile(custom_cert):
177
+            os.symlink(hostname_cert, custom_cert)
178
+            os.symlink(hostname_key, custom_key)
179
+
180
+
181
+def install_certs(ssl_dir, certs, chain=None):
182
+    """Install the certs passed into the ssl dir and append the chain if
183
+       provided.
184
+
185
+    :param ssl_dir: str Directory to create symlinks in
186
+    :param certs: {} {'cn': {'cert': 'CERT', 'key': 'KEY'}}
187
+    :param chain: str Chain to be appended to certs
188
+    """
189
+    for cn, bundle in certs.items():
190
+        cert_filename = 'cert_{}'.format(cn)
191
+        key_filename = 'key_{}'.format(cn)
192
+        cert_data = bundle['cert']
193
+        if chain:
194
+            # Append chain file so that clients that trust the root CA will
195
+            # trust certs signed by an intermediate in the chain
196
+            cert_data = cert_data + chain
197
+        write_file(
198
+            path=os.path.join(ssl_dir, cert_filename),
199
+            content=cert_data, perms=0o640)
200
+        write_file(
201
+            path=os.path.join(ssl_dir, key_filename),
202
+            content=bundle['key'], perms=0o640)
203
+
204
+
205
+def process_certificates(service_name, relation_id, unit,
206
+                         custom_hostname_link=None):
207
+    """Process the certificates supplied down the relation
208
+
209
+    :param service_name: str Name of service the certifcates are for.
210
+    :param relation_id: str Relation id providing the certs
211
+    :param unit: str Unit providing the certs
212
+    :param custom_hostname_link: str Name of custom link to create
213
+    """
214
+    data = relation_get(rid=relation_id, unit=unit)
215
+    ssl_dir = os.path.join('/etc/apache2/ssl/', service_name)
216
+    mkdir(path=ssl_dir)
217
+    name = local_unit().replace('/', '_')
218
+    certs = data.get('{}.processed_requests'.format(name))
219
+    chain = data.get('chain')
220
+    ca = data.get('ca')
221
+    if certs:
222
+        certs = json.loads(certs)
223
+        install_ca_cert(ca.encode())
224
+        install_certs(ssl_dir, certs, chain)
225
+        create_ip_cert_links(
226
+            ssl_dir,
227
+            custom_hostname_link=custom_hostname_link)

+ 30
- 21
hooks/charmhelpers/contrib/openstack/context.py View File

@@ -789,17 +789,18 @@ class ApacheSSLContext(OSContextGenerator):
789 789
         ssl_dir = os.path.join('/etc/apache2/ssl/', self.service_namespace)
790 790
         mkdir(path=ssl_dir)
791 791
         cert, key = get_cert(cn)
792
-        if cn:
793
-            cert_filename = 'cert_{}'.format(cn)
794
-            key_filename = 'key_{}'.format(cn)
795
-        else:
796
-            cert_filename = 'cert'
797
-            key_filename = 'key'
792
+        if cert and key:
793
+            if cn:
794
+                cert_filename = 'cert_{}'.format(cn)
795
+                key_filename = 'key_{}'.format(cn)
796
+            else:
797
+                cert_filename = 'cert'
798
+                key_filename = 'key'
798 799
 
799
-        write_file(path=os.path.join(ssl_dir, cert_filename),
800
-                   content=b64decode(cert), perms=0o640)
801
-        write_file(path=os.path.join(ssl_dir, key_filename),
802
-                   content=b64decode(key), perms=0o640)
800
+            write_file(path=os.path.join(ssl_dir, cert_filename),
801
+                       content=b64decode(cert), perms=0o640)
802
+            write_file(path=os.path.join(ssl_dir, key_filename),
803
+                       content=b64decode(key), perms=0o640)
803 804
 
804 805
     def configure_ca(self):
805 806
         ca_cert = get_ca_cert()
@@ -871,23 +872,31 @@ class ApacheSSLContext(OSContextGenerator):
871 872
         if not self.external_ports or not https():
872 873
             return {}
873 874
 
874
-        self.configure_ca()
875
+        use_keystone_ca = True
876
+        for rid in relation_ids('certificates'):
877
+            if related_units(rid):
878
+                use_keystone_ca = False
879
+
880
+        if use_keystone_ca:
881
+            self.configure_ca()
882
+
875 883
         self.enable_modules()
876 884
 
877 885
         ctxt = {'namespace': self.service_namespace,
878 886
                 'endpoints': [],
879 887
                 'ext_ports': []}
880 888
 
881
-        cns = self.canonical_names()
882
-        if cns:
883
-            for cn in cns:
884
-                self.configure_cert(cn)
885
-        else:
886
-            # Expect cert/key provided in config (currently assumed that ca
887
-            # uses ip for cn)
888
-            for net_type in (INTERNAL, ADMIN, PUBLIC):
889
-                cn = resolve_address(endpoint_type=net_type)
890
-                self.configure_cert(cn)
889
+        if use_keystone_ca:
890
+            cns = self.canonical_names()
891
+            if cns:
892
+                for cn in cns:
893
+                    self.configure_cert(cn)
894
+            else:
895
+                # Expect cert/key provided in config (currently assumed that ca
896
+                # uses ip for cn)
897
+                for net_type in (INTERNAL, ADMIN, PUBLIC):
898
+                    cn = resolve_address(endpoint_type=net_type)
899
+                    self.configure_cert(cn)
891 900
 
892 901
         addresses = self.get_network_addresses()
893 902
         for address, endpoint in addresses:

+ 10
- 0
hooks/charmhelpers/contrib/openstack/ip.py View File

@@ -184,3 +184,13 @@ def resolve_address(endpoint_type=PUBLIC, override=True):
184 184
                          "clustered=%s)" % (net_type, clustered))
185 185
 
186 186
     return resolved_address
187
+
188
+
189
+def get_vip_in_network(network):
190
+    matching_vip = None
191
+    vips = config('vip')
192
+    if vips:
193
+        for vip in vips.split():
194
+            if is_address_in_network(network, vip):
195
+                matching_vip = vip
196
+    return matching_vip

+ 7
- 0
hooks/charmhelpers/core/hookenv.py View File

@@ -972,6 +972,13 @@ def application_version_set(version):
972 972
         log("Application Version: {}".format(version))
973 973
 
974 974
 
975
+@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
976
+def goal_state():
977
+    """Juju goal state values"""
978
+    cmd = ['goal-state', '--format=json']
979
+    return json.loads(subprocess.check_output(cmd).decode('UTF-8'))
980
+
981
+
975 982
 @translate_exc(from_exc=OSError, to_exc=NotImplementedError)
976 983
 def is_leader():
977 984
     """Does the current unit hold the juju leadership

+ 6
- 13
lib/ceph/utils.py View File

@@ -1366,20 +1366,18 @@ def get_devices(name):
1366 1366
     return set(devices)
1367 1367
 
1368 1368
 
1369
-def osdize(dev, osd_format, osd_journal, reformat_osd=False,
1370
-           ignore_errors=False, encrypt=False, bluestore=False,
1371
-           key_manager=CEPH_KEY_MANAGER):
1369
+def osdize(dev, osd_format, osd_journal, ignore_errors=False, encrypt=False,
1370
+           bluestore=False, key_manager=CEPH_KEY_MANAGER):
1372 1371
     if dev.startswith('/dev'):
1373 1372
         osdize_dev(dev, osd_format, osd_journal,
1374
-                   reformat_osd, ignore_errors, encrypt,
1373
+                   ignore_errors, encrypt,
1375 1374
                    bluestore, key_manager)
1376 1375
     else:
1377 1376
         osdize_dir(dev, encrypt, bluestore)
1378 1377
 
1379 1378
 
1380
-def osdize_dev(dev, osd_format, osd_journal, reformat_osd=False,
1381
-               ignore_errors=False, encrypt=False, bluestore=False,
1382
-               key_manager=CEPH_KEY_MANAGER):
1379
+def osdize_dev(dev, osd_format, osd_journal, ignore_errors=False,
1380
+               encrypt=False, bluestore=False, key_manager=CEPH_KEY_MANAGER):
1383 1381
     """
1384 1382
     Prepare a block device for use as a Ceph OSD
1385 1383
 
@@ -1389,8 +1387,6 @@ def osdize_dev(dev, osd_format, osd_journal, reformat_osd=False,
1389 1387
     :param: dev: Full path to block device to use
1390 1388
     :param: osd_format: Format for OSD filesystem
1391 1389
     :param: osd_journal: List of block devices to use for OSD journals
1392
-    :param: reformat_osd: Reformat devices that are not currently in use
1393
-                          which have been used previously
1394 1390
     :param: ignore_errors: Don't fail in the event of any errors during
1395 1391
                            processing
1396 1392
     :param: encrypt: Encrypt block devices using 'key_manager'
@@ -1418,7 +1414,7 @@ def osdize_dev(dev, osd_format, osd_journal, reformat_osd=False,
1418 1414
         log('Path {} is not a block device - bailing'.format(dev))
1419 1415
         return
1420 1416
 
1421
-    if is_osd_disk(dev) and not reformat_osd:
1417
+    if is_osd_disk(dev):
1422 1418
         log('Looks like {} is already an'
1423 1419
             ' OSD data or journal, skipping.'.format(dev))
1424 1420
         return
@@ -1432,9 +1428,6 @@ def osdize_dev(dev, osd_format, osd_journal, reformat_osd=False,
1432 1428
             ' skipping.'.format(dev))
1433 1429
         return
1434 1430
 
1435
-    if reformat_osd:
1436
-        zap_disk(dev)
1437
-
1438 1431
     if cmp_pkgrevno('ceph', '12.2.4') >= 0:
1439 1432
         cmd = _ceph_volume(dev,
1440 1433
                            osd_journal,

+ 5
- 4
tests/basic_deployment.py View File

@@ -63,7 +63,10 @@ class CephOsdBasicDeployment(OpenStackAmuletDeployment):
63 63
            and the rest of the service are from lp branches that are
64 64
            compatible with the local charm (e.g. stable or next).
65 65
            """
66
-        this_service = {'name': 'ceph-osd', 'units': 3}
66
+        this_service = {
67
+            'name': 'ceph-osd',
68
+            'units': 3,
69
+            'storage': {'osd-devices': 'cinder,10G'}}
67 70
         other_services = [
68 71
             {'name': 'ceph-mon', 'units': 3},
69 72
             {'name': 'percona-cluster'},
@@ -118,9 +121,7 @@ class CephOsdBasicDeployment(OpenStackAmuletDeployment):
118 121
         # Include a non-existent device as osd-devices is a whitelist,
119 122
         # and this will catch cases where proposals attempt to change that.
120 123
         ceph_osd_config = {
121
-            'osd-reformat': True,
122
-            'ephemeral-unmount': '/mnt',
123
-            'osd-devices': '/dev/vdb /srv/ceph /dev/test-non-existent'
124
+            'osd-devices': '/srv/ceph /dev/test-non-existent'
124 125
         }
125 126
 
126 127
         configs = {'keystone': keystone_config,

+ 4
- 2
tests/charmhelpers/contrib/amulet/deployment.py View File

@@ -50,7 +50,8 @@ class AmuletDeployment(object):
50 50
             this_service['units'] = 1
51 51
 
52 52
         self.d.add(this_service['name'], units=this_service['units'],
53
-                   constraints=this_service.get('constraints'))
53
+                   constraints=this_service.get('constraints'),
54
+                   storage=this_service.get('storage'))
54 55
 
55 56
         for svc in other_services:
56 57
             if 'location' in svc:
@@ -64,7 +65,8 @@ class AmuletDeployment(object):
64 65
                 svc['units'] = 1
65 66
 
66 67
             self.d.add(svc['name'], charm=branch_location, units=svc['units'],
67
-                       constraints=svc.get('constraints'))
68
+                       constraints=svc.get('constraints'),
69
+                       storage=svc.get('storage'))
68 70
 
69 71
     def _add_relations(self, relations):
70 72
         """Add all of the relations for the services."""

+ 5
- 5
tests/charmhelpers/contrib/openstack/amulet/utils.py View File

@@ -544,7 +544,7 @@ class OpenStackAmuletUtils(AmuletUtils):
544 544
         return ep
545 545
 
546 546
     def get_default_keystone_session(self, keystone_sentry,
547
-                                     openstack_release=None):
547
+                                     openstack_release=None, api_version=2):
548 548
         """Return a keystone session object and client object assuming standard
549 549
            default settings
550 550
 
@@ -559,12 +559,12 @@ class OpenStackAmuletUtils(AmuletUtils):
559 559
                eyc
560 560
         """
561 561
         self.log.debug('Authenticating keystone admin...')
562
-        api_version = 2
563
-        client_class = keystone_client.Client
564 562
         # 11 => xenial_queens
565
-        if openstack_release and openstack_release >= 11:
566
-            api_version = 3
563
+        if api_version == 3 or (openstack_release and openstack_release >= 11):
567 564
             client_class = keystone_client_v3.Client
565
+            api_version = 3
566
+        else:
567
+            client_class = keystone_client.Client
568 568
         keystone_ip = keystone_sentry.info['public-address']
569 569
         session, auth = self.get_keystone_session(
570 570
             keystone_ip,

+ 7
- 0
tests/charmhelpers/core/hookenv.py View File

@@ -972,6 +972,13 @@ def application_version_set(version):
972 972
         log("Application Version: {}".format(version))
973 973
 
974 974
 
975
+@translate_exc(from_exc=OSError, to_exc=NotImplementedError)
976
+def goal_state():
977
+    """Juju goal state values"""
978
+    cmd = ['goal-state', '--format=json']
979
+    return json.loads(subprocess.check_output(cmd).decode('UTF-8'))
980
+
981
+
975 982
 @translate_exc(from_exc=OSError, to_exc=NotImplementedError)
976 983
 def is_leader():
977 984
     """Does the current unit hold the juju leadership

Loading…
Cancel
Save