notify_dc_users.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424
  1. #!/usr/bin/env python
  2. from __future__ import print_function
  3. import pickle
  4. import os.path
  5. import os
  6. from googleapiclient.discovery import build
  7. from elemental_utils import ElementalNetbox
  8. from pynetbox.models.ipam import IpAddresses
  9. import smtplib
  10. from email.message import EmailMessage
  11. import sys
  12. import re
  13. import subprocess
  14. import ipaddress
  15. import random
  16. import CLEUCreds # type: ignore
  17. from cleu.config import Config as C # type: ignore
  18. FROM = "Joe Clarke <jclarke@cisco.com>"
  19. CC = "Anthony Jesani <anjesani@cisco.com>, Jara Osterfeld <josterfe@cisco.com>"
  20. JUMP_HOSTS = ["10.100.252.26", "10.100.252.27", "10.100.252.28", "10.100.252.29"]
  21. DC_MAP = {
  22. "DC1": ["dc1_datastore_1", "dc1_datastore_2"],
  23. "DC2": ["dc2_datastore_1", "dc2_datastore_2"],
  24. "HyperFlex-DC1": ["DC1-HX-DS-01", "DC1-HX-DS-02"],
  25. "HyperFlex-DC2": ["DC2-HX-DS-01", "DC2-HX-DS-02"],
  26. }
  27. DEFAULT_CLUSTER = "FlexPod"
  28. HX_DCs = {"HyperFlex-DC1": 1, "HyperFlex-DC2": 1}
  29. IP4_SUBNET = "10.100."
  30. IP6_PREFIX = "2a11:d940:2:"
  31. STRETCHED_OCTET = 252
  32. GW_OCTET = 254
  33. NETWORK_MAP = {
  34. "Stretched_VMs": {
  35. "subnet": "{}{}.0/24".format(IP4_SUBNET, STRETCHED_OCTET),
  36. "gw": "{}{}.{}".format(IP4_SUBNET, STRETCHED_OCTET, GW_OCTET),
  37. "prefix": "{}64{}::".format(IP6_PREFIX, format(int(STRETCHED_OCTET), "x")),
  38. "gw6": "{}64{}::{}".format(IP6_PREFIX, format(int(STRETCHED_OCTET), "x"), format(int(GW_OCTET), "x")),
  39. },
  40. "VMs-DC1": {
  41. "subnet": "{}253.0/24".format(IP4_SUBNET),
  42. "gw": "{}253.{}".format(IP4_SUBNET, GW_OCTET),
  43. "prefix": "{}64fd::".format(IP6_PREFIX),
  44. "gw6": "{}64fd::{}".format(IP6_PREFIX, format(int(GW_OCTET), "x")),
  45. },
  46. "VMs-DC2": {
  47. "subnet": "{}254.0/24".format(IP4_SUBNET),
  48. "gw": "{}254.{}".format(IP4_SUBNET, GW_OCTET),
  49. "prefix": "{}64fe::".format(IP6_PREFIX),
  50. "gw6": "{}64fe::{}".format(IP6_PREFIX, format(int(GW_OCTET), "x")),
  51. },
  52. }
  53. OSTYPE_LIST = [
  54. (r"(?i)ubuntu ?22.04", "ubuntu64Guest", "ubuntu22.04", "eth0"),
  55. (r"(?i)ubuntu", "ubuntu64Guest", "linux", "eth0"),
  56. (r"(?i)windows 1[01]", "windows9_64Guest", "windows", "Ethernet 1"),
  57. (r"(?i)windows 2012", "windows8Server64Guest", "windows", "Ethernet 1"),
  58. (r"(?i)windows ?2019", "windows9Server64Guest", "windows2019", "Ethernet 1"),
  59. (r"(?i)windows 201(6|9)", "windows9Server64Guest", "windows", "Ethernet 1"),
  60. (r"(?i)windows", "windows9Server64Guest", "windows", "Ethernet 1"),
  61. (r"(?i)debian 8", "debian8_64Guest", "linux", "eth0"),
  62. (r"(?i)debian", "debian9_64Guest", "linux", "eth0"),
  63. (r"(?i)centos 7", "centos7_64Guest", "linux", "eth0"),
  64. (r"(?i)centos", "centos8_64Guest", "linux", "eth0"),
  65. (r"(?i)red hat", "rhel7_64Guest", "linux", "eth0"),
  66. (r"(?i)linux", "other3xLinux64Guest", "linux", "eth0"),
  67. (r"(?i)freebsd ?13.1", "freebsd12_64Guest", "freebsd13.1", "vmx0"),
  68. (r"(?i)freebsd", "freebsd12_64Guest", "other", "vmx0"),
  69. ]
  70. DNS1 = "10.100.253.6"
  71. DNS2 = "10.100.254.6"
  72. NTP1 = "10.128.0.1"
  73. NTP2 = "10.128.0.2"
  74. VCENTER = "https://" + C.VCENTER
  75. DOMAIN = C.DNS_DOMAIN
  76. AD_DOMAIN = C.AD_DOMAIN
  77. SMTP_SERVER = C.SMTP_SERVER
  78. SYSLOG = SMTP_SERVER
  79. ISO_DS = "dc1_datastore_1"
  80. ISO_DS_HX1 = "DC1-HX-DS-01"
  81. ISO_DS_HX2 = "DC2-HX-DS-01"
  82. VPN_SERVER_IP = C.VPN_SERVER_IP
  83. ANSIBLE_PATH = "/home/jclarke/src/git/ciscolive/automation/cleu-ansible-n9k"
  84. DATACENTER = "CiscoLive"
  85. CISCOLIVE_YEAR = C.CISCOLIVE_YEAR
  86. PW_RESET_URL = C.PW_RESET_URL
  87. TENANT_NAME = "Infrastructure"
  88. VRF_NAME = "default"
  89. SPREADSHEET_ID = "15sC26okPX1lHzMFDJFnoujDKLNclh4NQhBPmV175slY"
  90. SHEET_HOSTNAME = 0
  91. SHEET_OS = 1
  92. SHEET_OVA = 2
  93. SHEET_CONTACT = 5
  94. SHEET_CPU = 6
  95. SHEET_RAM = 7
  96. SHEET_DISK = 8
  97. SHEET_NICS = 9
  98. SHEET_COMMENTS = 11
  99. SHEET_CLUSTER = 12
  100. SHEET_DC = 13
  101. SHEET_VLAN = 14
  102. FIRST_IP = 30
  103. def get_next_ip(enb: ElementalNetbox, prefix: str) -> IpAddresses:
  104. """
  105. Get the next available IP for a prefix.
  106. """
  107. global FIRST_IP, TENANT_NAME, VRF_NAME
  108. prefix_obj = enb.ipam.prefixes.get(prefix=prefix)
  109. available_ips = prefix_obj.available_ips.list()
  110. for addr in available_ips:
  111. ip_obj = ipaddress.ip_address(addr.address.split("/")[0])
  112. if int(ip_obj.packed[-1]) > FIRST_IP:
  113. tenant = enb.tenancy.tenants.get(name=TENANT_NAME)
  114. vrf = enb.ipam.vrfs.get(name=VRF_NAME)
  115. return enb.ipam.ip_addresses.create(address=addr.address, tenant=tenant.id, vrf=vrf.id)
  116. return None
  117. def main():
  118. global NETWORK_MAP
  119. if len(sys.argv) != 2:
  120. print(f"usage: {sys.argv[0]} ROW_RANGE")
  121. sys.exit(1)
  122. if not os.path.exists("gs_token.pickle"):
  123. print("ERROR: Google Sheets token does not exist! Please re-auth the app first.")
  124. sys.exit(1)
  125. creds = None
  126. with open("gs_token.pickle", "rb") as token:
  127. creds = pickle.load(token)
  128. if "VMWARE_USER" not in os.environ or "VMWARE_PASSWORD" not in os.environ:
  129. print("ERROR: VMWARE_USER and VMWARE_PASSWORD environment variables must be set prior to running!")
  130. sys.exit(1)
  131. gs_service = build("sheets", "v4", credentials=creds)
  132. vm_sheet = gs_service.spreadsheets()
  133. vm_result = vm_sheet.values().get(spreadsheetId=SPREADSHEET_ID, range=sys.argv[1]).execute()
  134. vm_values = vm_result.get("values", [])
  135. if not vm_values:
  136. print("ERROR: Did not read anything from Google Sheets!")
  137. sys.exit(1)
  138. enb = ElementalNetbox()
  139. (rstart, _) = sys.argv[1].split(":")
  140. i = int(rstart) - 1
  141. users = {}
  142. for row in vm_values:
  143. i += 1
  144. try:
  145. owners = row[SHEET_CONTACT].strip().split(",")
  146. name = row[SHEET_HOSTNAME].strip()
  147. opsys = row[SHEET_OS].strip()
  148. is_ova = row[SHEET_OVA].strip()
  149. cpu = int(row[SHEET_CPU].strip())
  150. mem = int(row[SHEET_RAM].strip()) * 1024
  151. disk = int(row[SHEET_DISK].strip())
  152. dc = row[SHEET_DC].strip()
  153. cluster = row[SHEET_CLUSTER].strip()
  154. vlan = row[SHEET_VLAN].strip()
  155. comments = row[SHEET_COMMENTS].strip()
  156. except Exception as e:
  157. print(f"WARNING: Failed to process malformed row {i}: {e}")
  158. continue
  159. if name == "" or vlan == "" or dc == "":
  160. print(f"WARNING: Ignoring malformed row {i}")
  161. continue
  162. ova_bool = False
  163. if is_ova.lower() == "true" or is_ova.lower() == "yes":
  164. ova_bool = True
  165. ostype = None
  166. platform = "other"
  167. mgmt_intf = "Ethernet 1"
  168. for ostypes in OSTYPE_LIST:
  169. if re.search(ostypes[0], opsys):
  170. ostype = ostypes[1]
  171. platform = ostypes[2]
  172. mgmt_intf = ostypes[3]
  173. break
  174. if not ova_bool and ostype is None:
  175. print(f"WARNING: Did not find OS type for {vm['os']} on row {i}")
  176. continue
  177. vm = {
  178. "name": name.upper(),
  179. "os": opsys,
  180. "ostype": ostype,
  181. "platform": platform,
  182. "mem": mem,
  183. "is_ova": ova_bool,
  184. "mgmt_intf": mgmt_intf,
  185. "cpu": cpu,
  186. "disk": disk,
  187. "vlan": vlan,
  188. "cluster": cluster,
  189. "dc": dc,
  190. }
  191. if vm["vlan"] not in NETWORK_MAP:
  192. # This is an Attendee VLAN that has been added to the DC.
  193. nb_vlan = enb.ipam.vlans.get(name=vm["vlan"], tenant=TENANT_NAME.lower())
  194. if not nb_vlan:
  195. print(f"WARNING: Invalid VLAN {vm['vlan']} for {name}.")
  196. continue
  197. NETWORK_MAP[vm["vlan"]] = {
  198. "subnet": f"10.{nb_vlan.vid}.{STRETCHED_OCTET}.0/24",
  199. "gw": f"10.{nb_vlan.vid}.{STRETCHED_OCTET}.{GW_OCTET}",
  200. "prefix": f"{IP6_PREFIX}{format(int(nb_vlan.vid), 'x')}{format(int(STRETCHED_OCTET), 'x')}::",
  201. "gw6": f"{IP6_PREFIX}{format(int(nb_vlan.vid), 'x')}{format(int(STRETCHED_OCTET), 'x')}::{format(int(GW_OCTET), 'x')}",
  202. }
  203. ip_obj = get_next_ip(enb, NETWORK_MAP[vm["vlan"]]["subnet"])
  204. if not ip_obj:
  205. print(f"WARNING: No free IP addresses for {name} in subnet {NETWORK_MAP[vm['vlan']]}.")
  206. continue
  207. vm["ip"] = ip_obj.address.split("/")[0]
  208. vm_obj = enb.virtualization.virtual_machines.filter(name=name.lower())
  209. if vm_obj and len(vm_obj) > 0:
  210. print(f"WARNING: Duplicate VM name {name} in NetBox for row {i}.")
  211. continue
  212. platform_obj = enb.dcim.platforms.get(name=vm["platform"])
  213. cluster_obj = enb.virtualization.clusters.get(name=vm["cluster"])
  214. vm_obj = enb.virtualization.virtual_machines.create(
  215. name=name.lower(), platform=platform_obj.id, vcpus=vm["cpu"], disk=vm["disk"], memory=vm["mem"], cluster=cluster_obj.id
  216. )
  217. vm["vm_obj"] = vm_obj
  218. vm_intf = enb.virtualization.interfaces.create(virtual_machine=vm_obj.id, name=mgmt_intf)
  219. ip_obj.assigned_object_id = vm_intf.id
  220. ip_obj.assigned_object_type = "virtualization.vminterface"
  221. ip_obj.save()
  222. vm_obj.primary_ip4 = ip_obj.id
  223. contacts = []
  224. for owner in owners:
  225. owner = owner.strip().lower()
  226. if owner not in users:
  227. users[owner] = []
  228. users[owner].append(vm)
  229. contacts.append(owner)
  230. # TODO: Switch to using the official Contacts and Comments fields.
  231. vm_obj.custom_fields["Contact"] = ",".join(contacts)
  232. vm_obj.custom_fields["Notes"] = comments
  233. vm_obj.save()
  234. created = {}
  235. for user, vms in users.items():
  236. m = re.search(r"<?(\S+)@", user)
  237. username = m.group(1)
  238. body = "Please find the CLEU Data Centre Access details below\r\n\r\n"
  239. body += f"Before you can access the Data Centre from remote, AnyConnect to {VPN_SERVER_IP} and login with {CLEUCreds.VPN_USER} / {CLEUCreds.VPN_PASS}\r\n"
  240. body += f"Once connected, your browser should redirect you to the password change tool. If not go to {PW_RESET_URL} and login with {username} and password {CLEUCreds.DEFAULT_USER_PASSWORD}\r\n"
  241. body += "Reset your password. You must use a complex password that contains lower and uppercase letters, numbers, or a special character.\r\n"
  242. body += f"After resetting your password, drop the VPN and reconnect to {VPN_SERVER_IP} with {username} and the new password you just set.\r\n\r\n"
  243. body += "You can use any of the following Windows Jump Hosts to access the data centre using RDP:\r\n\r\n"
  244. for js in JUMP_HOSTS:
  245. body += f"{js}\r\n"
  246. body += "\r\nIf a Jump Host is full, try the next one.\r\n\r\n"
  247. body += f"Your login is {username} (or {username}@{AD_DOMAIN} on Windows). Your password is the same you used for the VPN.\r\n\r\n"
  248. body += "The network details for your VM(s) are:\r\n\r\n"
  249. body += f"DNS1 : {DNS1}\r\n"
  250. body += f"DNS2 : {DNS2}\r\n"
  251. body += f"NTP1 : {NTP1}\r\n"
  252. body += f"NTP2 : {NTP2}\r\n"
  253. body += f"DNS DOMAIN : {DOMAIN}\r\n"
  254. body += f"SMTP : {SMTP_SERVER}\r\n"
  255. body += f"AD DOMAIN : {AD_DOMAIN}\r\n"
  256. body += f"Syslog/NetFlow: {SYSLOG}\r\n\r\n"
  257. body += f"vCenter is {VCENTER}. You MUST use the web client. Your AD credentials above will work there. VMs that don't require an OVA have been pre-created, but require installation and configuration. If you use an OVA, you will need to deploy it yourself.\r\n\r\n"
  258. body += "Your VM details are as follows. DNS records have been pre-created for the VM name (i.e., hostname) below:\r\n\r\n"
  259. for vm in vms:
  260. datastore = DC_MAP[vm["dc"]][random.randint(0, len(DC_MAP[vm["dc"]]) - 1)]
  261. iso_ds = datastore
  262. cluster = DEFAULT_CLUSTER
  263. if vm["dc"] in HX_DCs:
  264. cluster = vm["dc"]
  265. if not vm["is_ova"] and vm["vlan"] != "" and vm["name"] not in created:
  266. created[vm["name"]] = False
  267. print(f"===Adding VM for {vm['name']}===")
  268. scsi = "lsilogic"
  269. if re.search(r"^win", vm["ostype"]):
  270. scsi = "lsilogicsas"
  271. os.chdir(ANSIBLE_PATH)
  272. command = [
  273. "ansible-playbook",
  274. "-i",
  275. "inventory/hosts",
  276. "-e",
  277. f"vmware_cluster='{cluster}'",
  278. "-e",
  279. f"vmware_datacenter='{DATACENTER}'",
  280. "-e",
  281. f"guest_id={vm['ostype']}",
  282. "-e",
  283. f"guest_name={vm['name']}",
  284. "-e",
  285. f"guest_size={vm['disk']}",
  286. "-e",
  287. f"guest_mem={vm['mem']}",
  288. "-e",
  289. f"guest_cpu={vm['cpu']}",
  290. "-e",
  291. f"guest_datastore={datastore}",
  292. "-e",
  293. f"guest_network='{vm['vlan']}'",
  294. "-e",
  295. f"guest_scsi={scsi}",
  296. "-e",
  297. f"ansible_python_interpreter={sys.executable}",
  298. "add-vm-playbook.yml",
  299. ]
  300. p = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
  301. output = ""
  302. for c in iter(lambda: p.stdout.read(1), b""):
  303. output += c.decode("utf-8")
  304. p.wait()
  305. rc = p.returncode
  306. if rc != 0:
  307. print(f"\n\n***ERROR: Failed to add VM {vm['name']}\n{output}!")
  308. vm["vm_obj"].delete()
  309. continue
  310. print("===DONE===")
  311. created[vm["name"]] = True
  312. octets = vm["ip"].split(".")
  313. body += '{} : {} (v6: {}{}) (Network: {}, Subnet: {}, GW: {}, v6 Prefix: {}/64, v6 GW: {}) : Deploy to the {} datastore in the "{}" cluster.\r\n\r\nFor this VM upload ISOs to the {} datastore. There is an "ISOs" folder there already.\r\n\r\n'.format(
  314. vm["name"],
  315. vm["ip"],
  316. NETWORK_MAP[vm["vlan"]]["prefix"],
  317. format(int(octets[3]), "x"),
  318. vm["vlan"],
  319. NETWORK_MAP[vm["vlan"]]["subnet"],
  320. NETWORK_MAP[vm["vlan"]]["gw"],
  321. NETWORK_MAP[vm["vlan"]]["prefix"],
  322. NETWORK_MAP[vm["vlan"]]["gw6"],
  323. datastore,
  324. cluster,
  325. iso_ds,
  326. )
  327. body += "Let us know via Webex if you need any other details.\r\n\r\n"
  328. body += "Joe, Anthony, and Jara\r\n\r\n"
  329. subject = f"Cisco Live Europe {CISCOLIVE_YEAR} Data Centre Access Info"
  330. smtp = smtplib.SMTP(SMTP_SERVER)
  331. msg = EmailMessage()
  332. msg.set_content(body)
  333. msg["Subject"] = subject
  334. msg["From"] = FROM
  335. msg["To"] = user
  336. msg["Cc"] = CC + "," + FROM
  337. smtp.send_message(msg)
  338. smtp.quit()
  339. if __name__ == "__main__":
  340. os.environ["NETBOX_ADDRESS"] = C.NETBOX_SERVER
  341. os.environ["NETBOX_API_TOKEN"] = CLEUCreds.NETBOX_API_TOKEN
  342. os.environ["CPNR_USERNAME"] = CLEUCreds.CPNR_USERNAME
  343. os.environ["CPNR_PASSWORD"] = CLEUCreds.CPNR_PASSWORD
  344. os.environ["VMWARE_USER"] = CLEUCreds.VMWARE_USER
  345. os.environ["VMWARE_PASSWORD"] = CLEUCreds.VMWARE_PASSWORD
  346. main()