matrix-bot/scripts/redeploy.py

281 lines
9.4 KiB
Python
Raw Normal View History

#!/usr/bin/env python
#
# auto-deploy script for https://riot.im/develop
#
2019-04-26 16:26:03 +02:00
# Listens for HTTP hits. 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
2016-02-03 13:00:17 +01:00
import json, requests, tarfile, argparse, os, errno
import time
2017-07-20 12:25:19 +02:00
import traceback
from urlparse import urljoin
import glob
2019-04-26 16:26:03 +02:00
import re
import shutil
from flask import Flask, jsonify, request, abort
from deploy import Deployer, DeployException
app = Flask(__name__)
deployer = None
arg_extract_path = None
arg_symlink = None
2019-04-26 16:26:03 +02:00
arg_webbook_token = None
arg_api_token = None
2016-02-03 13:00:17 +01:00
def create_symlink(source, linkname):
try:
os.symlink(source, linkname)
except OSError, e:
if e.errno == errno.EEXIST:
# atomic modification
os.symlink(source, linkname + ".tmp")
os.rename(linkname + ".tmp", linkname)
else:
raise e
2019-04-26 16:26:03 +02:00
def req_headers():
return {
"Authorization": "Bearer %s" % (arg_api_token,),
}
@app.route("/", methods=["POST"])
2019-04-26 16:26:03 +02:00
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_buildkit_org is not None:
required_api_prefix = 'https://api.buildkite.com/v2/organizations/%s' % (arg_buildkit_org,)
incoming_json = request.get_json()
if not incoming_json:
abort(400, "No JSON provided!")
return
print("Incoming JSON: %s" % (incoming_json,))
2019-04-26 16:26:03 +02:00
event = incoming_json.get("event")
if event is None:
abort(400, "No 'event' specified")
return
2019-04-26 16:26:03 +02:00
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
2019-04-26 16:26:03 +02:00
build_obj = incoming_json.get("build")
if build_obj is None:
abort(400, "No 'build' object")
return
2019-04-26 16:26:03 +02:00
build_url = build_obj.get('url')
if build_url is None:
abort(400, "build has no url")
return
2019-04-26 16:26:03 +02:00
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
2019-04-26 16:26:03 +02:00
build_num = build_obj.get('number')
if build_num is None:
abort(400, "build has no number")
return
2019-04-26 16:26:03 +02:00
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()
for artifact in artifacts_array:
artifact_to_deploy = None
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
return deploy_buildkite_artifact(artifact_to_deploy, pipeline_name, build_num)
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.
2019-04-26 16:26:03 +02:00
build_dir = os.path.join(arg_extract_path, "%s-#%s" % (pipeline_name, build_num))
try:
2019-04-26 16:26:03 +02:00
extracted_dir = deploy_tarball(artifact_obj, build_dir)
except DeployException as e:
2017-07-20 12:25:19 +02:00
traceback.print_exc()
abort(400, e.message)
create_symlink(source=extracted_dir, linkname=arg_symlink)
return jsonify({})
2019-04-26 16:26:03 +02:00
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)
2019-04-26 16:26:03 +02:00
# 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 riot.im/develop.
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)
# 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.
2016-09-16 19:15:15 +02:00
2019-04-26 16:26:03 +02:00
return deployer.deploy(artifact['filename'], build_dir)
if __name__ == "__main__":
parser = argparse.ArgumentParser("Runs a Vector redeployment server.")
parser.add_argument(
2016-02-03 14:11:51 +01:00
"-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."
)
)
2016-02-03 13:00:17 +01:00
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/*
2016-09-16 19:26:46 +02:00
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')"
2016-09-16 19:26:46 +02:00
)
)
parser.add_argument(
"--test", dest="tarball_uri", help=(
"Don't start an HTTP listener. Instead download a build from Jenkins \
immediately."
),
)
2019-04-26 16:26:03 +02:00
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="buildkit_org", help=(
"Lock down to this buildkite org"
)
)
args = parser.parse_args()
arg_extract_path = args.extract
2016-02-03 13:00:17 +01:00
arg_symlink = args.symlink
2019-04-26 16:26:03 +02:00
arg_webbook_token = args.webhook_token
arg_api_token = args.api_token
arg_buildkit_org = args.buildkit_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
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(
2019-04-26 16:26:03 +02:00
"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 "",
arg_symlink,
deployer.symlink_paths,
)
)
2019-04-26 16:26:03 +02:00
app.run(port=args.port, debug=False)