notify_dc_users.py 17 KB

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