blob: b0a5558d9482e1bec9e17b51cd016d09e4f388f0 [file] [log] [blame]
Dzmitry Stremkouski9dd6a1b2019-01-24 12:03:58 +01001#!/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#
azvyagintsev0beb4f12019-04-04 14:26:51 +030011__author__ = "Dzmitry Stremkouski"
Dzmitry Stremkouski9dd6a1b2019-01-24 12:03:58 +010012__copyright__ = "Copyright 2019, Mirantis Inc."
azvyagintsev0beb4f12019-04-04 14:26:51 +030013__license__ = "Apache 2.0"
Dzmitry Stremkouski9dd6a1b2019-01-24 12:03:58 +010014
15import argparse
16from crypt import crypt
17from json import dump as json_dump
18from os import makedirs, umask
19from shutil import copytree, copyfile, copyfileobj, rmtree
20from subprocess import call as run
21from sys import argv, exit
22from uuid import uuid1 as uuidgen
azvyagintsev0beb4f12019-04-04 14:26:51 +030023import ipaddress
Dzmitry Stremkouski9dd6a1b2019-01-24 12:03:58 +010024
25
26def crash_with_error(msg, exit_code=1):
27 print("ERROR: \n" + msg)
28 exit(exit_code)
29
30
31def xprint(msg):
32 if not args.quiet:
33 print(msg)
34
35
36def calculate_hostnames(name, hostname):
Dzmitry Stremkouski9dd6a1b2019-01-24 12:03:58 +010037 if len(name.split('.')) > 1:
azvyagintsev0beb4f12019-04-04 14:26:51 +030038 crash_with_error(
39 "instance name should be in short format without domain")
Dzmitry Stremkouski9dd6a1b2019-01-24 12:03:58 +010040 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
53def validate_args(args):
54 if not args.user_data:
55 if args.cloud_user_name or args.cloud_user_pass:
azvyagintsev0beb4f12019-04-04 14:26:51 +030056 crash_with_error(
57 "You have not specified user-data file path, but require"
58 "cloud-user setup, which requires it.")
Dzmitry Stremkouski9dd6a1b2019-01-24 12:03:58 +010059
60 if not args.skip_network and not args.network_data:
azvyagintsev0beb4f12019-04-04 14:26:51 +030061 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 Stremkouski9dd6a1b2019-01-24 12:03:58 +010084 if not args.ip or not args.netmask or not args.interface:
azvyagintsev0beb4f12019-04-04 14:26:51 +030085 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 Stremkouski9dd6a1b2019-01-24 12:03:58 +010089
90 if args.skip_network and args.network_data:
azvyagintsev0beb4f12019-04-04 14:26:51 +030091 crash_with_error(
92 "--skip-network and --network-data are mutually exclusive.")
Dzmitry Stremkouski9dd6a1b2019-01-24 12:03:58 +010093
94
azvyagintsev0beb4f12019-04-04 14:26:51 +030095def generate_iso(cfg_file_path, cfg_dir_path, quiet=''):
Dzmitry Stremkouski9dd6a1b2019-01-24 12:03:58 +010096 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
104def 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
azvyagintsev0beb4f12019-04-04 14:26:51 +0300129 meta_data = {"uuid": uuid, "hostname": hostname, "name": name}
Dzmitry Stremkouski9dd6a1b2019-01-24 12:03:58 +0100130 network_data = {}
Dzmitry Stremkouski9dd6a1b2019-01-24 12:03:58 +0100131
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:
azvyagintsev0beb4f12019-04-04 14:26:51 +0300139 xprint("Adding authorized keys file entries to config drive: %s" % str(
140 args.ssh_keys))
Dzmitry Stremkouski9dd6a1b2019-01-24 12:03:58 +0100141 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:
azvyagintsev0beb4f12019-04-04 14:26:51 +0300159 xprint("Adding pipeline-library to config drive: %s" % str(
160 args.pipeline_library))
Dzmitry Stremkouski9dd6a1b2019-01-24 12:03:58 +0100161 copytree(args.pipeline_library, pipeline_lib_path)
162
163 if args.mk_pipelines:
azvyagintsev0beb4f12019-04-04 14:26:51 +0300164 xprint(
165 "Adding mk-pipelines to config drive: %s" % str(args.mk_pipelines))
Dzmitry Stremkouski9dd6a1b2019-01-24 12:03:58 +0100166 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:
azvyagintsev0beb4f12019-04-04 14:26:51 +0300174 xprint("Adding vendor metadata file to config drive: %s" % str(
175 args.vendor_data))
Dzmitry Stremkouski9dd6a1b2019-01-24 12:03:58 +0100176 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:
azvyagintsev0beb4f12019-04-04 14:26:51 +0300182 xprint(
183 "Adding user data file to config drive: %s" % str(args.user_data))
Dzmitry Stremkouski9dd6a1b2019-01-24 12:03:58 +0100184 if username:
185 with open(user_file_path, 'a') as user_file:
186 users_data = "#cloud-config\n"
Denis Egorenkob1d3c202019-03-20 18:03:22 +0400187 users_data += "users:\n"
Dzmitry Stremkouski9dd6a1b2019-01-24 12:03:58 +0100188 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:
azvyagintsev0beb4f12019-04-04 14:26:51 +0300193 users_data += " passwd: %s\n" % str(
194 crypt(userpass, '$6$'))
Dzmitry Stremkouski9dd6a1b2019-01-24 12:03:58 +0100195 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:
azvyagintsev0beb4f12019-04-04 14:26:51 +0300207 xprint("Adding network metadata file to config drive: %s" % str(
208 args.network_data))
Dzmitry Stremkouski9dd6a1b2019-01-24 12:03:58 +0100209 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"] = []
azvyagintsev0beb4f12019-04-04 14:26:51 +0300215 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 Stremkouski9dd6a1b2019-01-24 12:03:58 +0100221 if args.dns_nameservers:
222 network_data["services"] = []
223 for nameserver in args.dns_nameservers.split(','):
azvyagintsev0beb4f12019-04-04 14:26:51 +0300224 network_data["services"].append(
225 {"type": "dns", "address": nameserver})
Dzmitry Stremkouski9dd6a1b2019-01-24 12:03:58 +0100226 if args.gateway:
227 network_data["networks"][0]["routes"] = []
azvyagintsev0beb4f12019-04-04 14:26:51 +0300228 network_data["networks"][0]["routes"].append(
229 {"netmask": "0.0.0.0", "gateway": args.gateway,
230 "network": "0.0.0.0"})
Dzmitry Stremkouski9dd6a1b2019-01-24 12:03:58 +0100231
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
243if __name__ == '__main__':
244 uuid = str(uuidgen())
azvyagintsev0beb4f12019-04-04 14:26:51 +0300245 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 Stremkouski9dd6a1b2019-01-24 12:03:58 +0100306 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)