#!/usr/bin/env python # # auto-deploy script for https://develop.element.io # # Listens for buildkite webhook pokes (https://buildkite.com/docs/apis/webhooks) # When it gets one, downloads the artifact from buildkite # and deploys it as the new version. # # Requires the following python packages: # # - requests # - flask # from __future__ import print_function import requests, argparse, os, errno import time import traceback import glob import re import shutil import threading from Queue import Queue from flask import Flask, jsonify, request, abort from deploy import Deployer, DeployException app = Flask(__name__) deployer = None arg_extract_path = None arg_webhook_token = None arg_api_token = None workQueue = Queue() def req_headers(): return { "Authorization": "Bearer %s" % (arg_api_token,), } # Buildkite considers a poke to have failed if it has to wait more than 10s for # data (any data, not just the initial response) and it normally takes longer than # that to download an artifact from buildkite. Apparently there is no way in flask # to finish the response and then keep doing stuff, so instead this has to involve # threading. Sigh. def worker_thread(): while True: toDeploy = workQueue.get() deploy_buildkite_artifact(*toDeploy) @app.route("/", methods=["POST"]) def on_receive_buildkite_poke(): got_webhook_token = request.headers.get('X-Buildkite-Token') if got_webhook_token != arg_webbook_token: print("Denying request with incorrect webhook token: %s" % (got_webhook_token,)) abort(400, "Incorrect webhook token") return required_api_prefix = None if arg_buildkite_org is not None: required_api_prefix = 'https://api.buildkite.com/v2/organizations/%s' % (arg_buildkite_org,) incoming_json = request.get_json() if not incoming_json: abort(400, "No JSON provided!") return print("Incoming JSON: %s" % (incoming_json,)) event = incoming_json.get("event") if event is None: abort(400, "No 'event' specified") return if event == 'ping': print("Got ping request - responding") return jsonify({'response': 'pong!'}) if event != 'build.finished': print("Rejecting '%s' event") abort(400, "Unrecognised event") return build_obj = incoming_json.get("build") if build_obj is None: abort(400, "No 'build' object") return build_url = build_obj.get('url') if build_url is None: abort(400, "build has no url") return if required_api_prefix is not None and not build_url.startswith(required_api_prefix): print("Denying poke for build url with incorrect prefix: %s" % (build_url,)) abort(400, "Invalid build url") return build_num = build_obj.get('number') if build_num is None: abort(400, "build has no number") return pipeline_obj = incoming_json.get("pipeline") if pipeline_obj is None: abort(400, "No 'pipeline' object") return pipeline_name = pipeline_obj.get('name') if pipeline_name is None: abort(400, "pipeline has no name") return artifacts_url = build_url + "/artifacts" artifacts_resp = requests.get(artifacts_url, headers=req_headers()) artifacts_resp.raise_for_status() artifacts_array = artifacts_resp.json() artifact_to_deploy = None for artifact in artifacts_array: if re.match(r"dist/.*.tar.gz", artifact['path']): artifact_to_deploy = artifact if artifact_to_deploy is None: print("No suitable artifacts found") return jsonify({}) # double paranoia check: make sure the artifact is on the right org too if required_api_prefix is not None and not artifact_to_deploy['url'].startswith(required_api_prefix): print("Denying poke for build url with incorrect prefix: %s" % (artifact_to_deploy['url'],)) abort(400, "Refusing to deploy artifact from URL %s", artifact_to_deploy['url']) return # there's no point building up a queue of things to deploy, so if there are any pending jobs, # remove them while not workQueue.empty(): try: workQueue.get(False) except: pass workQueue.put([artifact_to_deploy, pipeline_name, build_num]) return jsonify({}) def deploy_buildkite_artifact(artifact, pipeline_name, build_num): artifact_response = requests.get(artifact['url'], headers=req_headers()) artifact_response.raise_for_status() artifact_obj = artifact_response.json() # we extract into a directory based on the build number. This avoids the # problem of multiple builds building the same git version and thus having # the same tarball name. That would lead to two potential problems: # (a) sometimes jenkins serves corrupted artifacts; we would replace # a good deploy with a bad one # (b) we'll be overwriting the live deployment, which means people might # see half-written files. build_dir = os.path.join(arg_extract_path, "%s-#%s" % (pipeline_name, build_num)) try: extracted_dir = deploy_tarball(artifact_obj, build_dir) except DeployException as e: traceback.print_exc() abort(400, e.message) def deploy_tarball(artifact, build_dir): """Download a tarball from jenkins and unpack it Returns: (str) the path to the unpacked deployment """ if os.path.exists(build_dir): raise DeployException( "Not deploying. We have previously deployed this build." ) os.mkdir(build_dir) print("Fetching artifact %s -> %s..." % (artifact['download_url'], artifact['filename'])) # Download the tarball here as buildkite needs auth to do this # we don't pgp-sign buildkite artifacts, relying on HTTPS and buildkite # not being evil. If that's not good enough for you, don't use develop.element.io. resp = requests.get(artifact['download_url'], stream=True, headers=req_headers()) resp.raise_for_status() with open(artifact['filename'], 'wb') as ofp: shutil.copyfileobj(resp.raw, ofp) print("...download complete. Deploying...") # we rely on the fact that flask only serves one request at a time to # ensure that we do not overwrite a tarball from a concurrent request. return deployer.deploy(artifact['filename'], build_dir) if __name__ == "__main__": parser = argparse.ArgumentParser("Runs a Vector redeployment server.") parser.add_argument( "-p", "--port", dest="port", default=4000, type=int, help=( "The port to listen on for requests from Jenkins." ) ) parser.add_argument( "-e", "--extract", dest="extract", default="./extracted", help=( "The location to extract .tar.gz files to." ) ) parser.add_argument( "-b", "--bundles-dir", dest="bundles_dir", help=( "A directory to move the contents of the 'bundles' directory to. A \ symlink to the bundles directory will also be written inside the \ extracted tarball. Example: './bundles'." ) ) parser.add_argument( "-c", "--clean", dest="clean", action="store_true", default=False, help=( "Remove .tar.gz files after they have been downloaded and extracted." ) ) parser.add_argument( "-s", "--symlink", dest="symlink", default="./latest", help=( "Write a symlink to this location pointing to the extracted tarball. \ New builds will keep overwriting this symlink. The symlink will point \ to the /vector directory INSIDE the tarball." ) ) # --include ../../config.json ./localhost.json homepages/* parser.add_argument( "--include", nargs='*', default='./config*.json', help=( "Symlink these files into the root of the deployed tarball. \ Useful for config files and home pages. Supports glob syntax. \ (Default: '%(default)s')" ) ) parser.add_argument( "--test", dest="tarball_uri", help=( "Don't start an HTTP listener. Instead download a build from Jenkins \ immediately." ), ) parser.add_argument( "--webhook-token", dest="webhook_token", help=( "Only accept pokes with this buildkite token." ), required=True, ) parser.add_argument( "--api-token", dest="api_token", help=( "API access token for buildkite. Require read_artifacts scope." ), required=True, ) # We require a matching webhook token, but because we take everything else # about what to deploy from the poke body, we can be a little more paranoid # and only accept builds / artifacts from a specific buildkite org parser.add_argument( "--org", dest="buildkite_org", help=( "Lock down to this buildkite org" ) ) args = parser.parse_args() arg_extract_path = args.extract arg_webbook_token = args.webhook_token arg_api_token = args.api_token arg_buildkite_org = args.buildkite_org if not os.path.isdir(arg_extract_path): os.mkdir(arg_extract_path) deployer = Deployer() deployer.bundles_path = args.bundles_dir deployer.should_clean = args.clean deployer.symlink_latest = args.symlink for include in args.include: deployer.symlink_paths.update({ os.path.basename(pth): pth for pth in glob.iglob(include) }) if args.tarball_uri is not None: build_dir = os.path.join(arg_extract_path, "test-%i" % (time.time())) deploy_tarball(args.tarball_uri, build_dir) else: print( "Listening on port %s. Extracting to %s%s. Symlinking to %s. Include files: %s" % (args.port, arg_extract_path, " (clean after)" if deployer.should_clean else "", args.symlink, deployer.symlink_paths, ) ) fred = threading.Thread(target=worker_thread) fred.daemon = True fred.start() app.run(port=args.port, debug=False)