diff options
author | Sam Thursfield <sam.thursfield@codethink.co.uk> | 2015-06-16 15:46:17 +0100 |
---|---|---|
committer | Sam Thursfield <sam.thursfield@codethink.co.uk> | 2015-06-16 15:46:17 +0100 |
commit | dcb42d0a201ef560538aa1ed2014d1b7c4eb6224 (patch) | |
tree | 1443517e863ddde3277f85ffdc8f68b40f3b80fd | |
parent | 9d91283fc684ea16358fd5f4bbc88ac4a2368630 (diff) | |
download | morph-cache-server-dcb42d0a201ef560538aa1ed2014d1b7c4eb6224.tar.gz |
Split API out of the main morph-cache-server script
-rwxr-xr-x | morph-cache-server | 367 | ||||
-rw-r--r-- | morphcacheserver/__init__.py | 3 | ||||
-rw-r--r-- | morphcacheserver/api_1_0.py | 342 | ||||
-rw-r--r-- | morphcacheserver/api_2_0.py | 81 |
4 files changed, 434 insertions, 359 deletions
diff --git a/morph-cache-server b/morph-cache-server index 5fe6b0f..053d9e8 100755 --- a/morph-cache-server +++ b/morph-cache-server @@ -15,20 +15,17 @@ # with this program. If not, see <http://www.gnu.org/licenses/>. -from bottle import Bottle, request, response, run, static_file, template +from bottle import Bottle, run import cliapp from flup.server.fcgi import WSGIServer -import base64 import hashlib -import json -import logging import shutil import os import urllib import urllib2 -import morphcacheserver.frontend +import morphcacheserver from morphcacheserver.artifact_database import ArtifactDatabase from morphcacheserver.repocache import RepoCache @@ -42,76 +39,6 @@ defaults = { } -def checksum(filename, hasher): - with open(filename,'rb') as f: - for chunk in iter(lambda: f.read(128 * hasher.block_size), b''): - hasher.update(chunk) - return hasher.digest() - - -def _fetch_artifact(self, url, filename): - '''Fetch a single artifact for /fetch.''' - in_fh = None - try: - in_fh = urllib2.urlopen(url) - with open(filename, "w") as localtmp: - shutil.copyfileobj(in_fh, localtmp) - in_fh.close() - except Exception, e: - if in_fh is not None: - in_fh.close() - raise - else: - if in_fh is not None: - in_fh.close() - - # FIXME: we could hash the artifact while we download it, instead, for - # a bit of a speed increase. - hash_sha1 = checksum(filename, hashlib.sha1()) - - return os.stat(filename), hash_sha1 - -def _fetch_artifacts(self, db, server, cacheid, artifacts, - builder_name=None, build_datetime=None): - '''Implements the /fetch method, to pull artifacts into the cache. - - This allows any server to overwrite the files in the cache. Be aware!! - - ''' - ret = {} - try: - for artifact in artifacts: - artifact_name = "%s.%s" % (cacheid, artifact) - - tmpname = os.path.join(self.settings['artifact-dir'], - ".dl.%s" % artifact_name) - url = "http://%s/1.0/artifacts?filename=%s" % ( - server, urllib.quote(artifact_name)) - stinfo, hash_sha1 = self._fetch_artifact(url, tmpname) - - db.record_build(artifact_name, builder_name, build_datetime, - hash_sha1) - - ret[artifact_name] = { - "size": stinfo.st_size, - "used": stinfo.st_blocks * 512, - } - except Exception, e: - for artifact in ret.iterkeys(): - os.unlink(os.path.join(self.settings['artifact-dir'], - ".dl.%s" % artifact)) - raise - - for artifact in ret.iterkeys(): - tmpname = os.path.join(self.settings['artifact-dir'], - ".dl.%s" % artifact) - artifilename = os.path.join(self.settings['artifact-dir'], - artifact) - os.rename(tmpname, artifilename) - - return ret - - class MorphCacheServer(cliapp.Application): def add_settings(self): @@ -147,284 +74,6 @@ class MorphCacheServer(cliapp.Application): 'runs a fcgi-server', default=True) - def api_1_0(self, repo_cache, db): - '''The /1.0 set of HTTP methods.''' - app = Bottle() - - def writable(prefix): - """Selectively enable bottle prefixes. - - prefix -- The path prefix we are enabling - - If the runtime configuration setting --enable-writes is provided - then we return the app.get() decorator for the given path prefix - otherwise we return a lambda which passes the function through - undecorated. - - This has the effect of being a runtime-enablable @app.get(...) - - """ - if self.settings['enable-writes']: - return app.get(prefix) - return lambda fn: fn - - @writable('/list') - def list(): - response.set_header('Cache-Control', 'no-cache') - results = {} - files = {} - results["files"] = files - for artifactdir, __, filenames in \ - os.walk(self.settings['artifact-dir']): - fsstinfo = os.statvfs(artifactdir) - results["freespace"] = fsstinfo.f_bsize * fsstinfo.f_bavail - for fname in filenames: - if not fname.startswith(".dl."): - try: - stinfo = os.stat("%s/%s" % (artifactdir, fname)) - files[fname] = { - "atime": stinfo.st_atime, - "size": stinfo.st_size, - "used": stinfo.st_blocks * 512, - } - except Exception, e: - print(e) - return results - - @writable('/fetch') - def fetch(): - host = self._unescape_parameter(request.query.host) - cacheid = self._unescape_parameter(request.query.cacheid) - artifacts = self._unescape_parameter(request.query.artifacts) - - # Optional parameters added for bit-for-bit reproducibility - # checking. - builder_name = self._unescape_parameter( - request.query.get('builder_name')) - build_datetime = self._unescape_parameter( - request.query.get('build_datetime')) - - try: - response.set_header('Cache-Control', 'no-cache') - artifacts = artifacts.split(",") - return self._fetch_artifacts(db, host, cacheid, artifacts, - builder_name=builder_name, - build_datetime=build_datetime) - - except Exception, e: - response.status = 500 - logging.debug('%s' % e) - - @writable('/delete') - def delete(): - artifact = self._unescape_parameter(request.query.artifact) - try: - os.unlink('%s/%s' % (self.settings['artifact-dir'], - artifact)) - return { "status": 0, "reason": "success" } - except OSError, ose: - return { "status": ose.errno, "reason": ose.strerror } - except Exception, e: - response.status = 500 - logging.debug('%s' % e) - - @app.get('/sha1s') - def sha1(): - repo = self._unescape_parameter(request.query.repo) - ref = self._unescape_parameter(request.query.ref) - try: - response.set_header('Cache-Control', 'no-cache') - sha1, tree = repo_cache.resolve_ref(repo, ref) - return { - 'repo': '%s' % repo, - 'ref': '%s' % ref, - 'sha1': '%s' % sha1, - 'tree': '%s' % tree - } - except Exception, e: - response.status = 404 - logging.debug('%s' % e) - - @app.post('/sha1s') - def sha1s(): - result = [] - for pair in request.json: - repo = pair['repo'] - ref = pair['ref'] - try: - sha1, tree = repo_cache.resolve_ref(repo, ref) - result.append({ - 'repo': '%s' % repo, - 'ref': '%s' % ref, - 'sha1': '%s' % sha1, - 'tree': '%s' % tree - }) - except Exception, e: - logging.debug('%s' % e) - result.append({ - 'repo': '%s' % repo, - 'ref': '%s' % ref, - 'error': '%s' % e - }) - response.set_header('Cache-Control', 'no-cache') - response.set_header('Content-Type', 'application/json') - return json.dumps(result) - - @app.get('/files') - def file(): - repo = self._unescape_parameter(request.query.repo) - ref = self._unescape_parameter(request.query.ref) - filename = self._unescape_parameter(request.query.filename) - try: - content = repo_cache.cat_file(repo, ref, filename) - response.set_header('Content-Type', 'application/octet-stream') - return content - except Exception, e: - response.status = 404 - logging.debug('%s' % e) - - @app.post('/files') - def files(): - result = [] - for pair in request.json: - repo = pair['repo'] - ref = pair['ref'] - filename = pair['filename'] - try: - content = repo_cache.cat_file(repo, ref, filename) - result.append({ - 'repo': '%s' % repo, - 'ref': '%s' % ref, - 'filename': '%s' % filename, - 'data': '%s' % base64.b64encode(content), - }) - except Exception, e: - logging.debug('%s' % e) - result.append({ - 'repo': '%s' % repo, - 'ref': '%s' % ref, - 'filename': '%s' % filename, - 'error': '%s' % e - }) - response.set_header('Content-Type', 'application/json') - return json.dumps(result) - - @app.get('/trees') - def tree(): - repo = self._unescape_parameter(request.query.repo) - ref = self._unescape_parameter(request.query.ref) - path = self._unescape_parameter(request.query.path) - try: - tree = repo_cache.ls_tree(repo, ref, path) - return { - 'repo': '%s' % repo, - 'ref': '%s' % ref, - 'tree': tree, - } - except Exception, e: - response.status = 404 - logging.debug('%s' % e) - - @app.get('/bundles') - def bundle(): - repo = self._unescape_parameter(request.query.repo) - filename = repo_cache.get_bundle_filename(repo) - dirname = os.path.dirname(filename) - basename = os.path.basename(filename) - return static_file(basename, root=dirname, download=True) - - @app.get('/artifacts') - def artifact(): - basename = self._unescape_parameter(request.query.filename) - filename = os.path.join(self.settings['artifact-dir'], basename) - if os.path.exists(filename): - return static_file(basename, - root=self.settings['artifact-dir'], - download=True) - else: - response.status = 404 - logging.debug('artifact %s does not exist' % basename) - - @app.post('/artifacts') - def post_artifacts(): - if request.content_type != 'application/json': - logging.warning('Content-type is not json: ' - 'expecting a json post request') - - artifacts = json.load(request.body) - results = {} - - logging.debug('Received a POST request for /artifacts') - - for artifact in artifacts: - if artifact.startswith('/'): - response.status = 500 - logging.error("%s: artifact name cannot start with a '/'" - % artifact) - return - - filename = os.path.join(self.settings['artifact-dir'], - artifact) - results[artifact] = os.path.exists(filename) - - if results[artifact]: - logging.debug('%s is in the cache', artifact) - else: - logging.debug('%s is NOT in the cache', artifact) - - return results - - def api_2_0(self, db): - '''The 2.0/ set of HTTP methods.''' - app = Bottle() - - @app.put('/builds') - def put_build(): - '''Record a build. - - Expected parameters: - - - cache_name: artifact cache key plus name - - builder_name: URL identifying the build worker - - build_datetime: time artifact build was started - - hash_sha1: SHA1 of built artifact - - source_repo: (optional): repo this is built from - - source_ref: (optional): ref this is built from - - ''' - cache_name = self._unescape_parameter(request.query.cache_name) - builder_name = self._unescape_parameter(request.query.builder_name) - build_datetime = self._unescape_parameter( - request.query.build_datetime) - hash_sha1 = self._unescape_parameter(request.query.hash_sha1) - - source_repo = self._unescape_parameter( - request.query.get('source_repo')) - source_ref = self._unescape_parameter( - request.query.get('source_ref')) - - db.record_build(cache_name, builder_name, build_datetime, - hash_sha1, source_repo, source_ref) - - @app.get('/builds') - def get_builds(): - '''Return info on all known builds.''' - return {'builds': db.view_artifact_statistics()} - - # This may not be the right name for this method, as its name is - # 'builds' but it takes the cache name of an artifact, not a build. - @app.get('/builds/<cache_name>') - def get_builds_for_artifact(cache_name): - '''Return info on builds of a given artifact.''' - results = sorted(db.iter_builds_for_artifact_file(cache_name)) - - if len(results) == 0: - response.status = 404 - else: - return {cache_name: results} - - return app - def process_args(self, args): repo_cache = RepoCache(self, self.settings['repo-dir'], @@ -433,9 +82,12 @@ class MorphCacheServer(cliapp.Application): db = ArtifactDatabase(self.settings['database-file']) - api_1_0 = self.api_1_0(repo_cache, db) - api_2_0 = self.api_2_0(db) - web_frontend = morphcacheserver.frontend.web_frontend(db) + api_1_0 = morphcacheserver.api_1_0( + repo_cache, db, + artifact_dir=self.settings['artifact-dir'], + enable_writes=self.settings['enable-writes']) + api_2_0 = morphcacheserver.api_2_0(db) + web_frontend = morphcacheserver.web_frontend(db) root = Bottle() root.merge(web_frontend) @@ -468,9 +120,6 @@ class MorphCacheServer(cliapp.Application): run(root, host='0.0.0.0', port=self.settings['port'], reloader=True) - def _unescape_parameter(self, param): - return urllib.unquote(param) - if __name__ == '__main__': MorphCacheServer().run() diff --git a/morphcacheserver/__init__.py b/morphcacheserver/__init__.py index c646c1a..36e7ff5 100644 --- a/morphcacheserver/__init__.py +++ b/morphcacheserver/__init__.py @@ -13,4 +13,7 @@ # with this program. If not, see <http://www.gnu.org/licenses/>. +from api_1_0 import api_1_0 +from api_2_0 import api_2_0 +from frontend import web_frontend import repocache diff --git a/morphcacheserver/api_1_0.py b/morphcacheserver/api_1_0.py new file mode 100644 index 0000000..0d3d1d8 --- /dev/null +++ b/morphcacheserver/api_1_0.py @@ -0,0 +1,342 @@ +# Copyright (C) 2013, 2014-2015 Codethink Limited +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; version 2 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program. If not, see <http://www.gnu.org/licenses/>. + + +'''The /1.0 set of HTTP methods. + +This API is used by: + + - Morph's 'remoterepocache' module and YBD's 'repos' module, for getting info + about a given commit in Git repo without having to clone the whole repo. The + default provider used is http://git.baserock.org:8080/ + + - Morph's 'remoteartifactcache' module, which downloads prebuilt artifacts + that have already been built by the 'Mason' autobuilders. Public provider is + http://cache.baserock.org:8080/ (although it's not the default). + + - Morph's 'distbuild' module (which distributes builds among multiple workers + at the chunk level) for coordinating the results of the distributed build. + +It should not be changed without testing all of the above things. + +Note that the 'write' API is totally unsecured and allows full access to the +cache. You should leave it disabled, or secure it somehow. + +''' + +from bottle import Bottle, request, response, static_file + +import base64 +import hashlib +import json +import logging +import os +import shutil +import urllib +import urllib2 + + +def unescape_parameter(param): + return urllib.unquote(param) + + +def checksum(filename, hasher): + with open(filename, 'rb') as f: + for chunk in iter(lambda: f.read(128 * hasher.block_size), b''): + hasher.update(chunk) + return hasher.digest() + + +def fetch_artifact(url, filename): + '''Fetch a single artifact for /fetch.''' + in_fh = None + try: + in_fh = urllib2.urlopen(url) + with open(filename, "w") as localtmp: + shutil.copyfileobj(in_fh, localtmp) + in_fh.close() + except Exception, e: + if in_fh is not None: + in_fh.close() + raise + else: + if in_fh is not None: + in_fh.close() + + # FIXME: we could hash the artifact while we download it, instead, for + # a bit of a speed increase. + hash_sha1 = checksum(filename, hashlib.sha1()) + + return os.stat(filename), hash_sha1 + +def fetch_artifacts(db, artifact_dir, server, cacheid, artifacts, + builder_name=None, build_datetime=None): + '''Implements the /fetch method, to pull artifacts into the cache. + + This allows any server to overwrite the files in the cache. Be aware!! + + ''' + ret = {} + try: + for artifact in artifacts: + artifact_name = "%s.%s" % (cacheid, artifact) + + tmpname = os.path.join(artifact_dir, ".dl.%s" % artifact_name) + url = "http://%s/1.0/artifacts?filename=%s" % ( + server, urllib.quote(artifact_name)) + stinfo, hash_sha1 = fetch_artifact(url, tmpname) + + db.record_build(artifact_name, builder_name, build_datetime, + hash_sha1) + + ret[artifact_name] = { + "size": stinfo.st_size, + "used": stinfo.st_blocks * 512, + } + except Exception, e: + for artifact in ret.iterkeys(): + os.unlink(os.path.join(artifact_dir, ".dl.%s" % artifact)) + raise + + for artifact in ret.iterkeys(): + tmpname = os.path.join(artifact_dir, ".dl.%s" % artifact) + artifilename = os.path.join(artifact_dir, artifact) + os.rename(tmpname, artifilename) + + return ret + + +def api_1_0(repo_cache, db, artifact_dir, enable_writes): + app = Bottle() + + def writable(prefix): + """Selectively enable bottle prefixes. + + prefix -- The path prefix we are enabling + + If the runtime configuration setting --enable-writes is provided + then we return the app.get() decorator for the given path prefix + otherwise we return a lambda which passes the function through + undecorated. + + This has the effect of being a runtime-enablable @app.get(...) + + """ + if enable_writes: + return app.get(prefix) + return lambda fn: fn + + @writable('/list') + def list(): + response.set_header('Cache-Control', 'no-cache') + results = {} + files = {} + results["files"] = files + for dirname, __, filenames in \ + os.walk(artifact_dir): + fsstinfo = os.statvfs(dirname) + results["freespace"] = fsstinfo.f_bsize * fsstinfo.f_bavail + for fname in filenames: + if not fname.startswith(".dl."): + try: + stinfo = os.stat("%s/%s" % (dirname, fname)) + files[fname] = { + "atime": stinfo.st_atime, + "size": stinfo.st_size, + "used": stinfo.st_blocks * 512, + } + except Exception, e: + print(e) + return results + + @writable('/fetch') + def fetch(): + host = unescape_parameter(request.query.host) + cacheid = unescape_parameter(request.query.cacheid) + artifacts = unescape_parameter(request.query.artifacts) + + # Optional parameters added for bit-for-bit reproducibility + # checking. + builder_name = unescape_parameter( + request.query.get('builder_name')) + build_datetime = unescape_parameter( + request.query.get('build_datetime')) + + try: + response.set_header('Cache-Control', 'no-cache') + artifacts = artifacts.split(",") + return fetch_artifacts(db, artifact_dir, host, cacheid, artifacts, + builder_name=builder_name, + build_datetime=build_datetime) + + except Exception, e: + response.status = 500 + logging.debug('%s' % e) + + @writable('/delete') + def delete(): + artifact = unescape_parameter(request.query.artifact) + try: + os.unlink('%s/%s' % (artifact_dir, artifact)) + return { "status": 0, "reason": "success" } + except OSError, ose: + return { "status": ose.errno, "reason": ose.strerror } + except Exception, e: + response.status = 500 + logging.debug('%s' % e) + + @app.get('/sha1s') + def sha1(): + repo = unescape_parameter(request.query.repo) + ref = unescape_parameter(request.query.ref) + try: + response.set_header('Cache-Control', 'no-cache') + sha1, tree = repo_cache.resolve_ref(repo, ref) + return { + 'repo': '%s' % repo, + 'ref': '%s' % ref, + 'sha1': '%s' % sha1, + 'tree': '%s' % tree + } + except Exception, e: + response.status = 404 + logging.debug('%s' % e) + + @app.post('/sha1s') + def sha1s(): + result = [] + for pair in request.json: + repo = pair['repo'] + ref = pair['ref'] + try: + sha1, tree = repo_cache.resolve_ref(repo, ref) + result.append({ + 'repo': '%s' % repo, + 'ref': '%s' % ref, + 'sha1': '%s' % sha1, + 'tree': '%s' % tree + }) + except Exception, e: + logging.debug('%s' % e) + result.append({ + 'repo': '%s' % repo, + 'ref': '%s' % ref, + 'error': '%s' % e + }) + response.set_header('Cache-Control', 'no-cache') + response.set_header('Content-Type', 'application/json') + return json.dumps(result) + + @app.get('/files') + def file(): + repo = unescape_parameter(request.query.repo) + ref = unescape_parameter(request.query.ref) + filename = unescape_parameter(request.query.filename) + try: + content = repo_cache.cat_file(repo, ref, filename) + response.set_header('Content-Type', 'application/octet-stream') + return content + except Exception, e: + response.status = 404 + logging.debug('%s' % e) + + @app.post('/files') + def files(): + result = [] + for pair in request.json: + repo = pair['repo'] + ref = pair['ref'] + filename = pair['filename'] + try: + content = repo_cache.cat_file(repo, ref, filename) + result.append({ + 'repo': '%s' % repo, + 'ref': '%s' % ref, + 'filename': '%s' % filename, + 'data': '%s' % base64.b64encode(content), + }) + except Exception, e: + logging.debug('%s' % e) + result.append({ + 'repo': '%s' % repo, + 'ref': '%s' % ref, + 'filename': '%s' % filename, + 'error': '%s' % e + }) + response.set_header('Content-Type', 'application/json') + return json.dumps(result) + + @app.get('/trees') + def tree(): + repo = unescape_parameter(request.query.repo) + ref = unescape_parameter(request.query.ref) + path = unescape_parameter(request.query.path) + try: + tree = repo_cache.ls_tree(repo, ref, path) + return { + 'repo': '%s' % repo, + 'ref': '%s' % ref, + 'tree': tree, + } + except Exception, e: + response.status = 404 + logging.debug('%s' % e) + + @app.get('/bundles') + def bundle(): + repo = unescape_parameter(request.query.repo) + filename = repo_cache.get_bundle_filename(repo) + dirname = os.path.dirname(filename) + basename = os.path.basename(filename) + return static_file(basename, root=dirname, download=True) + + @app.get('/artifacts') + def artifact(): + basename = unescape_parameter(request.query.filename) + filename = os.path.join(artifact_dir, basename) + if os.path.exists(filename): + return static_file(basename, + root=artifact_dir, + download=True) + else: + response.status = 404 + logging.debug('artifact %s does not exist' % basename) + + @app.post('/artifacts') + def post_artifacts(): + if request.content_type != 'application/json': + logging.warning('Content-type is not json: ' + 'expecting a json post request') + + artifacts = json.load(request.body) + results = {} + + logging.debug('Received a POST request for /artifacts') + + for artifact in artifacts: + if artifact.startswith('/'): + response.status = 500 + logging.error("%s: artifact name cannot start with a '/'" + % artifact) + return + + filename = os.path.join(artifact_dir, artifact) + results[artifact] = os.path.exists(filename) + + if results[artifact]: + logging.debug('%s is in the cache', artifact) + else: + logging.debug('%s is NOT in the cache', artifact) + + return results diff --git a/morphcacheserver/api_2_0.py b/morphcacheserver/api_2_0.py new file mode 100644 index 0000000..94e9341 --- /dev/null +++ b/morphcacheserver/api_2_0.py @@ -0,0 +1,81 @@ +# Copyright (C) 2015 Codethink Limited +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; version 2 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program. If not, see <http://www.gnu.org/licenses/>. + + +from bottle import Bottle, request, response + + +'''The 2.0/ set of HTTP methods. + +This is still in development and you can pretty much do what you want with it +right now. Work began on it by Sam Thursfield as part of +<http://wiki.baserock.org/projects/deterministic-builds/>, to provide a way of +doing ongoing measurement. + +''' + + +def unescape_parameter(param): + return urllib.unquote(param) + + +def api_2_0(db): + app = Bottle() + + @app.put('/builds') + def put_build(): + '''Record a build. + + Expected parameters: + + - cache_name: artifact cache key plus name + - builder_name: URL identifying the build worker + - build_datetime: time artifact build was started + - hash_sha1: SHA1 of built artifact + - source_repo: (optional): repo this is built from + - source_ref: (optional): ref this is built from + + ''' + cache_name = unescape_parameter(request.query.cache_name) + builder_name = unescape_parameter(request.query.builder_name) + build_datetime = unescape_parameter( + request.query.build_datetime) + hash_sha1 = unescape_parameter(request.query.hash_sha1) + + source_repo = unescape_parameter( + request.query.get('source_repo')) + source_ref = unescape_parameter( + request.query.get('source_ref')) + + db.record_build(cache_name, builder_name, build_datetime, + hash_sha1, source_repo, source_ref) + + @app.get('/builds') + def get_builds(): + '''Return info on all known builds.''' + return {'builds': db.view_artifact_statistics()} + + # This may not be the right name for this method, as its name is + # 'builds' but it takes the cache name of an artifact, not a build. + @app.get('/builds/<cache_name>') + def get_builds_for_artifact(cache_name): + '''Return info on builds of a given artifact.''' + results = sorted(db.iter_builds_for_artifact_file(cache_name)) + + if len(results) == 0: + response.status = 404 + else: + return {cache_name: results} + + return app |