blob: b6b229d63a68443d7415e311c788e399ac735a73 [file] [log] [blame]
#!/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()