Vasyl Saienko | 59096f2 | 2018-11-26 17:26:42 +0200 | [diff] [blame^] | 1 | #!/usr/bin/env python |
| 2 | # |
| 3 | # Copyright 2018 Mirantis, Inc. |
| 4 | # |
| 5 | # Licensed under the Apache License, Version 2.0 (the "License"); you may |
| 6 | # not use this file except in compliance with the License. You may obtain |
| 7 | # a copy of the License at |
| 8 | # |
| 9 | # http://www.apache.org/licenses/LICENSE-2.0 |
| 10 | # |
| 11 | # Unless required by applicable law or agreed to in writing, software |
| 12 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT |
| 13 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the |
| 14 | # License for the specific language governing permissions and limitations |
| 15 | # under the License. |
| 16 | |
| 17 | |
| 18 | """Growpart python module |
| 19 | |
| 20 | The module is aimed to extend logical volumes according to sizes provided |
| 21 | in image layout. |
| 22 | |
| 23 | Example: |
| 24 | python growlvm --image-layout-file '/root/mylayout.yml' |
| 25 | |
| 26 | Attributes: |
| 27 | image-layout - json string with image layout. Supported params and |
| 28 | description might be found in IMAGE_LAYOUT_SCHEMA |
| 29 | |
| 30 | """ |
| 31 | |
| 32 | __version__ = '1.0' |
| 33 | |
| 34 | import argparse |
| 35 | import collections |
| 36 | import yaml |
| 37 | from jsonschema import validate |
| 38 | import logging |
| 39 | import os |
| 40 | import re |
| 41 | import subprocess |
| 42 | import sys |
| 43 | |
| 44 | |
| 45 | LOG = logging.getLogger(__name__) |
| 46 | |
| 47 | DECIMAL_REG = re.compile(r"(\d+)") |
| 48 | |
| 49 | IMAGE_LAYOUT_SCHEMA = { |
| 50 | "$schema": "http://json-schema.org/draft-04/schema#", |
| 51 | "title": "Image partition layout", |
| 52 | "type": "object", |
| 53 | "patternProperties": { |
| 54 | ".*": {"$ref": "#/definitions/logical_volume_layout"} |
| 55 | }, |
| 56 | "definitions": { |
| 57 | "logical_volume_layout": { |
| 58 | "type": "object", |
| 59 | "properties": { |
| 60 | "name": { |
| 61 | "description": "Logical Volume Name", |
| 62 | "type": "string" |
| 63 | }, |
| 64 | "size": { |
| 65 | "description": ( |
| 66 | "Size of Logical volume in units of logical extents. " |
| 67 | "The number might be volume size in units of " |
| 68 | "megabytes. A size suffix of M for megabytes, G for " |
| 69 | "gigabytes, T for terabytes, P for petabytes or E for " |
| 70 | "exabytes is optional. The number can also be " |
| 71 | "expressed as a percentage of the total space in the " |
| 72 | "Volume Group with the suffix %VG. Percentage of the " |
| 73 | "changeble values like free space is not supported." |
| 74 | ), |
| 75 | }, |
| 76 | "resizefs": { |
| 77 | "description": ( |
| 78 | "Resize underlying filesystem together with the " |
| 79 | "logical volume using fsadm(8)." |
| 80 | ), |
| 81 | "type": "boolean" |
| 82 | }, |
| 83 | "vg": { |
| 84 | "description": ("Volume group name to resize logical " |
| 85 | "volume on."), |
| 86 | "type": "string" |
| 87 | } |
| 88 | }, |
| 89 | "additionalProperties": False, |
| 90 | "required": ["size"] |
| 91 | } |
| 92 | }, |
| 93 | } |
| 94 | |
| 95 | |
| 96 | def get_volume_groups_info(unit, vg): |
| 97 | cmd = ("vgs --noheadings -o vg_name,size,free,vg_extent_size --units %s " |
| 98 | "--separator ';' %s") % (unit, vg) |
| 99 | try: |
| 100 | output = subprocess.check_output(cmd, shell=True, |
| 101 | stderr=subprocess.STDOUT) |
| 102 | except subprocess.CalledProcessError as exc: |
| 103 | raise Exception("Failed to get volume group info", exc.output) |
| 104 | |
| 105 | vgs = [] |
| 106 | for line in output.splitlines(): |
| 107 | parts = line.strip().split(';') |
| 108 | vgs.append({ |
| 109 | 'name': parts[0], |
| 110 | 'size': int(DECIMAL_REG.match(parts[1]).group(1)), |
| 111 | 'free': int(DECIMAL_REG.match(parts[2]).group(1)), |
| 112 | 'ext_size': int(DECIMAL_REG.match(parts[3]).group(1)) |
| 113 | }) |
| 114 | return vgs |
| 115 | |
| 116 | |
| 117 | def get_logical_volume_info(unit, vg): |
| 118 | cmd = ("lvs -a --noheadings --nosuffix -o lv_name,size,lv_attr --units %s " |
| 119 | "--separator ';' %s") % (unit, vg) |
| 120 | try: |
| 121 | output = subprocess.check_output(cmd, shell=True, |
| 122 | stderr=subprocess.STDOUT) |
| 123 | except subprocess.CalledProcessError as exc: |
| 124 | raise Exception("Failed to get volume info", exc.output) |
| 125 | |
| 126 | lvs = [] |
| 127 | |
| 128 | for line in output.splitlines(): |
| 129 | parts = line.strip().split(';') |
| 130 | lvs.append({ |
| 131 | 'name': parts[0].replace('[', '').replace(']', ''), |
| 132 | 'size': int(DECIMAL_REG.match(parts[1]).group(1)), |
| 133 | }) |
| 134 | return lvs |
| 135 | |
| 136 | |
| 137 | def normalize_to_pe(size, pe): |
| 138 | """ Make sure requested size is multiply of PE |
| 139 | |
| 140 | PE is gathered from system with honor of provided units, |
| 141 | when volume size is set in Gigabytes, PE (4mb default) will |
| 142 | be shown as 0. |
| 143 | """ |
| 144 | |
| 145 | if pe > 0: |
| 146 | return (size // pe + 1) * pe |
| 147 | |
| 148 | return size |
| 149 | |
| 150 | |
| 151 | def main(): |
| 152 | logging.basicConfig( |
| 153 | format='%(asctime)s - %(levelname)s - %(message)s' |
| 154 | ) |
| 155 | LOG.setLevel(logging.INFO) |
| 156 | |
| 157 | parser = argparse.ArgumentParser( |
| 158 | description=('Grow lvm partitions and theirs filesystems to ' |
| 159 | 'specified sizes.') |
| 160 | ) |
| 161 | |
| 162 | group = parser.add_mutually_exclusive_group(required=True) |
| 163 | group.add_argument( |
| 164 | '--image-layout', |
| 165 | help='json based image layout', |
| 166 | ) |
| 167 | group.add_argument( |
| 168 | '--image-layout-file', |
| 169 | help='Path to file with image layout', |
| 170 | ) |
| 171 | args = parser.parse_args() |
| 172 | |
| 173 | if args.image_layout_file: |
| 174 | with open(args.image_layout_file) as f: |
| 175 | layout_info = yaml.load(f) |
| 176 | else: |
| 177 | layout_info = yaml.load(args.image_layout) |
| 178 | |
| 179 | LOG.info("Validating provided layout.") |
| 180 | try: |
| 181 | validate(layout_info, IMAGE_LAYOUT_SCHEMA) |
| 182 | except Exception as e: |
| 183 | LOG.error("Validation of provided layout failed.") |
| 184 | raise e |
| 185 | |
| 186 | for part_name, part_layout in layout_info.iteritems(): |
| 187 | |
| 188 | size_opt = '--size' |
| 189 | size_unit = 'm' |
| 190 | |
| 191 | size = part_layout['size'] |
| 192 | vg = part_layout.get('vg', 'vg0') |
| 193 | resizefs = part_layout.get('resizefs', True) |
| 194 | |
| 195 | if size: |
| 196 | if '+' in size: |
| 197 | raise Exception("Setting relative size is not supported.") |
| 198 | # LVCREATE(8) -l --extents option with percentage |
| 199 | elif '%' in size: |
| 200 | size_parts = size.split('%', 1) |
| 201 | size_percent = int(size_parts[0]) |
| 202 | if size_percent > 100: |
| 203 | raise Exception( |
| 204 | "Size percentage cannot be larger than 100%") |
| 205 | size_whole = size_parts[1] |
| 206 | if size_whole == 'ORIGIN': |
| 207 | raise Exception("Snapshot Volumes are not supported") |
| 208 | elif size_whole not in ['VG']: |
| 209 | raise Exception("Relative sizes are not supported.") |
| 210 | size_opt = '--extents' |
| 211 | size_unit = '' |
| 212 | else: |
| 213 | # LVCREATE(8) -L --size option unit |
| 214 | if size[-1].lower() in 'bskmgtpe': |
| 215 | size_unit = size[-1].lower() |
| 216 | size = size[0:-1] |
| 217 | |
| 218 | # when no unit, megabytes by default |
| 219 | if size_opt == '--extents': |
| 220 | unit = 'm' |
| 221 | else: |
| 222 | unit = size_unit |
| 223 | |
| 224 | vgs = get_volume_groups_info(unit, vg) |
| 225 | this_volume_group = vgs[0] |
| 226 | pe = this_volume_group['ext_size'] |
| 227 | |
| 228 | lvs = get_logical_volume_info(unit, vg) |
| 229 | |
| 230 | LOG.info("Volume group info: %s", vgs) |
| 231 | LOG.info("Logical Volume info: %s", lvs) |
| 232 | |
| 233 | this_volume = [v for v in lvs if v['name'] == part_name][0] |
| 234 | |
| 235 | LOG.info("Using %s for volume: %s", size_opt, this_volume) |
| 236 | if size_opt == '--extents': |
| 237 | size_free = this_volume_group['free'] |
| 238 | if size_whole == 'VG': |
| 239 | size_requested = normalize_to_pe( |
| 240 | size_percent * this_volume_group['size'] / 100, pe) |
| 241 | |
| 242 | LOG.info("Request %s size for volume %s", |
| 243 | size_requested, this_volume) |
| 244 | if this_volume['size'] > size_requested: |
| 245 | raise Exception("Redusing volume size in not supported.") |
| 246 | elif this_volume['size'] < size_requested: |
| 247 | if (size_free > 0) and (('+' not in size) or |
| 248 | (size_free >= (size_requested - this_volume['size']))): |
| 249 | cmd = "lvextend " |
| 250 | else: |
| 251 | raise Exception( |
| 252 | ("Logical Volume %s could not be extended. Not " |
| 253 | "enough free space left (%s%s required / %s%s " |
| 254 | "available)"), |
| 255 | this_volume['name'], |
| 256 | size_requested - this_volume['size'], |
| 257 | unit, size_free, unit |
| 258 | ) |
| 259 | if resizefs: |
| 260 | cmd += "--resizefs " |
| 261 | |
| 262 | cmd = "%s -%s %s%s %s/%s" % ( |
| 263 | cmd, size_opt, size, size_unit, vg, this_volume['name']) |
| 264 | try: |
| 265 | LOG.debug("Executing command: %s", cmd) |
| 266 | output = subprocess.check_output( |
| 267 | cmd, |
| 268 | shell=True, |
| 269 | stderr=subprocess.STDOUT) |
| 270 | except subprocess.CalledProcessError as exc: |
| 271 | raise Exception( |
| 272 | "Failed to resize volume %s. Exception: %s" % ( |
| 273 | part_name, exc.output)) |
| 274 | else: |
| 275 | LOG.info("No need to resize volume %s.", part_name) |
| 276 | else: |
| 277 | cmd = "lvresize " |
| 278 | if normalize_to_pe(int(size), pe) > this_volume['size']: |
| 279 | if resizefs: |
| 280 | cmd += "--resizefs " |
| 281 | cmd = "%s -%s %s%s %s/%s" % ( |
| 282 | cmd, size_opt, size, size_unit, vg, this_volume['name']) |
| 283 | try: |
| 284 | LOG.debug("Executing command: %s", cmd) |
| 285 | output = subprocess.check_output( |
| 286 | cmd, |
| 287 | shell=True, |
| 288 | stderr=subprocess.STDOUT) |
| 289 | except subprocess.CalledProcessError as exc: |
| 290 | raise Exception( |
| 291 | "Failed to resize volume %s. Exception: %s" % ( |
| 292 | part_name, exc.output)) |
| 293 | |
| 294 | elif normalize_to_pe(int(size), pe) == this_volume['size']: |
| 295 | LOG.info("No need to resize volume %s.", part_name) |
| 296 | else: |
| 297 | raise Exception( |
| 298 | "Redusing size in not supported for provided layout.") |
| 299 | |
| 300 | |
| 301 | if __name__ == '__main__': |
| 302 | try: |
| 303 | main() |
| 304 | except Exception as e: |
| 305 | LOG.exception("Failed to apply image layout: %s", e) |
| 306 | sys.exit(1) |