| #!/usr/local/sbin/charm-env python3 |
| |
| # Copyright 2015 The Kubernetes Authors. |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); |
| # you may not use this file except in compliance with the License. |
| # You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| # See the License for the specific language governing permissions and |
| # limitations under the License. |
| |
| from charmhelpers.core.templating import render |
| from charms.reactive import is_state |
| from charmhelpers.core.hookenv import action_get |
| from charmhelpers.core.hookenv import action_set |
| from charmhelpers.core.hookenv import action_fail |
| from subprocess import check_call |
| from subprocess import check_output |
| from subprocess import CalledProcessError |
| from tempfile import TemporaryDirectory |
| import json |
| import re |
| import os |
| import sys |
| |
| |
| os.environ['PATH'] += os.pathsep + os.path.join(os.sep, 'snap', 'bin') |
| |
| |
| def main(): |
| ''' Control logic to enlist Ceph RBD volumes as PersistentVolumes in |
| Kubernetes. This will invoke the validation steps, and only execute if |
| this script thinks the environment is 'sane' enough to provision volumes. |
| ''' |
| |
| # k8s >= 1.10 uses CSI and doesn't directly create persistent volumes |
| if get_version('kube-apiserver') >= (1, 10): |
| print('This action is deprecated in favor of CSI creation of persistent volumes') |
| print('in Kubernetes >= 1.10. Just create the PVC and a PV will be created') |
| print('for you.') |
| action_fail('Deprecated, just create PVC.') |
| return |
| |
| # validate relationship pre-reqs before additional steps can be taken |
| if not validate_relation(): |
| print('Failed ceph relationship check') |
| action_fail('Failed ceph relationship check') |
| return |
| |
| if not is_ceph_healthy(): |
| print('Ceph was not healthy.') |
| action_fail('Ceph was not healthy.') |
| return |
| |
| context = {} |
| |
| context['RBD_NAME'] = action_get_or_default('name').strip() |
| context['RBD_SIZE'] = action_get_or_default('size') |
| context['RBD_FS'] = action_get_or_default('filesystem').strip() |
| context['PV_MODE'] = action_get_or_default('mode').strip() |
| |
| # Ensure we're not exceeding available space in the pool |
| if not validate_space(context['RBD_SIZE']): |
| return |
| |
| # Ensure our parameters match |
| param_validation = validate_parameters(context['RBD_NAME'], |
| context['RBD_FS'], |
| context['PV_MODE']) |
| if not param_validation == 0: |
| return |
| |
| if not validate_unique_volume_name(context['RBD_NAME']): |
| action_fail('Volume name collision detected. Volume creation aborted.') |
| return |
| |
| context['monitors'] = get_monitors() |
| |
| # Invoke creation and format the mount device |
| create_rbd_volume(context['RBD_NAME'], |
| context['RBD_SIZE'], |
| context['RBD_FS']) |
| |
| # Create a temporary workspace to render our persistentVolume template, and |
| # enlist the RDB based PV we've just created |
| with TemporaryDirectory() as active_working_path: |
| temp_template = '{}/pv.yaml'.format(active_working_path) |
| render('rbd-persistent-volume.yaml', temp_template, context) |
| |
| cmd = ['kubectl', 'create', '-f', temp_template] |
| debug_command(cmd) |
| check_call(cmd) |
| |
| |
| def get_version(bin_name): |
| """Get the version of an installed Kubernetes binary. |
| |
| :param str bin_name: Name of binary |
| :return: 3-tuple version (maj, min, patch) |
| |
| Example:: |
| |
| >>> `get_version('kubelet') |
| (1, 6, 0) |
| |
| """ |
| cmd = '{} --version'.format(bin_name).split() |
| version_string = check_output(cmd).decode('utf-8') |
| return tuple(int(q) for q in re.findall("[0-9]+", version_string)[:3]) |
| |
| |
| def action_get_or_default(key): |
| ''' Convenience method to manage defaults since actions dont appear to |
| properly support defaults ''' |
| |
| value = action_get(key) |
| if value: |
| return value |
| elif key == 'filesystem': |
| return 'xfs' |
| elif key == 'size': |
| return 0 |
| elif key == 'mode': |
| return "ReadWriteOnce" |
| elif key == 'skip-size-check': |
| return False |
| else: |
| return '' |
| |
| |
| def create_rbd_volume(name, size, filesystem): |
| ''' Create the RBD volume in Ceph. Then mount it locally to format it for |
| the requested filesystem. |
| |
| :param name - The name of the RBD volume |
| :param size - The size in MB of the volume |
| :param filesystem - The type of filesystem to format the block device |
| ''' |
| |
| # Create the rbd volume |
| # $ rbd create foo --size 50 --image-feature layering |
| command = ['rbd', 'create', '--size', '{}'.format(size), '--image-feature', |
| 'layering', name] |
| debug_command(command) |
| check_call(command) |
| |
| # Lift the validation sequence to determine if we actually created the |
| # rbd volume |
| if validate_unique_volume_name(name): |
| # we failed to create the RBD volume. whoops |
| action_fail('RBD Volume not listed after creation.') |
| print('Ceph RBD volume {} not found in rbd list'.format(name)) |
| # hack, needs love if we're killing the process thread this deep in |
| # the call stack. |
| sys.exit(0) |
| |
| mount = ['rbd', 'map', name] |
| debug_command(mount) |
| device_path = check_output(mount).strip() |
| |
| try: |
| format_command = ['mkfs.{}'.format(filesystem), device_path] |
| debug_command(format_command) |
| check_call(format_command) |
| unmount = ['rbd', 'unmap', name] |
| debug_command(unmount) |
| check_call(unmount) |
| except CalledProcessError: |
| print('Failed to format filesystem and unmount. RBD created but not' |
| ' enlisted.') |
| action_fail('Failed to format filesystem and unmount.' |
| ' RDB created but not enlisted.') |
| |
| |
| def is_ceph_healthy(): |
| ''' Probe the remote ceph cluster for health status ''' |
| command = ['ceph', 'health'] |
| debug_command(command) |
| health_output = check_output(command) |
| if b'HEALTH_OK' in health_output: |
| return True |
| else: |
| return False |
| |
| |
| def get_monitors(): |
| ''' Parse the monitors out of /etc/ceph/ceph.conf ''' |
| found_hosts = [] |
| # This is kind of hacky. We should be piping this in from juju relations |
| with open('/etc/ceph/ceph.conf', 'r') as ceph_conf: |
| for line in ceph_conf.readlines(): |
| if 'mon host' in line: |
| # strip out the key definition |
| hosts = line.lstrip('mon host = ').split(' ') |
| for host in hosts: |
| found_hosts.append(host) |
| return found_hosts |
| |
| |
| def get_available_space(): |
| ''' Determine the space available in the RBD pool. Throw an exception if |
| the RBD pool ('rbd') isn't found. ''' |
| command = 'ceph df -f json'.split() |
| debug_command(command) |
| out = check_output(command).decode('utf-8') |
| data = json.loads(out) |
| for pool in data['pools']: |
| if pool['name'] == 'rbd': |
| return int(pool['stats']['max_avail'] / (1024 * 1024)) |
| raise UnknownAvailableSpaceException('Unable to determine available space.') # noqa |
| |
| |
| def validate_unique_volume_name(name): |
| ''' Poll the CEPH-MON services to determine if we have a unique rbd volume |
| name to use. If there is naming collisions, block the request for volume |
| provisioning. |
| |
| :param name - The name of the RBD volume |
| ''' |
| |
| command = ['rbd', 'list'] |
| debug_command(command) |
| raw_out = check_output(command) |
| |
| # Split the output on newlines |
| # output spec: |
| # $ rbd list |
| # foo |
| # foobar |
| volume_list = raw_out.decode('utf-8').splitlines() |
| |
| for volume in volume_list: |
| if volume.strip() == name: |
| return False |
| |
| return True |
| |
| |
| def validate_relation(): |
| ''' Determine if we are related to ceph. If we are not, we should |
| note this in the action output and fail this action run. We are relying |
| on specific files in specific paths to be placed in order for this function |
| to work. This method verifies those files are placed. ''' |
| |
| # TODO: Validate that the ceph-common package is installed |
| if not is_state('ceph-storage.available'): |
| message = 'Failed to detect connected ceph-mon' |
| print(message) |
| action_set({'pre-req.ceph-relation': message}) |
| return False |
| |
| if not os.path.isfile('/etc/ceph/ceph.conf'): |
| message = 'No Ceph configuration found in /etc/ceph/ceph.conf' |
| print(message) |
| action_set({'pre-req.ceph-configuration': message}) |
| return False |
| |
| # TODO: Validate ceph key |
| |
| return True |
| |
| |
| def validate_space(size): |
| if action_get_or_default('skip-size-check'): |
| return True |
| available_space = get_available_space() |
| if available_space < size: |
| msg = 'Unable to allocate RBD of size {}MB, only {}MB are available.' |
| action_fail(msg.format(size, available_space)) |
| return False |
| return True |
| |
| |
| def validate_parameters(name, fs, mode): |
| ''' Validate the user inputs to ensure they conform to what the |
| action expects. This method will check the naming characters used |
| for the rbd volume, ensure they have selected a fstype we are expecting |
| and the mode against our whitelist ''' |
| name_regex = '^[a-zA-z0-9][a-zA-Z0-9|-]' |
| |
| fs_whitelist = ['xfs', 'ext4'] |
| |
| # see http://kubernetes.io/docs/user-guide/persistent-volumes/#access-modes |
| # for supported operations on RBD volumes. |
| mode_whitelist = ['ReadWriteOnce', 'ReadOnlyMany'] |
| |
| fails = 0 |
| |
| if not re.match(name_regex, name): |
| message = 'Validation failed for RBD volume-name' |
| action_fail(message) |
| fails = fails + 1 |
| action_set({'validation.name': message}) |
| |
| if fs not in fs_whitelist: |
| message = 'Validation failed for file system' |
| action_fail(message) |
| fails = fails + 1 |
| action_set({'validation.filesystem': message}) |
| |
| if mode not in mode_whitelist: |
| message = "Validation failed for mode" |
| action_fail(message) |
| fails = fails + 1 |
| action_set({'validation.mode': message}) |
| |
| return fails |
| |
| |
| def debug_command(cmd): |
| ''' Print a debug statement of the command invoked ''' |
| print("Invoking {}".format(cmd)) |
| |
| |
| class UnknownAvailableSpaceException(Exception): |
| pass |
| |
| |
| if __name__ == '__main__': |
| main() |