Python 3 library and CLI application for SporeStack
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

405 lines
11 KiB

  1. #!/usr/bin/python3
  2. import logging
  3. import sys
  4. import os
  5. from time import sleep
  6. import requests
  7. from . import validate
  8. LATEST_API_VERSION = 2
  9. GET_TIMEOUT = 60
  10. POST_TIMEOUT = 90
  11. USE_TOR_PROXY = "auto"
  12. TOR_PROXY = "socks5h://127.0.0.1:9050"
  13. # For requests module
  14. TOR_PROXY_REQUESTS = {"http": TOR_PROXY, "https": TOR_PROXY}
  15. def validate_use_tor_proxy(use_tor_proxy):
  16. if isinstance(use_tor_proxy, bool):
  17. return True
  18. if isinstance(use_tor_proxy, str):
  19. if use_tor_proxy == "auto":
  20. return True
  21. raise ValueError('use_tor_proxy must be True, False, or "auto"')
  22. def is_onion_url(url):
  23. """
  24. returns True/False depending on if a URL looks like a Tor hidden service
  25. (.onion) or not.
  26. This is designed to false as non-onion just to be on the safe-ish side,
  27. depending on your view point. It requires URLs like: http://domain.tld/,
  28. not http://domain.tld or domain.tld/.
  29. This can be optimized a lot.
  30. """
  31. try:
  32. url_parts = url.split("/")
  33. domain = url_parts[2]
  34. tld = domain.split(".")[-1]
  35. if tld == "onion":
  36. return True
  37. except Exception:
  38. pass
  39. return False
  40. def api_request(
  41. url, json_params=None, get_params=None, retry=False, use_tor_proxy=USE_TOR_PROXY
  42. ):
  43. validate_use_tor_proxy(use_tor_proxy)
  44. proxies = {}
  45. if use_tor_proxy is True:
  46. proxies = TOR_PROXY_REQUESTS
  47. elif use_tor_proxy == "auto":
  48. if is_onion_url(url) is True:
  49. msg = 'use_tor_proxy is "auto" and we have a .onion url, '
  50. msg += "using local Tor SOCKS proxy."
  51. logging.debug(msg)
  52. proxies = TOR_PROXY_REQUESTS
  53. try:
  54. if json_params is None:
  55. request = requests.get(
  56. url, params=get_params, timeout=GET_TIMEOUT, proxies=proxies
  57. )
  58. else:
  59. request = requests.post(
  60. url, json=json_params, timeout=POST_TIMEOUT, proxies=proxies
  61. )
  62. except Exception as e:
  63. if retry is True:
  64. logging.warning("Got an error, but retrying: {}".format(e))
  65. sleep(5)
  66. # Try again.
  67. return api_request(
  68. url,
  69. json_params=json_params,
  70. get_params=get_params,
  71. retry=retry,
  72. use_tor_proxy=use_tor_proxy,
  73. )
  74. else:
  75. raise
  76. status_code_first_digit = request.status_code // 100
  77. if status_code_first_digit == 2:
  78. try:
  79. request_dict = request.json()
  80. if "latest_api_version" in request_dict:
  81. if request_dict["latest_api_version"] > LATEST_API_VERSION:
  82. logging.warning("New API version may be available.")
  83. return request_dict
  84. except Exception:
  85. return request.content
  86. elif status_code_first_digit == 4:
  87. if request.status_code == 415:
  88. raise NotImplementedError(request.content.decode("utf-8"))
  89. else:
  90. logging.debug("Status code: {}".format(request.status_code))
  91. raise ValueError(request.content.decode("utf-8"))
  92. elif status_code_first_digit == 5:
  93. if retry is True:
  94. logging.warning(request.content.decode("utf-8"))
  95. logging.warning("Got a 500, retrying in 5 seconds...")
  96. sleep(5)
  97. # Try again if we get a 500
  98. return api_request(
  99. url,
  100. json_params=json_params,
  101. get_params=get_params,
  102. retry=retry,
  103. use_tor_proxy=use_tor_proxy,
  104. )
  105. else:
  106. raise Exception(str(request.content))
  107. else:
  108. # Not sure why we'd get this.
  109. request.raise_for_status()
  110. raise Exception("Stuff broke strangely.")
  111. def normalize_argument(argument):
  112. """
  113. Helps normalize arguments from aaargh that may not be what we want.
  114. """
  115. if argument == "False":
  116. return False
  117. elif argument == "True":
  118. return True
  119. elif argument == "None":
  120. return None
  121. else:
  122. return argument
  123. def get_url(api_endpoint, host, target):
  124. """
  125. Has nothing to do with GET requests.
  126. """
  127. if api_endpoint is None:
  128. api_endpoint = "http://{}".format(host)
  129. return "{}/v{}/{}".format(api_endpoint, LATEST_API_VERSION, target)
  130. def launch(
  131. machine_id,
  132. days,
  133. currency,
  134. flavor=None,
  135. disk=None,
  136. memory=None,
  137. ipv4=None,
  138. ipv6=None,
  139. region=None,
  140. ipxescript=None,
  141. operating_system=None,
  142. ssh_key=None,
  143. organization=None,
  144. cores=1,
  145. settlement_token=None,
  146. api_endpoint=None,
  147. host=None,
  148. retry=False,
  149. affiliate_amount=None,
  150. affiliate_token=None,
  151. ):
  152. """
  153. Only ipxescript or operating_system + ssh_key can be None.
  154. flavor overrides cores, memory, etc settings.
  155. """
  156. validate.currency(currency)
  157. validate.flavor(flavor)
  158. validate.organization(organization)
  159. validate.machine_id(machine_id)
  160. validate.ipxescript(ipxescript)
  161. validate.operating_system(operating_system)
  162. validate.ssh_key(ssh_key)
  163. validate.affiliate_amount(affiliate_amount)
  164. json_params = {
  165. "machine_id": machine_id,
  166. "days": days,
  167. "flavor": flavor,
  168. "currency": currency,
  169. "region": region,
  170. "organization": organization,
  171. "settlement_token": settlement_token,
  172. "ipxescript": ipxescript,
  173. "operating_system": operating_system,
  174. "ssh_key": ssh_key,
  175. "host": host,
  176. "affiliate_amount": affiliate_amount,
  177. "affiliate_token": affiliate_token,
  178. }
  179. # If flavor is None, check these
  180. if flavor is None:
  181. ipv4 = normalize_argument(ipv4)
  182. ipv6 = normalize_argument(ipv6)
  183. validate.ipv4(ipv4)
  184. validate.ipv6(ipv6)
  185. validate.cores(cores)
  186. validate.disk(disk)
  187. validate.memory(memory)
  188. json_params["ipv4"] = ipv4
  189. json_params["ipv6"] = ipv6
  190. json_params["cores"] = cores
  191. json_params["disk"] = disk
  192. json_params["memory"] = memory
  193. url = get_url(api_endpoint=api_endpoint, host=host, target="launch")
  194. return api_request(url=url, json_params=json_params, retry=retry)
  195. def topup(
  196. machine_id,
  197. days,
  198. currency,
  199. settlement_token=None,
  200. api_endpoint=None,
  201. host=None,
  202. retry=False,
  203. affiliate_amount=None,
  204. affiliate_token=None,
  205. ):
  206. validate.machine_id(machine_id)
  207. validate.currency(currency)
  208. validate.affiliate_amount(affiliate_amount)
  209. json_params = {
  210. "machine_id": machine_id,
  211. "days": days,
  212. "settlement_token": settlement_token,
  213. "currency": currency,
  214. "host": host,
  215. "affiliate_amount": affiliate_amount,
  216. "affiliate_token": affiliate_token,
  217. }
  218. url = get_url(api_endpoint=api_endpoint, host=host, target="topup")
  219. return api_request(url=url, json_params=json_params, retry=retry)
  220. def start(host, machine_id, api_endpoint=None):
  221. """
  222. Boots the VM.
  223. """
  224. validate.machine_id(machine_id)
  225. url = get_url(api_endpoint=api_endpoint, host=host, target="start")
  226. json_params = {"machine_id": machine_id, "host": host}
  227. api_request(url, json_params=json_params)
  228. return True
  229. def stop(host, machine_id, api_endpoint=None):
  230. """
  231. Immediately kills the VM.
  232. """
  233. validate.machine_id(machine_id)
  234. url = get_url(api_endpoint=api_endpoint, host=host, target="stop")
  235. json_params = {"machine_id": machine_id, "host": host}
  236. api_request(url, json_params=json_params)
  237. return True
  238. def delete(host, machine_id, api_endpoint=None):
  239. """
  240. Immediately deletes the VM.
  241. """
  242. validate.machine_id(machine_id)
  243. url = get_url(api_endpoint=api_endpoint, host=host, target="delete")
  244. json_params = {"machine_id": machine_id, "host": host}
  245. api_request(url, json_params=json_params)
  246. return True
  247. def sshhostname(host, machine_id, api_endpoint=None):
  248. """
  249. Returns a hostname that we can SSH into to reach
  250. port 22 on the VM.
  251. """
  252. validate.machine_id(machine_id)
  253. url = get_url(api_endpoint=api_endpoint, host=host, target="sshhostname")
  254. get_params = {"machine_id": machine_id, "host": host}
  255. return api_request(url, get_params=get_params)
  256. def info(host, machine_id, api_endpoint=None):
  257. """
  258. Returns info about the VM.
  259. """
  260. validate.machine_id(machine_id)
  261. url = get_url(api_endpoint=api_endpoint, host=host, target="info")
  262. get_params = {"machine_id": machine_id, "host": host}
  263. return api_request(url, get_params=get_params)
  264. def ipxescript(host, machine_id, ipxescript=None, api_endpoint=None):
  265. """
  266. Trying to make this both useful as a CLI tool and
  267. as a library. Not really sure how to do that best.
  268. """
  269. validate.machine_id(machine_id)
  270. if ipxescript is None:
  271. if __name__ == "__main__":
  272. ipxescript = sys.stdin.read()
  273. else:
  274. raise ValueError("ipxescript must be set.")
  275. url = get_url(api_endpoint=api_endpoint, host=host, target="ipxescript")
  276. json_params = {"machine_id": machine_id, "host": host, "ipxescript": ipxescript}
  277. return api_request(url, json_params=json_params)
  278. def bootorder(host, machine_id, bootorder, api_endpoint=None):
  279. """
  280. Updates the boot order for a VM.
  281. """
  282. validate.machine_id(machine_id)
  283. validate.bootorder(bootorder)
  284. url = get_url(api_endpoint=api_endpoint, host=host, target="ipxescript")
  285. json_params = {"machine_id": machine_id, "host": host, "bootorder": bootorder}
  286. return api_request(url, json_params=json_params)
  287. def host_info(host, api_endpoint=None):
  288. """
  289. Returns info about the host.
  290. """
  291. url = get_url(api_endpoint=api_endpoint, host=host, target="host_info")
  292. return api_request(url)
  293. def serialconsole(host, machine_id):
  294. """
  295. This needs to be adjusted to use a Tor socks proxy of the host is a .onion.
  296. """
  297. validate.machine_id(machine_id)
  298. command = "/usr/bin/ssh"
  299. arguments = []
  300. arguments.append(command)
  301. arguments.append("-t")
  302. arguments.append("vmmanagement@{}".format(host))
  303. arguments.append("-p")
  304. arguments.append("1060")
  305. arguments.append("serialconsole {}".format(machine_id))
  306. logging.info(command, arguments)
  307. os.execv(command, arguments)
  308. def settlement_token_enable(
  309. settlement_token, cents, currency, api_endpoint=None, retry=False
  310. ):
  311. validate.settlement_token(settlement_token)
  312. validate.cents(cents)
  313. validate.currency(currency)
  314. json_params = {
  315. "settlement_token": settlement_token,
  316. "cents": cents,
  317. "currency": currency,
  318. }
  319. url = api_endpoint + "/settlement/enable"
  320. return api_request(url=url, json_params=json_params, retry=retry)
  321. def settlement_token_add(
  322. settlement_token, cents, currency, api_endpoint=None, retry=False
  323. ):
  324. validate.settlement_token(settlement_token)
  325. validate.cents(cents)
  326. validate.currency(currency)
  327. json_params = {
  328. "settlement_token": settlement_token,
  329. "cents": cents,
  330. "currency": currency,
  331. }
  332. url = api_endpoint + "/settlement/add"
  333. return api_request(url=url, json_params=json_params, retry=retry)
  334. def settlement_token_balance(settlement_token, api_endpoint=None, retry=False):
  335. validate.settlement_token(settlement_token)
  336. get_params = {"settlement_token": settlement_token}
  337. url = api_endpoint + "/settlement/balance"
  338. return api_request(url=url, get_params=get_params, retry=retry)