ntx_ssvm_customize.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429
  1. #!/usr/bin/env python
  2. """
  3. Customization script for Self-Service VM Provisioning via Nutanix Prism
  4. Central. Invoked by cloud-init (Linux) and cloudbase-init (Windows)
  5. when a VM is provisioned by an end user.
  6. Performs a single reboot for Linux and 3 reboots for Windows.
  7. """
  8. __version__ = '3.0'
  9. __author__ = 'cybergavin'
  10. __email__ = 'cybergavin@gmail.com'
  11. import logging
  12. import sys
  13. import configparser
  14. import json
  15. import platform
  16. import subprocess
  17. from pathlib import Path, PurePath
  18. from time import sleep
  19. import requests
  20. requests.packages.urllib3.disable_warnings()
  21. try:
  22. # Variables
  23. script_host = platform.node()
  24. script_os = platform.system()
  25. script_dir = Path((PurePath(sys.argv[0]).parent)).resolve(strict=True)
  26. script_name = PurePath(sys.argv[0]).name
  27. script_stem = PurePath(sys.argv[0]).stem
  28. log_file = script_dir / f'{script_stem}.log'
  29. cfg_file = script_dir / f'{script_stem}.cfg'
  30. cred_file = script_dir / f'{script_stem}.cred'
  31. run_file = script_dir / f'{script_stem}.running'
  32. # Set up Logger
  33. logger = logging.getLogger(__name__)
  34. logger.setLevel(logging.DEBUG)
  35. _FORMAT = '%(asctime)s.%(msecs)03d — %(module)s:%(name)s:%(lineno)d — %(levelname)s — %(message)s'
  36. formatter = logging.Formatter(_FORMAT, datefmt='%Y-%b-%d %H:%M:%S')
  37. console_handler = logging.StreamHandler(sys.stdout)
  38. file_handler = logging.FileHandler(log_file, mode='a')
  39. console_handler.setFormatter(formatter)
  40. file_handler.setFormatter(formatter)
  41. console_handler.setLevel(logging.INFO)
  42. file_handler.setLevel(logging.DEBUG)
  43. logger.addHandler(console_handler)
  44. logger.addHandler(file_handler)
  45. logger.debug('script_dir=%s|log_file=%s', script_dir, log_file)
  46. logger.debug('cfg_file=%s|cred_file=%s', cfg_file, cred_file)
  47. # Validation
  48. if not sys.version_info.major == 3 and sys.version_info.minor >= 5:
  49. logger.critical('This script requires Python version >= 3.5.')
  50. sys.exit()
  51. if not Path(cfg_file).exists():
  52. logger.critical('Missing required file %s.', cfg_file)
  53. sys.exit()
  54. if not Path(cred_file).exists():
  55. logger.critical('Missing required file %s.', cred_file)
  56. sys.exit()
  57. # Parsing configuration and credential files
  58. config = configparser.ConfigParser(interpolation=None)
  59. config.read([cfg_file, cred_file])
  60. ntx_pc_url = f'https://{config["ntx_prism"]["ntx_pc_fqdn"]}:9440/api/nutanix/v3'
  61. prism_timeout = int(config['ntx_prism']['timeout'])
  62. ad_domain = config['ms_ad']['ad_domain']
  63. ad_fqdn = config['ms_ad']['ad_fqdn']
  64. ad_ou = config['ms_ad']['ad_ou']
  65. linux_admins = config['ms_ad']['linux_admins']
  66. windows_admins = config['ms_ad']['windows_admins']
  67. ntx_pc_auth = config['ntx_prism']['ntx_pc_auth']
  68. ad_bind_user = config['ad_bind']['ad_bind_user']
  69. ad_bind_password = config['ad_bind']['ad_bind_password']
  70. # Variables for REST API calls to Prism Central
  71. headers = {'Content-Type': 'application/json',
  72. 'Accept': 'application/json',
  73. 'Authorization': f'Basic {ntx_pc_auth}'}
  74. vm_filter = {'kind': 'vm','filter': f'vm_name=={script_host}'}
  75. vm_ngt = {"nutanix_guest_tools": {"iso_mount_state":"MOUNTED","enabled_capability_list": ["SELF_SERVICE_RESTORE","VSS_SNAPSHOT"],"state":"ENABLED"}}
  76. logger.debug('Parsed files %s and %s. Available sections are %s',
  77. cfg_file, cred_file, config.sections())
  78. logger.debug('Nutanix Prism Central URL is %s', ntx_pc_url)
  79. logger.info('Parsed configuration and credential files')
  80. logger.debug('Validation checks PASSED')
  81. '''
  82. Use run_file to pass values between Windows VM reboots.
  83. run_file is not used for Linux VM customization
  84. '''
  85. if not Path(run_file).exists():
  86. # Get VM's Metadata from Nutanix Prism Central
  87. try:
  88. md_response = requests.post(
  89. f'{ntx_pc_url}/vms/list',
  90. headers=headers,
  91. data=json.dumps(vm_filter),
  92. timeout=prism_timeout,
  93. verify=False)
  94. md_json = md_response.json()
  95. md_response.raise_for_status()
  96. except requests.exceptions.HTTPError as err_http:
  97. logger.error(err_http)
  98. sys.exit()
  99. except requests.exceptions.ConnectionError as err_conn:
  100. logger.error(err_conn)
  101. sys.exit()
  102. except requests.exceptions.Timeout as err_timeout:
  103. logger.error(err_timeout)
  104. sys.exit()
  105. except requests.exceptions.RequestException as err:
  106. logger.error(err)
  107. sys.exit()
  108. logger.info('Successfully called Prism Central API for VM spec')
  109. logger.debug('VM spec JSON from Prism Central. \n %s', json.dumps(md_json,indent=4))
  110. try:
  111. ssvm_uuid = md_json["entities"][-1]["metadata"]["uuid"]
  112. ssvm_project = md_json["entities"][-1]["metadata"]["project_reference"]["name"]
  113. ssvm_owner = md_json["entities"][-1]["metadata"]["owner_reference"]["name"]
  114. except KeyError as ke:
  115. logger.error('Encountered KeyError while obtaining VM Metadata \n %s',ke)
  116. sys.exit()
  117. logger.debug('UUID=%s|PROJECT=%s|OWNER=%s', ssvm_uuid, ssvm_project, ssvm_owner)
  118. logger.info('Obtained the required metadata from Prism Central.')
  119. '''
  120. Generate a new hostname for other IT systems (AD/Patching/etc.)
  121. Modified hostname -> upto 12 characters of provided name combined with
  122. '-NN', where NN are the last 2 characters of the project name. This
  123. allows for unique names across (but not within) Nutanix projects
  124. '''
  125. mon_host = f'{script_host[:12]}-{ssvm_project[-2:]}'
  126. '''
  127. Get the VM's latest spec (due to a current bug in different spec_version returned
  128. in previous POST while creating VM and subsequent GET) for update.
  129. '''
  130. try:
  131. vm_spec_res = requests.get(
  132. f'{ntx_pc_url}/vms/{ssvm_uuid}',
  133. headers=headers,
  134. timeout=prism_timeout,
  135. verify=False)
  136. vm_spec = vm_spec_res.json()
  137. vm_spec_res.raise_for_status()
  138. except requests.exceptions.HTTPError as err_http:
  139. logger.error(err_http)
  140. sys.exit()
  141. except requests.exceptions.ConnectionError as err_conn:
  142. logger.error(err_conn)
  143. sys.exit()
  144. except requests.exceptions.Timeout as err_timeout:
  145. logger.error(err_timeout)
  146. sys.exit()
  147. except requests.exceptions.RequestException as err:
  148. logger.error(err)
  149. sys.exit()
  150. logger.info('Successfully called Prism Central API for latest VM spec')
  151. logger.debug('Latest VM spec JSON from Prism Central. \n %s', json.dumps(vm_spec,indent=4))
  152. # Prepare VM spec for VM Update
  153. new_vm_spec = {}
  154. new_vm_spec["api_version"] = vm_spec["api_version"]
  155. new_vm_spec["spec"] = vm_spec["spec"]
  156. new_vm_spec["spec"]["name"] = mon_host
  157. new_vm_spec["spec"]["resources"]["guest_tools"] = vm_ngt
  158. new_vm_spec["metadata"] = vm_spec["metadata"]
  159. new_vm_spec["metadata"]["categories_mapping"] = { "Self-Service": [f"{ssvm_project}"]}
  160. new_vm_spec["metadata"]["categories"] = { "Self-Service": f"{ssvm_project}"}
  161. # Update VM - change hostname, mount Nutanix Guest Tools and update VM's categories
  162. try:
  163. update_vm_res = requests.put(
  164. f'{ntx_pc_url}/vms/{ssvm_uuid}',
  165. headers=headers,
  166. data=json.dumps(new_vm_spec),
  167. timeout=prism_timeout,
  168. verify=False)
  169. update_vm_json = update_vm_res.json()
  170. update_vm_res.raise_for_status()
  171. except requests.exceptions.HTTPError as err_http:
  172. logger.error(err_http)
  173. sys.exit()
  174. except requests.exceptions.ConnectionError as err_conn:
  175. logger.error(err_conn)
  176. sys.exit()
  177. except requests.exceptions.Timeout as err_timeout:
  178. logger.error(err_timeout)
  179. sys.exit()
  180. except requests.exceptions.RequestException as err:
  181. logger.error(err)
  182. sys.exit()
  183. logger.debug('JSON Response from Prism Central for updating VM \n %s',json.dumps(update_vm_json,indent=4))
  184. logger.info('Successfully called Prism Central to update the VM')
  185. sleep(10)
  186. # Customize VM
  187. if script_os.upper() == 'LINUX':
  188. hostname_cmd = (
  189. f'hostnamectl set-hostname {mon_host}'
  190. )
  191. ad_join_cmd = (
  192. f'echo "{ad_bind_password}" | realm join "{ad_fqdn}" '
  193. f'--computer-ou="{ad_ou}" '
  194. f'-U "{ad_bind_user}"'
  195. )
  196. ENABLE_SSSD_CMD = (
  197. 'systemctl enable sssd && '
  198. 'sed -i "s/use_fully_qualified_names = True/use_fully_qualified_names = False/" '
  199. '/etc/sssd/sssd.conf'
  200. )
  201. adminaccess_cmd = (
  202. f'realm deny --all && '
  203. f'realm permit -g "{ad_domain}\{linux_admins}" && '
  204. f'echo \'"%{linux_admins}" ALL=(ALL) NOPASSWD: ALL\''
  205. f'>> /etc/sudoers'
  206. )
  207. if ssvm_project and ssvm_owner:
  208. useraccess_cmd = (
  209. f'realm permit -g "{ad_domain}\{ssvm_project}" && '
  210. f'echo \'{ssvm_owner[:ssvm_owner.rfind("@")]} ALL=(ALL) NOPASSWD: ALL\''
  211. f'>> /etc/sudoers'
  212. )
  213. with open(log_file, 'a') as _lf:
  214. if not subprocess.run(
  215. f'{hostname_cmd}',
  216. shell=True,
  217. stdout=_lf,
  218. stderr=_lf).returncode:
  219. logger.info('Renamed VM %s to %s before joining %s AD Domain',
  220. script_host, mon_host, ad_domain)
  221. else:
  222. logger.error('Failed to rename VM %s before joining the %s AD Domain',
  223. script_host, ad_domain)
  224. if not subprocess.run(
  225. f'{ad_join_cmd}',
  226. shell=True,
  227. stdout=_lf,
  228. stderr=_lf).returncode:
  229. logger.info('Joined VM %s to the %s AD Domain', mon_host, ad_domain)
  230. else:
  231. logger.error('Failed to join VM %s to the %s AD Domain',
  232. mon_host, ad_domain)
  233. if not subprocess.run(
  234. f'{ENABLE_SSSD_CMD}',
  235. shell=True,
  236. stdout=_lf,
  237. stderr=_lf).returncode:
  238. logger.info('Configured and enabled SSSD')
  239. else:
  240. logger.error('Failed to configure/enable SSSD')
  241. if not subprocess.run(
  242. f'{adminaccess_cmd}',
  243. shell=True,
  244. stdout=_lf,
  245. stderr=_lf).returncode:
  246. logger.info('Provisioned Admin access to the VM.')
  247. else:
  248. logger.error('Failed to provision Admin access to the VM.')
  249. if ssvm_project and ssvm_owner:
  250. if not subprocess.run(
  251. f'{useraccess_cmd}',
  252. shell=True,
  253. stdout=_lf,
  254. stderr=_lf).returncode:
  255. logger.info('Provisioned SSH access to the VM for %s\\%s AD group '
  256. 'and sudo privileges for user %s', ad_domain,
  257. ssvm_project, ssvm_owner[:ssvm_owner.rfind("@")])
  258. else:
  259. logger.error('Failed to provision user access to the VM.')
  260. if not subprocess.run(
  261. 'systemctl restart sssd',
  262. shell=True,
  263. stdout=_lf,
  264. stderr=_lf).returncode:
  265. logger.info('Restarted the SSSD service.')
  266. else:
  267. logger.error('Failed to restart the SSSD service.')
  268. if not subprocess.run('systemctl restart ngt_guest_agent',
  269. shell=True,
  270. stdout=_lf,
  271. stderr=_lf).returncode:
  272. logger.info('Restarted the Nutanix Guest Agent service.')
  273. else:
  274. logger.error('Failed to restart the Nutanix Guest Agent service.')
  275. # Disable cloud-init to prevent reverting the hostname
  276. Path("/etc/cloud/cloud-init.disabled").touch()
  277. if Path("/etc/cloud/cloud-init.disabled").exists():
  278. logger.info('Disabled the cloud-init service')
  279. else:
  280. logger.error('Failed to disable the cloud-init service')
  281. if script_os.upper() == 'WINDOWS':
  282. # Log data in run_file for next run after Windows VM reboot
  283. # Change hostname and reboot with exit code 1003 to signal to
  284. # Cloudbase-init to rerun this program after reboot
  285. with open(run_file, "a") as text_file:
  286. print(f'ssvm_project={ssvm_project}', file=text_file)
  287. print(f'ssvm_owner={ssvm_owner}', file=text_file)
  288. hostname_cmd = (
  289. f'netdom renamecomputer {script_host} '
  290. f'/newname:{mon_host} '
  291. f'/force'
  292. )
  293. with open(log_file, 'a') as _lf:
  294. if not subprocess.run(
  295. f'{hostname_cmd}',
  296. shell=True,
  297. stdout=_lf,
  298. stderr=_lf).returncode:
  299. logger.info('Renamed VM %s to %s',script_host, mon_host)
  300. sys.exit(1003)
  301. else:
  302. logger.error('Failed to rename VM %s to %s',
  303. script_host, mon_host)
  304. sys.exit(911)
  305. else:
  306. # Entering Windows reboot re-run block for further customization
  307. ntx_fetched = {}
  308. with open(run_file) as f:
  309. for line in f.readlines():
  310. key, value = line.rstrip("\n").split("=")
  311. ntx_fetched[key] = value
  312. ssvm_project = ntx_fetched["ssvm_project"]
  313. ssvm_owner = ntx_fetched["ssvm_owner"]
  314. if not ssvm_project and ssvm_owner:
  315. log.error('Could not determine project and owner from run file. Exiting.')
  316. sys.exit()
  317. logger.debug('ntx_fetched values are ssvm_project = %s and '
  318. 'ssvm_owner = %s', ssvm_project,ssvm_owner)
  319. ad_join_cmd = (
  320. f'netdom join {script_host} '
  321. f'/ou:"{ad_ou}" '
  322. f'/domain:{ad_fqdn} '
  323. f'/ud:{ad_domain}\\{ad_bind_user} '
  324. f'/pd:"{ad_bind_password}"'
  325. )
  326. adminaccess_cmd = (
  327. f'net localgroup Administrators "{windows_admins}" /ADD'
  328. )
  329. useraccess_cmd = (
  330. f'net localgroup "Remote Desktop Users" '
  331. f'"{ssvm_project}" /ADD && '
  332. f'net localgroup Administrators '
  333. f'{ssvm_owner[:ssvm_owner.rfind("@")]} /ADD'
  334. )
  335. NGA_RESTART_CMD = (
  336. 'net stop "Nutanix Guest Agent" && '
  337. 'net start "Nutanix Guest Agent"'
  338. )
  339. with open(log_file, 'a') as _lf:
  340. if not subprocess.run(
  341. f'{ad_join_cmd}',
  342. shell=True,
  343. stdout=_lf,
  344. stderr=_lf).returncode:
  345. logger.info('Joined VM %s to the %s AD Domain',
  346. script_host, ad_domain)
  347. sleep(5)
  348. else:
  349. logger.error('Failed to join VM %s to the %s AD Domain',
  350. script_host, ad_domain)
  351. if not subprocess.run(
  352. f'{adminaccess_cmd}',
  353. shell=True,
  354. stdout=_lf,
  355. stderr=_lf).returncode:
  356. logger.info('Provisioned Admin access to the VM.')
  357. else:
  358. logger.error('Failed to provision Admin access to the VM.')
  359. if ssvm_project and ssvm_owner:
  360. if not subprocess.run(
  361. f'{useraccess_cmd}',
  362. shell=True,
  363. stdout=_lf,
  364. stderr=_lf).returncode:
  365. logger.info('Provisioned standard RDP access to the VM '
  366. 'for %s\\%s AD group and Administrator privileges for user %s',
  367. ad_domain, ssvm_project, ssvm_owner[:ssvm_owner.rfind("@")])
  368. else:
  369. logger.error('Failed to provision user access to the VM.')
  370. if not subprocess.run(
  371. f'{NGA_RESTART_CMD}',
  372. shell=True,
  373. stdout=_lf,
  374. stderr=_lf).returncode:
  375. logger.info('Restarted the Nutanix Guest Agent service.')
  376. else:
  377. logger.error('Failed to restart the Nutanix Guest Agent service.')
  378. except Exception as ex:
  379. logger.exception('Encountered unhandled exception\n %s',ex)
  380. if Path(cred_file).exists():
  381. Path(cred_file).unlink()
  382. # Cleanup
  383. if Path(cred_file).exists():
  384. Path(cred_file).unlink()
  385. if Path(run_file).exists():
  386. Path(run_file).unlink()
  387. # Cloudbase-init exit for Windows to prevent re-execution upon boot.
  388. if script_os.upper() == 'WINDOWS':
  389. sys.exit(1001)