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 | # |
azvyagintsev | 0beb4f1 | 2019-04-04 14:26:51 +0300 | [diff] [blame^] | 11 | __author__ = "Dzmitry Stremkouski" |
Dzmitry Stremkouski | 9dd6a1b | 2019-01-24 12:03:58 +0100 | [diff] [blame] | 12 | __copyright__ = "Copyright 2019, Mirantis Inc." |
azvyagintsev | 0beb4f1 | 2019-04-04 14:26:51 +0300 | [diff] [blame^] | 13 | __license__ = "Apache 2.0" |
Dzmitry Stremkouski | 9dd6a1b | 2019-01-24 12:03:58 +0100 | [diff] [blame] | 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 |
azvyagintsev | 0beb4f1 | 2019-04-04 14:26:51 +0300 | [diff] [blame^] | 23 | import ipaddress |
Dzmitry Stremkouski | 9dd6a1b | 2019-01-24 12:03:58 +0100 | [diff] [blame] | 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): |
Dzmitry Stremkouski | 9dd6a1b | 2019-01-24 12:03:58 +0100 | [diff] [blame] | 37 | if len(name.split('.')) > 1: |
azvyagintsev | 0beb4f1 | 2019-04-04 14:26:51 +0300 | [diff] [blame^] | 38 | crash_with_error( |
| 39 | "instance name should be in short format without domain") |
Dzmitry Stremkouski | 9dd6a1b | 2019-01-24 12:03:58 +0100 | [diff] [blame] | 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: |
azvyagintsev | 0beb4f1 | 2019-04-04 14:26:51 +0300 | [diff] [blame^] | 56 | crash_with_error( |
| 57 | "You have not specified user-data file path, but require" |
| 58 | "cloud-user setup, which requires it.") |
Dzmitry Stremkouski | 9dd6a1b | 2019-01-24 12:03:58 +0100 | [diff] [blame] | 59 | |
| 60 | if not args.skip_network and not args.network_data: |
azvyagintsev | 0beb4f1 | 2019-04-04 14:26:51 +0300 | [diff] [blame^] | 61 | try: |
| 62 | ipaddress.ip_address(args.ip) |
| 63 | except Exception: |
| 64 | raise Exception( |
| 65 | "Unable to parse ip addr:{}".format(args.ip)) |
| 66 | try: |
| 67 | ipaddress.ip_network(args.netmask) |
| 68 | except Exception: |
| 69 | raise Exception( |
| 70 | "Unable to parse netmask:{}".format(args.netmask)) |
| 71 | if args.gateway: |
| 72 | try: |
| 73 | ipaddress.ip_address(args.gateway_ip) |
| 74 | except Exception: |
| 75 | raise Exception( |
| 76 | "Unable to parse gateway IP:{}".format(args.netmask)) |
| 77 | if args.dns_nameservers: |
| 78 | for ns in args.dns_nameservers.split(','): |
| 79 | try: |
| 80 | ipaddress.ip_address(ns) |
| 81 | except Exception: |
| 82 | raise Exception( |
| 83 | "Unable to parse nameserver IP:{}".format(ns)) |
Dzmitry Stremkouski | 9dd6a1b | 2019-01-24 12:03:58 +0100 | [diff] [blame] | 84 | if not args.ip or not args.netmask or not args.interface: |
azvyagintsev | 0beb4f1 | 2019-04-04 14:26:51 +0300 | [diff] [blame^] | 85 | crash_with_error( |
| 86 | "You have not specified neither ip nor netmask nor interface " |
| 87 | "nor network_data.json file. Either skip network configuration " |
| 88 | "or provide network_data.json file path.") |
Dzmitry Stremkouski | 9dd6a1b | 2019-01-24 12:03:58 +0100 | [diff] [blame] | 89 | |
| 90 | if args.skip_network and args.network_data: |
azvyagintsev | 0beb4f1 | 2019-04-04 14:26:51 +0300 | [diff] [blame^] | 91 | crash_with_error( |
| 92 | "--skip-network and --network-data are mutually exclusive.") |
Dzmitry Stremkouski | 9dd6a1b | 2019-01-24 12:03:58 +0100 | [diff] [blame] | 93 | |
| 94 | |
azvyagintsev | 0beb4f1 | 2019-04-04 14:26:51 +0300 | [diff] [blame^] | 95 | def generate_iso(cfg_file_path, cfg_dir_path, quiet=''): |
Dzmitry Stremkouski | 9dd6a1b | 2019-01-24 12:03:58 +0100 | [diff] [blame] | 96 | xprint("Generating config drive image: %s" % cfg_file_path) |
| 97 | cmd = ["mkisofs", "-r", "-J", "-V", "config-2", "-input-charset", "utf-8"] |
| 98 | if quiet: |
| 99 | cmd.append("-quiet") |
| 100 | cmd += ["-o", cfg_file_path, cfg_dir_path] |
| 101 | run(cmd) |
| 102 | |
| 103 | |
| 104 | def create_config_drive(args): |
| 105 | name, hostname = calculate_hostnames(args.name, args.hostname) |
| 106 | username = args.cloud_user_name |
| 107 | if args.cloud_user_pass: |
| 108 | userpass = args.cloud_user_pass |
| 109 | else: |
| 110 | userpass = "" |
| 111 | |
| 112 | cfg_file_path = hostname + '-config.iso' |
| 113 | cfg_dir_path = '/var/tmp/config-drive-' + uuid |
| 114 | mcp_dir_path = cfg_dir_path + '/mcp' |
| 115 | model_path = mcp_dir_path + '/model' |
| 116 | mk_pipelines_path = mcp_dir_path + '/mk-pipelines' |
| 117 | pipeline_lib_path = mcp_dir_path + '/pipeline-library' |
| 118 | meta_dir_path = cfg_dir_path + '/openstack/latest' |
| 119 | meta_file_path = meta_dir_path + '/meta_data.json' |
| 120 | user_file_path = meta_dir_path + '/user_data' |
| 121 | net_file_path = meta_dir_path + '/network_data.json' |
| 122 | vendor_file_path = meta_dir_path + '/vendor_data.json' |
| 123 | gpg_file_path = mcp_dir_path + '/gpg' |
| 124 | |
| 125 | umask(0o0027) |
| 126 | makedirs(mcp_dir_path) |
| 127 | makedirs(meta_dir_path) |
| 128 | |
azvyagintsev | 0beb4f1 | 2019-04-04 14:26:51 +0300 | [diff] [blame^] | 129 | meta_data = {"uuid": uuid, "hostname": hostname, "name": name} |
Dzmitry Stremkouski | 9dd6a1b | 2019-01-24 12:03:58 +0100 | [diff] [blame] | 130 | network_data = {} |
Dzmitry Stremkouski | 9dd6a1b | 2019-01-24 12:03:58 +0100 | [diff] [blame] | 131 | |
| 132 | ssh_keys = [] |
| 133 | |
| 134 | if args.ssh_key: |
| 135 | xprint("Adding authorized key to config drive: %s" % str(args.ssh_key)) |
| 136 | ssh_keys.append(args.ssh_key) |
| 137 | |
| 138 | if args.ssh_keys: |
azvyagintsev | 0beb4f1 | 2019-04-04 14:26:51 +0300 | [diff] [blame^] | 139 | xprint("Adding authorized keys file entries to config drive: %s" % str( |
| 140 | args.ssh_keys)) |
Dzmitry Stremkouski | 9dd6a1b | 2019-01-24 12:03:58 +0100 | [diff] [blame] | 141 | with open(args.ssh_keys, 'r') as ssh_keys_file: |
| 142 | ssh_keys += ssh_keys_file.readlines() |
| 143 | ssh_keys = [x.strip() for x in ssh_keys] |
| 144 | |
| 145 | # Deduplicate keys if any |
| 146 | ssh_keys = list(set(ssh_keys)) |
| 147 | |
| 148 | # Load keys |
| 149 | if len(ssh_keys) > 0: |
| 150 | meta_data["public_keys"] = {} |
| 151 | for i in range(len(ssh_keys)): |
| 152 | meta_data["public_keys"][str(i)] = ssh_keys[i] |
| 153 | |
| 154 | if args.model: |
| 155 | xprint("Adding cluster model to config drive: %s" % str(args.model)) |
| 156 | copytree(args.model, model_path) |
| 157 | |
| 158 | if args.pipeline_library: |
azvyagintsev | 0beb4f1 | 2019-04-04 14:26:51 +0300 | [diff] [blame^] | 159 | xprint("Adding pipeline-library to config drive: %s" % str( |
| 160 | args.pipeline_library)) |
Dzmitry Stremkouski | 9dd6a1b | 2019-01-24 12:03:58 +0100 | [diff] [blame] | 161 | copytree(args.pipeline_library, pipeline_lib_path) |
| 162 | |
| 163 | if args.mk_pipelines: |
azvyagintsev | 0beb4f1 | 2019-04-04 14:26:51 +0300 | [diff] [blame^] | 164 | xprint( |
| 165 | "Adding mk-pipelines to config drive: %s" % str(args.mk_pipelines)) |
Dzmitry Stremkouski | 9dd6a1b | 2019-01-24 12:03:58 +0100 | [diff] [blame] | 166 | copytree(args.mk_pipelines, mk_pipelines_path) |
| 167 | |
| 168 | if args.gpg_key: |
| 169 | xprint("Adding gpg keys file to config drive: %s" % str(args.gpg_key)) |
| 170 | makedirs(gpg_file_path) |
| 171 | copyfile(args.gpg_key, gpg_file_path + '/salt_master_pillar.asc') |
| 172 | |
| 173 | if args.vendor_data: |
azvyagintsev | 0beb4f1 | 2019-04-04 14:26:51 +0300 | [diff] [blame^] | 174 | xprint("Adding vendor metadata file to config drive: %s" % str( |
| 175 | args.vendor_data)) |
Dzmitry Stremkouski | 9dd6a1b | 2019-01-24 12:03:58 +0100 | [diff] [blame] | 176 | copyfile(args.vendor_data, vendor_file_path) |
| 177 | |
| 178 | with open(meta_file_path, 'w') as meta_file: |
| 179 | json_dump(meta_data, meta_file) |
| 180 | |
| 181 | if args.user_data: |
azvyagintsev | 0beb4f1 | 2019-04-04 14:26:51 +0300 | [diff] [blame^] | 182 | xprint( |
| 183 | "Adding user data file to config drive: %s" % str(args.user_data)) |
Dzmitry Stremkouski | 9dd6a1b | 2019-01-24 12:03:58 +0100 | [diff] [blame] | 184 | if username: |
| 185 | with open(user_file_path, 'a') as user_file: |
| 186 | users_data = "#cloud-config\n" |
Denis Egorenko | b1d3c20 | 2019-03-20 18:03:22 +0400 | [diff] [blame] | 187 | users_data += "users:\n" |
Dzmitry Stremkouski | 9dd6a1b | 2019-01-24 12:03:58 +0100 | [diff] [blame] | 188 | users_data += " - name: %s\n" % username |
| 189 | users_data += " sudo: ALL=(ALL) NOPASSWD:ALL\n" |
| 190 | users_data += " groups: admin\n" |
| 191 | users_data += " lock_passwd: false\n" |
| 192 | if userpass: |
azvyagintsev | 0beb4f1 | 2019-04-04 14:26:51 +0300 | [diff] [blame^] | 193 | users_data += " passwd: %s\n" % str( |
| 194 | crypt(userpass, '$6$')) |
Dzmitry Stremkouski | 9dd6a1b | 2019-01-24 12:03:58 +0100 | [diff] [blame] | 195 | if ssh_keys: |
| 196 | users_data += " ssh_authorized_keys:\n" |
| 197 | for ssh_key in ssh_keys: |
| 198 | users_data += " - %s\n" % ssh_key |
| 199 | users_data += "\n" |
| 200 | user_file.write(users_data) |
| 201 | with open(args.user_data, 'r') as user_data_file: |
| 202 | copyfileobj(user_data_file, user_file) |
| 203 | else: |
| 204 | copyfile(args.user_data, user_file_path) |
| 205 | |
| 206 | if args.network_data: |
azvyagintsev | 0beb4f1 | 2019-04-04 14:26:51 +0300 | [diff] [blame^] | 207 | xprint("Adding network metadata file to config drive: %s" % str( |
| 208 | args.network_data)) |
Dzmitry Stremkouski | 9dd6a1b | 2019-01-24 12:03:58 +0100 | [diff] [blame] | 209 | copyfile(args.network_data, net_file_path) |
| 210 | else: |
| 211 | if not args.skip_network: |
| 212 | xprint("Configuring network metadata from specified parameters.") |
| 213 | network_data["links"] = [] |
| 214 | network_data["networks"] = [] |
azvyagintsev | 0beb4f1 | 2019-04-04 14:26:51 +0300 | [diff] [blame^] | 215 | network_data["links"].append( |
| 216 | {"type": "phy", "id": args.interface, "name": args.interface}) |
| 217 | network_data["networks"].append( |
| 218 | {"type": "ipv4", "netmask": args.netmask, |
| 219 | "link": args.interface, "id": "private-ipv4", |
| 220 | "ip_address": args.ip}) |
Dzmitry Stremkouski | 9dd6a1b | 2019-01-24 12:03:58 +0100 | [diff] [blame] | 221 | if args.dns_nameservers: |
| 222 | network_data["services"] = [] |
| 223 | for nameserver in args.dns_nameservers.split(','): |
azvyagintsev | 0beb4f1 | 2019-04-04 14:26:51 +0300 | [diff] [blame^] | 224 | network_data["services"].append( |
| 225 | {"type": "dns", "address": nameserver}) |
Dzmitry Stremkouski | 9dd6a1b | 2019-01-24 12:03:58 +0100 | [diff] [blame] | 226 | if args.gateway: |
| 227 | network_data["networks"][0]["routes"] = [] |
azvyagintsev | 0beb4f1 | 2019-04-04 14:26:51 +0300 | [diff] [blame^] | 228 | network_data["networks"][0]["routes"].append( |
| 229 | {"netmask": "0.0.0.0", "gateway": args.gateway, |
| 230 | "network": "0.0.0.0"}) |
Dzmitry Stremkouski | 9dd6a1b | 2019-01-24 12:03:58 +0100 | [diff] [blame] | 231 | |
| 232 | # Check if network metadata is not skipped |
| 233 | if len(network_data) > 0: |
| 234 | with open(net_file_path, 'w') as net_file: |
| 235 | json_dump(network_data, net_file) |
| 236 | |
| 237 | generate_iso(cfg_file_path, cfg_dir_path, args.quiet) |
| 238 | if args.clean_up: |
| 239 | xprint("Cleaning up working dir.") |
| 240 | rmtree(cfg_dir_path) |
| 241 | |
| 242 | |
| 243 | if __name__ == '__main__': |
| 244 | uuid = str(uuidgen()) |
azvyagintsev | 0beb4f1 | 2019-04-04 14:26:51 +0300 | [diff] [blame^] | 245 | parser = argparse.ArgumentParser( |
| 246 | description='Config drive generator for MCP instances.', prog=argv[0], |
| 247 | usage='%(prog)s [options]') |
| 248 | parser.add_argument('--gpg-key', type=str, |
| 249 | help='Upload gpg key for salt master. Specify path to file in asc format.', |
| 250 | required=False) |
| 251 | parser.add_argument('--name', type=str, default=uuid, |
| 252 | help='Specify instance name. Hostname in short format, without domain.', |
| 253 | required=False) |
| 254 | parser.add_argument('--hostname', type=str, default=uuid, |
| 255 | help='Specify instance hostname. FQDN. Hostname in full format with domain. Shortname would be trated as name.', |
| 256 | required=False) |
| 257 | parser.add_argument('--skip-network', action='store_true', |
| 258 | help='Do not generate network_data for the instance.', |
| 259 | required=False) |
| 260 | parser.add_argument('--interface', type=str, default='ens3', |
| 261 | help='Specify interface for instance to configure.', |
| 262 | required=False) |
| 263 | parser.add_argument('--ssh-key', type=str, |
| 264 | help='Specify ssh public key to upload to cloud image.', |
| 265 | required=False) |
| 266 | parser.add_argument('--ssh-keys', type=str, |
| 267 | help='Upload authorized_keys to cloud image. Specify path to file in authorized_keys format.', |
| 268 | required=False) |
| 269 | parser.add_argument('--cloud-user-name', type=str, |
| 270 | help='Specify cloud user name.', required=False) |
| 271 | parser.add_argument('--cloud-user-pass', type=str, |
| 272 | help='Specify cloud user password.', required=False) |
| 273 | parser.add_argument('--ip', type=str, |
| 274 | help='Specify IP address for instance.', required=False) |
| 275 | parser.add_argument('--netmask', type=str, |
| 276 | help='Specify netmask for instance.', required=False) |
| 277 | parser.add_argument('--gateway', type=str, |
| 278 | help='Specify gateway address for instance.', |
| 279 | required=False) |
| 280 | parser.add_argument('--dns-nameservers', type=str, |
| 281 | help='Specify DNS nameservers delimited by comma.', |
| 282 | required=False) |
| 283 | parser.add_argument('--user-data', type=str, |
| 284 | help='Specify path to user_data file in yaml format.', |
| 285 | required=False) |
| 286 | parser.add_argument('--vendor-data', type=str, |
| 287 | help='Specify path to vendor_data.json in openstack vendor metadata format.', |
| 288 | required=False) |
| 289 | parser.add_argument('--network-data', type=str, |
| 290 | help='Specify path to network_data.json in openstack network metadata format.', |
| 291 | required=False) |
| 292 | parser.add_argument('--model', type=str, |
| 293 | help='Specify path to cluster model.', required=False) |
| 294 | parser.add_argument('--mk-pipelines', type=str, |
| 295 | help='Specify path to mk-pipelines folder.', |
| 296 | required=False) |
| 297 | parser.add_argument('--pipeline-library', type=str, |
| 298 | help='Specify path to pipeline-library folder.', |
| 299 | required=False) |
| 300 | parser.add_argument('--clean-up', action='store_true', |
| 301 | help='Clean-up config-drive dir once ISO is created.', |
| 302 | required=False) |
| 303 | parser.add_argument('--quiet', action='store_true', |
| 304 | help='Keep silence. Do not write any output messages to stout.', |
| 305 | required=False) |
Dzmitry Stremkouski | 9dd6a1b | 2019-01-24 12:03:58 +0100 | [diff] [blame] | 306 | args = parser.parse_args() |
| 307 | |
| 308 | if len(argv) < 2: |
| 309 | parser.print_help() |
| 310 | exit(0) |
| 311 | |
| 312 | validate_args(args) |
| 313 | create_config_drive(args) |