/silk/fabfile.py

https://bitbucket.org/btubbs/silk-deployment/ · Python · 510 lines · 414 code · 30 blank · 66 comment · 19 complexity · 4850c2f7bcc33a38e6b4232a6d2ec6a6 MD5 · raw file

  1. import sys
  2. import os
  3. import datetime
  4. import time
  5. import posixpath
  6. import random
  7. import re
  8. import copy
  9. import yaml
  10. import pkg_resources
  11. from fabric.api import *
  12. from fabric.colors import green, red, yellow
  13. from fabric.contrib.files import exists, contains, upload_template
  14. import silk.lib
  15. def _join(*args):
  16. """Convenience wrapper around posixpath.join to make the rest of our
  17. functions more readable."""
  18. return posixpath.join(*args)
  19. SRV_ROOT = '/srv'
  20. DEFAULT_ROLLBACK_CAP = 3
  21. DTS_FORMAT = '%Y%m%d_%H%M%S'
  22. NGINX_SITE_DIR = '/etc/nginx/sites-enabled'
  23. def _set_vars():
  24. """
  25. Loads deployment settings into Fabric's global 'env' dict
  26. """
  27. env.local_root = silk.lib.get_root(os.getcwd())
  28. env.config = silk.lib.get_site_config(env.local_root)
  29. env.dts = datetime.datetime.now().strftime(DTS_FORMAT)
  30. if len(env.roles) == 1:
  31. env.config.update(silk.lib.get_role_config(env.roles[0]))
  32. env.site = env.config['site']
  33. env.deployment = '%s_%s' % (env.site, env.dts)
  34. env.root = _join(SRV_ROOT, env.deployment)
  35. env.envdir = _join(env.root, 'env')
  36. env.rollback_cap = env.config.get('rollback_cap', DEFAULT_ROLLBACK_CAP)
  37. # Use the default supervisord include location for the deployment's include file.
  38. env.supd_conf_file = '/etc/supervisor/conf.d/%s.conf' % env.deployment
  39. # Set up gunicorn config
  40. env.default_bind = silk.lib.GUNICORN_BIND_PATTERN % env.deployment
  41. if 'gunicorn' in env.config:
  42. env.config['bind'] = env.config['gunicorn'].get('bind', env.default_bind)
  43. else:
  44. env.config['bind'] = env.default_bind
  45. _set_vars()
  46. # UGLY MAGIC
  47. # Here we're (ab)using Fabric's built in 'role' feature to work with the way
  48. # we're loading context-specific config. Could possibly use a refactor to
  49. # avoid Fabric roles altogether.
  50. def _get_hosts():
  51. """Return list of hosts to push to"""
  52. return env.config['push_hosts']
  53. for role in silk.lib.get_role_list(env.local_root):
  54. env.roledefs[role] = _get_hosts
  55. # END UGLY MAGIC
  56. def _tmpfile():
  57. """Generates a random filename in /tmp. Useful for dumping stdout to a
  58. file that you want to download or read later. Assumes the remote host has
  59. a /tmp directory."""
  60. chars = "abcdefghijklmnopqrstuvwxyz1234567890"
  61. length = 20
  62. randompart = "".join([random.choice(chars) for x in xrange(20)])
  63. return "/tmp/silk_tmp_%s" % randompart
  64. def _put_dir(local_dir, remote_dir, exclude=''):
  65. """
  66. Copies a local directory to a remote one, using tar and put. Silently
  67. overwrites remote directory if it exists, creates it if it does not
  68. exist.
  69. """
  70. tarball = "%s.tar.bz2" % _tmpfile()
  71. tar_cmd = 'tar -C "%(local_dir)s" -cjf "%(tarball)s" %(exclude)s .' % locals()
  72. local(tar_cmd)
  73. put(tarball, tarball, use_sudo=True)
  74. local('rm -f "%(tarball)s"' % locals())
  75. sudo('rm -Rf "{0}"; mkdir -p "{0}"; tar -C "{0}" -xjf "{1}" && rm -f "{1}"'\
  76. .format(remote_dir, tarball))
  77. def _get_blame():
  78. """
  79. Return information about this deployment, to be written as the "blame"
  80. section in the site.yaml file.
  81. """
  82. return {'deployed_by': env.user,
  83. 'deployed_from': os.uname()[1],
  84. 'deployed_at': datetime.datetime.now(),
  85. 'deployed_role': env.roles[0]}
  86. def _write_file(path, contents, use_sudo=True, chown=None):
  87. file_name = _tmpfile()
  88. file = open(file_name, 'w')
  89. file.write(contents)
  90. file.close()
  91. put(file_name, path, use_sudo=use_sudo)
  92. sudo('chmod +r %s' % path)
  93. if chown:
  94. sudo('chown %s %s' % (chown, path))
  95. local('rm %s' % file_name)
  96. def _write_site_yaml():
  97. """Writes the site.yaml file for the deployed site."""
  98. # Make a copy of env.config
  99. site_yaml_dict = copy.copy(env.config)
  100. # add the blame in
  101. site_yaml_dict['blame'] = _get_blame()
  102. # write it to the remote host
  103. file = _join(env.root, 'site.yaml')
  104. _write_file(file, yaml.safe_dump(site_yaml_dict, default_flow_style=False))
  105. def _write_template(template, dest, context):
  106. path = silk.lib.get_template_path(template, env.local_root)
  107. upload_template(path, dest, context=context, use_sudo=True)
  108. def _ensure_dir(remote_path):
  109. if not exists(remote_path, use_sudo=True):
  110. sudo('mkdir -p %s' % remote_path)
  111. def _format_supervisord_env(env_dict):
  112. """Takes a dictionary and returns a string in form
  113. 'key=val,key2=val2'"""
  114. try:
  115. return ','.join(['%s="%s"' % (key, env_dict[key]) for key in env_dict.keys()])
  116. except AttributeError:
  117. #env_dict isn't a dictionary, so they must not have included any env vars for us.
  118. return ''
  119. def _green(text):
  120. print green(text)
  121. def _red(text):
  122. print red(text)
  123. def _yellow(text):
  124. print yellow(text)
  125. def _list_dir(dirname):
  126. """Given the path for a directory on the remote host, return its contents
  127. as a python list."""
  128. txt = sudo('ls -1 %s' % dirname)
  129. return txt.split('\r\n')
  130. def _is_supervisored(site):
  131. """Returns True if site 'site' has a supervisord.conf entry, else False."""
  132. # Check both the default include location and the silk <=0.2.9 location.
  133. old_conf_file = _join(SRV_ROOT, site, 'conf', 'supervisord.conf')
  134. new_conf_file = _join('/etc/supervisor/conf.d', '%s.conf' % site)
  135. return exists(new_conf_file) or exists(old_conf_file)
  136. def _socket_head_request(path):
  137. """Given a path to a socket, make an HTTP HEAD request on it"""
  138. # Get the local path for the unix socket http tool
  139. script = pkg_resources.resource_filename('silk', 'sock_http.py')
  140. # copy it over to the host
  141. dest = _tmpfile()
  142. put(script, dest, use_sudo=True)
  143. # run it, passing in the path to the socket
  144. return sudo('python %s %s HEAD / %s' % (dest, path,
  145. env.config['listen_hosts'][0]))
  146. def _port_head_request(port):
  147. """Given a port number, use curl to make an http HEAD request to it.
  148. Return response status as integer."""
  149. return run('curl -I http://localhost:%s' % port)
  150. def returns_good_status():
  151. """Makes an HTTP request to '/' on the site and returns True if it gets
  152. back a status in the 200, 300, or 400 ranges.
  153. You can only use this as a standalone silk command if your site has a
  154. hard-configured 'bind'."""
  155. _yellow("Making http request to site to ensure upness.")
  156. bind = env.config['bind']
  157. if bind.startswith('unix:'):
  158. result = _socket_head_request(bind.replace('unix:', ''))
  159. else:
  160. result = _port_head_request(bind.split(':')[-1])
  161. first_line = result.split('\r\n')[0]
  162. status = int(first_line.split()[1])
  163. return 200 <= status < 500
  164. def _is_running(procname, tries=3, wait=2):
  165. """Given the name of a supervisord process, tell you whether it's running
  166. or not. If status is 'starting', will wait until status has settled."""
  167. # Status return from supervisorctl will look something like this::
  168. # mysite_20110623_162319 RUNNING pid 628, uptime 0:34:13
  169. # mysite_20110623_231206 FATAL Exited too quickly (process log may have details)
  170. status_parts = sudo('supervisorctl status %s' % procname).split()
  171. if status_parts[1] == 'RUNNING':
  172. # For super extra credit, we're actually going to make an HTTP request
  173. # to the site to verify that it's up and running. Unfortunately we
  174. # can only do that if Gunicorn is binding to a different socket or port
  175. # on each deployment (Silk binds to a new socket on each deployment by
  176. # default.) If you've configured your site to bind to a port, we'll
  177. # just have to wing it.
  178. if env.config['bind'] == env.default_bind:
  179. if returns_good_status():
  180. _green("You're golden!")
  181. return True
  182. else:
  183. _red(":(")
  184. return False
  185. else:
  186. return True
  187. elif status_parts[1] == "FATAL":
  188. return False
  189. elif tries > 0:
  190. # It's neither running nor dead yet, so try again
  191. _yellow("Waiting %s seconds for process to settle" % wait)
  192. time.sleep(wait)
  193. # decrement the tries and double the wait time for the next check.
  194. return _is_running(procname, tries - 1, wait * 2)
  195. else:
  196. return False
  197. def _is_this_site(name):
  198. """Return True if 'name' matches our site name (old style Silk deployment
  199. naming' or matches our name + timestamp pattern."""
  200. site_pattern = re.compile('%s_\d{8}_\d{6}' % env.site)
  201. return (name == env.site) or (re.match(site_pattern, name) is not None)
  202. def install_server_deps():
  203. """
  204. Installs nginx and supervisord on remote Ubuntu host.
  205. """
  206. sudo('apt-get install nginx supervisor --assume-yes --quiet --no-upgrade')
  207. def push_code():
  208. _green("PUSHING CODE")
  209. # Push the local site to the remote root, excluding files that we don't
  210. # want to leave cluttering the production server. Exclude site.yaml
  211. # because we'll be writing a new one containing the site config updated
  212. # with the role config. Omit roles because they're superfluous at that
  213. # point and also may contain sensitive connection credentials.
  214. exclude = "--exclude=site.yaml --exclude=roles"
  215. _put_dir(env.local_root, env.root, exclude)
  216. def create_virtualenv():
  217. """Create a virtualenv inside the remote root"""
  218. if 'runtime' in env.config:
  219. pyversion = '--python=%s' % env.config['runtime']
  220. else:
  221. pyversion = ''
  222. # Put all the prereq packages into /srv/pip_cache on the remote host, if
  223. # they're not already there.
  224. local_dir = pkg_resources.resource_filename('silk', 'prereqs')
  225. files = pkg_resources.resource_listdir('silk', 'prereqs')
  226. for f in files:
  227. remote = posixpath.join('/tmp', f)
  228. local = posixpath.join(local_dir, f)
  229. if not exists(remote):
  230. put(local, remote, use_sudo=True)
  231. tmpl_vars = vars()
  232. tmpl_vars.update(env)
  233. c = ("virtualenv --no-site-packages %(pyversion)s --extra-search-dir=/tmp "
  234. "--never-download %(envdir)s") % tmpl_vars
  235. sudo(c)
  236. def pip_deps():
  237. """Install requirements listed in the site's requirements.txt file."""
  238. _green("INSTALLING PYTHON DEPENDENCIES")
  239. reqs_file = os.path.join(env.root, 'requirements.txt')
  240. pypi = env.config.get('pypi', 'http://pypi.python.org/pypi')
  241. cachedir = posixpath.join(SRV_ROOT, 'pip_cache')
  242. _ensure_dir(cachedir)
  243. sudo('PIP_DOWNLOAD_CACHE="%s" %s/bin/pip install -r %s -i %s ' %
  244. (cachedir, env.envdir, reqs_file, pypi))
  245. def configure_supervisor():
  246. """
  247. Creates and upload config file for supervisord
  248. """
  249. _green("WRITING SUPERVISOR CONFIG")
  250. template_vars = {
  251. 'cmd': silk.lib.get_gunicorn_cmd(env, bin_dir='%s/bin' % (env.envdir)),
  252. 'process_env': _format_supervisord_env(env.config.get('env', '')),
  253. 'srv_root': SRV_ROOT,
  254. }
  255. template_vars.update(env)
  256. template_vars.update(env.config)
  257. #make sure the logs dir is created
  258. _ensure_dir(_join(env.root, 'logs'))
  259. # Put supervisord include in default location
  260. _write_template('supervisord.conf', env.supd_conf_file, template_vars)
  261. def fix_supd_config_bug():
  262. """Fixes a bug from an earlier version of Silk that wrote an invalid line
  263. to the master supervisord.conf"""
  264. # Silk 0.2.9 and earlier included a command to configure supervisord and
  265. # nginx to include files in /srv/<site>/conf, in addition to their default
  266. # include directories. While this was valid for nginx, supervisord does
  267. # not allow for multiple "files" lines in its "include" section (it does
  268. # allow for multiple globs on the single "files" line, though. This command
  269. # finds the offending pattern in /etc/supervisor/supervisord.conf and
  270. # replaces it with the correct equivalent.
  271. # Note that Silk 0.3.0 and later does not require any changes from the
  272. # default supervisord.conf that ships with Ubuntu. All files are included
  273. # in the supervisord's conf.d directory.
  274. file = '/etc/supervisor/supervisord.conf'
  275. if contains(file, "files = /srv/\*/conf/supervisord.conf", use_sudo=True):
  276. _green("FIXING OLD SUPERVISOR CONFIG BUG")
  277. _yellow("See http://bits.btubbs.com/silk-deployment/issue/15/incorrect-supervisord-include-config-in")
  278. bad = "\r\n".join([
  279. "files = /etc/supervisor/conf.d/*.conf",
  280. "files = /srv/*/conf/supervisord.conf"
  281. ])
  282. good = ("files = /etc/supervisor/conf.d/*.conf "
  283. "/srv/*/conf/supervisord.conf\n")
  284. txt = sudo('cat %s' % file)
  285. if bad in txt:
  286. txt = txt.replace(bad, good)
  287. _write_file(file, txt, use_sudo=True, chown='root')
  288. def cleanup():
  289. """Deletes old versions of the site that are still sitting around."""
  290. _green("CLEANING UP")
  291. folders = _list_dir(SRV_ROOT)
  292. rollbacks = [x for x in folders if _is_this_site(x)]
  293. if len(rollbacks) > env.rollback_cap:
  294. # There are more rollbacks than we want to keep around. See if we can
  295. # delete some.
  296. suspects = rollbacks[:-(env.rollback_cap + 1)]
  297. for folder in suspects:
  298. if not _is_supervisored(folder):
  299. fullpath = _join(SRV_ROOT, folder)
  300. sudo('rm -rf %s' % fullpath)
  301. # Clean up old socket files in /tmp/ that have no associated site
  302. # TODO: use our own list dir function and a regular expression to filter
  303. # the list of /tmp sockets instead of this funky grepping.
  304. with cd('/tmp'):
  305. socks = run('ls -1 | grep %s | grep sock | cat -' % env.site).split('\r\n')
  306. for sock in socks:
  307. procname = sock.replace('.sock', '')
  308. if not exists(_join(SRV_ROOT, procname)):
  309. sudo('rm /tmp/%s' % sock)
  310. # TODO: clean out the pip-* folders that can build up in /tmp
  311. # TODO: figure out a way to clean out pybundle files in /srv/_silk_build
  312. # that aren't needed anymore.
  313. def start_process():
  314. """Tell supervisord to read the new config, then start the new process."""
  315. _green('STARTING PROCESS')
  316. result = sudo('supervisorctl reread')
  317. sudo('supervisorctl add %s' % env.deployment)
  318. def _get_nginx_static_snippet(url_path, local_path):
  319. return """
  320. location %(url_path)s {
  321. alias %(local_path)s;
  322. }
  323. """ % locals()
  324. def configure_nginx():
  325. """Writes a new nginx config include pointing at the newly-deployed site."""
  326. _green("WRITING NGINX CONFIG")
  327. nginx_static = ''
  328. static_dirs = env.config.get('static_dirs', None)
  329. # Use the static_dirs values from the site config to set up static file
  330. # serving in nginx.
  331. if static_dirs:
  332. for item in static_dirs:
  333. nginx_static += _get_nginx_static_snippet(
  334. item['url_path'],
  335. _join(env.root, item['system_path'])
  336. )
  337. template_vars = {
  338. 'nginx_static': nginx_static,
  339. 'nginx_hosts': ' '.join(env.config['listen_hosts']),
  340. }
  341. template_vars.update(env)
  342. template_vars.update(env.config)
  343. # Create nginx include here:
  344. # /etc/nginx/sites-enabled/<sitename>.conf
  345. nginx_file = _join('/etc', 'nginx', 'sites-enabled', env.site)
  346. sudo('rm -f %s' % nginx_file)
  347. _write_template('nginx.conf', nginx_file, template_vars)
  348. def switch_nginx():
  349. _green("LOADING NEW NGINX CONFIG")
  350. # Check if there is an old-style version (within the site root) of the
  351. # nginx config laying around, and rename it to something innocuous if so.
  352. old_nginx = _join(SRV_ROOT, env.site, 'conf', 'nginx.conf')
  353. if exists(old_nginx):
  354. sudo('mv %s %s' % (old_nginx, "%s_disabled" % old_nginx))
  355. # Tell nginx to rescan its config files
  356. sudo('/etc/init.d/nginx reload')
  357. def stop_other_versions():
  358. """Stop other versions of the site that are still running, and disable their
  359. configs."""
  360. proclist = sudo('supervisorctl status').split('\r\n')
  361. # parse each line so we can get at just the proc names
  362. proclist = [x.split() for x in proclist]
  363. # filter proclist to include only versions of our site
  364. proclist = [x for x in proclist if _is_this_site(x[0])]
  365. live_statuses = ["RUNNING", "STARTING"]
  366. # stop each process left in proclist that isn't the current one
  367. for proc in proclist:
  368. # We assume that spaces are not allowed in proc names
  369. procname = proc[0]
  370. procstatus = proc[1]
  371. if procname != env.deployment:
  372. # Stop the process
  373. if procstatus in live_statuses:
  374. sudo('supervisorctl stop %s' % procname)
  375. # Remove it from live config
  376. sudo('supervisorctl remove %s' % procname)
  377. # Remove its supervisord config file
  378. conf_file = '/etc/supervisor/conf.d/%s.conf' % procname
  379. if exists(conf_file):
  380. sudo('rm %s' % conf_file)
  381. # Also remove old style supervisord include if it exists
  382. old_conf_file = _join(SRV_ROOT, procname, 'conf/supervisord.conf')
  383. if exists(old_conf_file):
  384. sudo('rm %s' % old_conf_file)
  385. sudo('supervisorctl reread')
  386. def congrats():
  387. """Congratulate the user and print a link to the site."""
  388. link0 = "http://%s" % env.config['listen_hosts'][0]
  389. msg = ("SUCCESS! I think. Check that the site is running by browsing "
  390. "to this url:\n\n%s" % link0)
  391. _green(msg)
  392. def push():
  393. """
  394. The main function. This function will put your site on the remote host and get it
  395. running.
  396. """
  397. # Make sure nginx and supervisord are installed
  398. install_server_deps()
  399. # Fix an embarrassing config bug from earlier versions
  400. fix_supd_config_bug()
  401. # push site code and pybundle to server, in timestamped folder
  402. push_code()
  403. # make virtualenv on the server and run pip install on the pybundle
  404. create_virtualenv()
  405. pip_deps()
  406. _write_site_yaml()
  407. # write supervisord config for the new site
  408. configure_supervisor()
  409. ##then the magic
  410. ##silk starts up supervisord on the new site
  411. start_process()
  412. # checks that the new site is running (by using supervisorctl)
  413. if _is_running(env.deployment):
  414. _green("Site is running. Proceeding with nginx switch.")
  415. # if the site's running fine, then silk configures nginx to forward requests
  416. # to the new site
  417. configure_nginx()
  418. switch_nginx()
  419. stop_other_versions()
  420. cleanup()
  421. congrats()
  422. else:
  423. _red("Process failed to start cleanly. Off to the log files!")
  424. sys.exit(1)