| Dzmitry Stremkouski | 9dd6a1b | 2019-01-24 12:03:58 +0100 | [diff] [blame] | 1 | #!/usr/bin/env python3 | 
|  | 2 | # | 
|  | 3 | # Generate config drives v2 for MCP instances. | 
|  | 4 | # | 
|  | 5 | # Config Drive v2 links: | 
|  | 6 | # - structure: https://cloudinit.readthedocs.io/en/latest/topics/datasources/configdrive.html#version-2 | 
|  | 7 | # - network configuration: https://cloudinit.readthedocs.io/en/latest/topics/network-config.html | 
|  | 8 | # | 
|  | 9 | # This script uses OpenStack Metadata Service Network format for configuring networking. | 
|  | 10 | # | 
|  | 11 | __author__    = "Dzmitry Stremkouski" | 
|  | 12 | __copyright__ = "Copyright 2019, Mirantis Inc." | 
|  | 13 | __license__   = "Apache 2.0" | 
|  | 14 |  | 
|  | 15 | import argparse | 
|  | 16 | from crypt import crypt | 
|  | 17 | from json import dump as json_dump | 
|  | 18 | from os import makedirs, umask | 
|  | 19 | from shutil import copytree, copyfile, copyfileobj, rmtree | 
|  | 20 | from subprocess import call as run | 
|  | 21 | from sys import argv, exit | 
|  | 22 | from uuid import uuid1 as uuidgen | 
|  | 23 | from yaml import safe_load | 
|  | 24 |  | 
|  | 25 |  | 
|  | 26 | def crash_with_error(msg, exit_code=1): | 
|  | 27 | print("ERROR: \n" + msg) | 
|  | 28 | exit(exit_code) | 
|  | 29 |  | 
|  | 30 |  | 
|  | 31 | def xprint(msg): | 
|  | 32 | if not args.quiet: | 
|  | 33 | print(msg) | 
|  | 34 |  | 
|  | 35 |  | 
|  | 36 | def calculate_hostnames(name, hostname): | 
|  | 37 |  | 
|  | 38 | if len(name.split('.')) > 1: | 
|  | 39 | crash_with_error("instance name should be in short format without domain") | 
|  | 40 | else: | 
|  | 41 | if len(hostname.split('.')) == 1: | 
|  | 42 | if not name == uuid: | 
|  | 43 | hostname = name | 
|  | 44 | else: | 
|  | 45 | name = hostname | 
|  | 46 | else: | 
|  | 47 | if name == uuid: | 
|  | 48 | name = hostname.split('.')[0] | 
|  | 49 |  | 
|  | 50 | return [name, hostname] | 
|  | 51 |  | 
|  | 52 |  | 
|  | 53 | def validate_args(args): | 
|  | 54 | if not args.user_data: | 
|  | 55 | if args.cloud_user_name or args.cloud_user_pass: | 
|  | 56 | crash_with_error("You have not specified user-data file path, but require cloud-user setup, which requires it.") | 
|  | 57 |  | 
|  | 58 | if not args.skip_network and not args.network_data: | 
|  | 59 | if not args.ip or not args.netmask or not args.interface: | 
|  | 60 | crash_with_error("You have not specified neither ip nor netmask nor interface nor network_data.json file.\nEither skip network configuration or provide network_data.json file path.") | 
|  | 61 |  | 
|  | 62 | if args.skip_network and args.network_data: | 
|  | 63 | crash_with_error("--skip-network and --network-data are mutually exclusive.") | 
|  | 64 |  | 
|  | 65 |  | 
|  | 66 | def generate_iso(cfg_file_path, cfg_dir_path, quiet = ''): | 
|  | 67 | xprint("Generating config drive image: %s" % cfg_file_path) | 
|  | 68 | cmd = ["mkisofs", "-r", "-J", "-V", "config-2", "-input-charset", "utf-8"] | 
|  | 69 | if quiet: | 
|  | 70 | cmd.append("-quiet") | 
|  | 71 | cmd += ["-o", cfg_file_path, cfg_dir_path] | 
|  | 72 | run(cmd) | 
|  | 73 |  | 
|  | 74 |  | 
|  | 75 | def create_config_drive(args): | 
|  | 76 | name, hostname = calculate_hostnames(args.name, args.hostname) | 
|  | 77 | username = args.cloud_user_name | 
|  | 78 | if args.cloud_user_pass: | 
|  | 79 | userpass = args.cloud_user_pass | 
|  | 80 | else: | 
|  | 81 | userpass = "" | 
|  | 82 |  | 
|  | 83 | cfg_file_path = hostname + '-config.iso' | 
|  | 84 | cfg_dir_path = '/var/tmp/config-drive-' + uuid | 
|  | 85 | mcp_dir_path = cfg_dir_path + '/mcp' | 
|  | 86 | model_path = mcp_dir_path + '/model' | 
|  | 87 | mk_pipelines_path = mcp_dir_path + '/mk-pipelines' | 
|  | 88 | pipeline_lib_path = mcp_dir_path + '/pipeline-library' | 
|  | 89 | meta_dir_path = cfg_dir_path + '/openstack/latest' | 
|  | 90 | meta_file_path = meta_dir_path + '/meta_data.json' | 
|  | 91 | user_file_path = meta_dir_path + '/user_data' | 
|  | 92 | net_file_path = meta_dir_path + '/network_data.json' | 
|  | 93 | vendor_file_path = meta_dir_path + '/vendor_data.json' | 
|  | 94 | gpg_file_path = mcp_dir_path + '/gpg' | 
|  | 95 |  | 
|  | 96 | umask(0o0027) | 
|  | 97 | makedirs(mcp_dir_path) | 
|  | 98 | makedirs(meta_dir_path) | 
|  | 99 |  | 
|  | 100 | meta_data = {} | 
|  | 101 | meta_data["uuid"] = uuid | 
|  | 102 | meta_data["hostname"] = hostname | 
|  | 103 | meta_data["name"] = name | 
|  | 104 | network_data = {} | 
|  | 105 | gateway_ip = "" | 
|  | 106 |  | 
|  | 107 | ssh_keys = [] | 
|  | 108 |  | 
|  | 109 | if args.ssh_key: | 
|  | 110 | xprint("Adding authorized key to config drive: %s" % str(args.ssh_key)) | 
|  | 111 | ssh_keys.append(args.ssh_key) | 
|  | 112 |  | 
|  | 113 | if args.ssh_keys: | 
|  | 114 | xprint("Adding authorized keys file entries to config drive: %s" % str(args.ssh_keys)) | 
|  | 115 | with open(args.ssh_keys, 'r') as ssh_keys_file: | 
|  | 116 | ssh_keys += ssh_keys_file.readlines() | 
|  | 117 | ssh_keys = [x.strip() for x in ssh_keys] | 
|  | 118 |  | 
|  | 119 | # Deduplicate keys if any | 
|  | 120 | ssh_keys = list(set(ssh_keys)) | 
|  | 121 |  | 
|  | 122 | # Load keys | 
|  | 123 | if len(ssh_keys) > 0: | 
|  | 124 | meta_data["public_keys"] = {} | 
|  | 125 | for i in range(len(ssh_keys)): | 
|  | 126 | meta_data["public_keys"][str(i)] = ssh_keys[i] | 
|  | 127 |  | 
|  | 128 | if args.model: | 
|  | 129 | xprint("Adding cluster model to config drive: %s" % str(args.model)) | 
|  | 130 | copytree(args.model, model_path) | 
|  | 131 |  | 
|  | 132 | if args.pipeline_library: | 
|  | 133 | xprint("Adding pipeline-library to config drive: %s" % str(args.pipeline_library)) | 
|  | 134 | copytree(args.pipeline_library, pipeline_lib_path) | 
|  | 135 |  | 
|  | 136 | if args.mk_pipelines: | 
|  | 137 | xprint("Adding mk-pipelines to config drive: %s" % str(args.mk_pipelines)) | 
|  | 138 | copytree(args.mk_pipelines, mk_pipelines_path) | 
|  | 139 |  | 
|  | 140 | if args.gpg_key: | 
|  | 141 | xprint("Adding gpg keys file to config drive: %s" % str(args.gpg_key)) | 
|  | 142 | makedirs(gpg_file_path) | 
|  | 143 | copyfile(args.gpg_key, gpg_file_path + '/salt_master_pillar.asc') | 
|  | 144 |  | 
|  | 145 | if args.vendor_data: | 
|  | 146 | xprint("Adding vendor metadata file to config drive: %s" % str(args.vendor_data)) | 
|  | 147 | copyfile(args.vendor_data, vendor_file_path) | 
|  | 148 |  | 
|  | 149 | with open(meta_file_path, 'w') as meta_file: | 
|  | 150 | json_dump(meta_data, meta_file) | 
|  | 151 |  | 
|  | 152 | if args.user_data: | 
|  | 153 | xprint("Adding user data file to config drive: %s" % str(args.user_data)) | 
|  | 154 | if username: | 
|  | 155 | with open(user_file_path, 'a') as user_file: | 
|  | 156 | users_data = "#cloud-config\n" | 
|  | 157 | users_data = "users:\n" | 
|  | 158 | users_data += "  - name: %s\n" % username | 
|  | 159 | users_data += "    sudo: ALL=(ALL) NOPASSWD:ALL\n" | 
|  | 160 | users_data += "    groups: admin\n" | 
|  | 161 | users_data += "    lock_passwd: false\n" | 
|  | 162 | if userpass: | 
|  | 163 | users_data += "    passwd: %s\n" % str(crypt(userpass, '$6$')) | 
|  | 164 | if ssh_keys: | 
|  | 165 | users_data += "    ssh_authorized_keys:\n" | 
|  | 166 | for ssh_key in ssh_keys: | 
|  | 167 | users_data += "    - %s\n" % ssh_key | 
|  | 168 | users_data += "\n" | 
|  | 169 | user_file.write(users_data) | 
|  | 170 | with open(args.user_data, 'r') as user_data_file: | 
|  | 171 | copyfileobj(user_data_file, user_file) | 
|  | 172 | else: | 
|  | 173 | copyfile(args.user_data, user_file_path) | 
|  | 174 |  | 
|  | 175 | if args.network_data: | 
|  | 176 | xprint("Adding network metadata file to config drive: %s" % str(args.network_data)) | 
|  | 177 | copyfile(args.network_data, net_file_path) | 
|  | 178 | else: | 
|  | 179 | if not args.skip_network: | 
|  | 180 | xprint("Configuring network metadata from specified parameters.") | 
|  | 181 | network_data["links"] = [] | 
|  | 182 | network_data["networks"] = [] | 
|  | 183 | network_data["links"].append({"type": "phy", "id": args.interface, "name": args.interface}) | 
|  | 184 | network_data["networks"].append({"type": "ipv4", "netmask": args.netmask, "link": args.interface, "id": "private-ipv4", "ip_address": args.ip}) | 
|  | 185 | if args.dns_nameservers: | 
|  | 186 | network_data["services"] = [] | 
|  | 187 | for nameserver in args.dns_nameservers.split(','): | 
|  | 188 | network_data["services"].append({"type": "dns", "address": nameserver}) | 
|  | 189 | if args.gateway: | 
|  | 190 | network_data["networks"][0]["routes"] = [] | 
|  | 191 | network_data["networks"][0]["routes"].append({"netmask": "0.0.0.0", "gateway": args.gateway, "network": "0.0.0.0"}) | 
|  | 192 |  | 
|  | 193 | # Check if network metadata is not skipped | 
|  | 194 | if len(network_data) > 0: | 
|  | 195 | with open(net_file_path, 'w') as net_file: | 
|  | 196 | json_dump(network_data, net_file) | 
|  | 197 |  | 
|  | 198 | generate_iso(cfg_file_path, cfg_dir_path, args.quiet) | 
|  | 199 | if args.clean_up: | 
|  | 200 | xprint("Cleaning up working dir.") | 
|  | 201 | rmtree(cfg_dir_path) | 
|  | 202 |  | 
|  | 203 |  | 
|  | 204 | if __name__ == '__main__': | 
|  | 205 | uuid = str(uuidgen()) | 
|  | 206 | parser = argparse.ArgumentParser(description='Config drive generator for MCP instances.', prog=argv[0], usage='%(prog)s [options]') | 
|  | 207 | parser.add_argument('--gpg-key', type=str, help='Upload gpg key for salt master. Specify path to file in asc format.', required=False) | 
|  | 208 | parser.add_argument('--name', type=str, default=uuid, help='Specify instance name. Hostname in short format, without domain.', required=False) | 
|  | 209 | parser.add_argument('--hostname',  type=str, default=uuid, help='Specify instance hostname. FQDN. Hostname in full format with domain. Shortname would be trated as name.', required=False) | 
|  | 210 | parser.add_argument('--skip-network', action='store_true', help='Do not generate network_data for the instance.', required=False) | 
|  | 211 | parser.add_argument('--interface', type=str, default='ens3', help='Specify interface for instance to configure.', required=False) | 
|  | 212 | parser.add_argument('--ssh-key', type=str, help='Specify ssh public key to upload to cloud image.', required=False) | 
|  | 213 | parser.add_argument('--ssh-keys', type=str, help='Upload authorized_keys to cloud image. Specify path to file in authorized_keys format.', required=False) | 
|  | 214 | parser.add_argument('--cloud-user-name', type=str, help='Specify cloud user name.', required=False) | 
|  | 215 | parser.add_argument('--cloud-user-pass', type=str, help='Specify cloud user password.', required=False) | 
|  | 216 | parser.add_argument('--ip', type=str, help='Specify IP address for instance.', required=False) | 
|  | 217 | parser.add_argument('--netmask', type=str, help='Specify netmask for instance.', required=False) | 
|  | 218 | parser.add_argument('--gateway', type=str, help='Specify gateway address for instance.', required=False) | 
|  | 219 | parser.add_argument('--dns-nameservers', type=str, help='Specify DNS nameservers delimited by comma.', required=False) | 
|  | 220 | parser.add_argument('--user-data', type=str, help='Specify path to user_data file in yaml format.', required=False) | 
|  | 221 | parser.add_argument('--vendor-data', type=str, help='Specify path to vendor_data.json in openstack vendor metadata format.', required=False) | 
|  | 222 | parser.add_argument('--network-data', type=str, help='Specify path to network_data.json in openstack network metadata format.', required=False) | 
|  | 223 | parser.add_argument('--model', type=str, help='Specify path to cluster model.', required=False) | 
|  | 224 | parser.add_argument('--mk-pipelines', type=str, help='Specify path to mk-pipelines folder.', required=False) | 
|  | 225 | parser.add_argument('--pipeline-library', type=str, help='Specify path to pipeline-library folder.', required=False) | 
|  | 226 | parser.add_argument('--clean-up', action='store_true', help='Clean-up config-drive dir once ISO is created.', required=False) | 
|  | 227 | parser.add_argument('--quiet', action='store_true', help='Keep silence. Do not write any output messages to stout.', required=False) | 
|  | 228 | args = parser.parse_args() | 
|  | 229 |  | 
|  | 230 | if len(argv) < 2: | 
|  | 231 | parser.print_help() | 
|  | 232 | exit(0) | 
|  | 233 |  | 
|  | 234 | validate_args(args) | 
|  | 235 | create_config_drive(args) |