From 5270066d6d466ab613fd3f9a1db025857774d052 Mon Sep 17 00:00:00 2001 From: yackob03 Date: Mon, 3 Feb 2014 19:08:37 -0500 Subject: [PATCH] Switch to the redis backed build logs and status. --- config.py | 14 ++- data/buildlogs.py | 56 +++++++++ data/database.py | 12 +- data/model.py | 20 ++-- endpoints/api.py | 87 ++++++++++---- requirements-nover.txt | 2 + requirements.txt | 2 + static/js/app.js | 10 +- test/data/test.db | Bin 141312 -> 142336 bytes workers/dockerfilebuild.py | 230 ++++++++++++++++++++++--------------- 10 files changed, 292 insertions(+), 141 deletions(-) create mode 100644 data/buildlogs.py diff --git a/config.py b/config.py index 55492bab1..7a772a119 100644 --- a/config.py +++ b/config.py @@ -6,6 +6,7 @@ from peewee import MySQLDatabase, SqliteDatabase from storage.s3 import S3Storage from storage.local import LocalStorage from data.userfiles import UserRequestFiles +from data.buildlogs import BuildLogs from util import analytics from test.teststorage import FakeStorage, FakeUserfiles @@ -86,6 +87,10 @@ class S3Userfiles(AWSCredentials): AWSCredentials.REGISTRY_S3_BUCKET) +class RedisBuildLogs(object): + BUILDLOGS = BuildLogs('logs.quay.io') + + class StripeTestConfig(object): STRIPE_SECRET_KEY = 'sk_test_PEbmJCYrLXPW0VRLSnWUiZ7Y' STRIPE_PUBLISHABLE_KEY = 'pk_test_uEDHANKm9CHCvVa2DLcipGRh' @@ -153,7 +158,7 @@ def logs_init_builder(level=logging.DEBUG, logfile=None): class TestConfig(FlaskConfig, FakeStorage, EphemeralDB, FakeUserfiles, - FakeAnalytics, StripeTestConfig): + FakeAnalytics, StripeTestConfig, RedisBuildLogs): LOGGING_CONFIG = logs_init_builder(logging.WARN) POPULATE_DB_TEST_DATA = True TESTING = True @@ -162,7 +167,8 @@ class TestConfig(FlaskConfig, FakeStorage, EphemeralDB, FakeUserfiles, class DebugConfig(FlaskConfig, MailConfig, LocalStorage, SQLiteDB, StripeTestConfig, MixpanelTestConfig, GitHubTestConfig, - DigitalOceanConfig, BuildNodeConfig, S3Userfiles): + DigitalOceanConfig, BuildNodeConfig, S3Userfiles, + RedisBuildLogs): LOGGING_CONFIG = logs_init_builder() SEND_FILE_MAX_AGE_DEFAULT = 0 POPULATE_DB_TEST_DATA = True @@ -172,7 +178,7 @@ class DebugConfig(FlaskConfig, MailConfig, LocalStorage, SQLiteDB, class LocalHostedConfig(FlaskConfig, MailConfig, S3Storage, RDSMySQL, StripeLiveConfig, MixpanelTestConfig, GitHubProdConfig, DigitalOceanConfig, - BuildNodeConfig, S3Userfiles): + BuildNodeConfig, S3Userfiles, RedisBuildLogs): LOGGING_CONFIG = logs_init_builder() SEND_FILE_MAX_AGE_DEFAULT = 0 @@ -180,7 +186,7 @@ class LocalHostedConfig(FlaskConfig, MailConfig, S3Storage, RDSMySQL, class ProductionConfig(FlaskProdConfig, MailConfig, S3Storage, RDSMySQL, StripeLiveConfig, MixpanelProdConfig, GitHubProdConfig, DigitalOceanConfig, BuildNodeConfig, - S3Userfiles): + S3Userfiles, RedisBuildLogs): LOGGING_CONFIG = logs_init_builder(logfile='/mnt/logs/application.log') SEND_FILE_MAX_AGE_DEFAULT = 0 diff --git a/data/buildlogs.py b/data/buildlogs.py new file mode 100644 index 000000000..ff09934f7 --- /dev/null +++ b/data/buildlogs.py @@ -0,0 +1,56 @@ +import redis +import json + + +class BuildLogs(object): + def __init__(self, redis_host): + self._redis = redis.StrictRedis(host=redis_host) + + @staticmethod + def _logs_key(build_id): + return 'builds/%s/logs' % build_id + + def append_log_entry(self, build_id, log_obj): + """ + Appends the serialized form of log_obj to the end of the log entry list + and returns the new length of the list. + """ + return self._redis.rpush(self._logs_key(build_id), json.dumps(log_obj)) + + def append_log_message(self, build_id, log_message): + """ + Wraps the message in an envelope and push it to the end of the log entry + list and returns the new length of the list. + """ + log_obj = { + 'message': log_message + } + return self._redis.rpush(self._logs_key(build_id), json.dumps(log_obj)) + + def get_log_entries(self, build_id, start_index, end_index): + """ + Returns a tuple of the current length of the list and an iterable of the + requested log entries. End index is inclusive. + """ + llen = self._redis.llen(self._logs_key(build_id)) + log_entries = self._redis.lrange(self._logs_key(build_id), start_index, + end_index) + return (llen, (json.loads(entry) for entry in log_entries)) + + @staticmethod + def _status_key(build_id): + return 'builds/%s/status' % build_id + + def set_status(self, build_id, status_obj): + """ + Sets the status key for this build to json serialized form of the supplied + obj. + """ + self._redis.set(self._status_key(build_id), json.dumps(status_obj)) + + def get_status(self, build_id): + """ + Loads the status information for the specified build id. + """ + fetched = self._redis.get(self._status_key(build_id)) + return json.loads(fetched) if fetched else None diff --git a/data/database.py b/data/database.py index d2319773b..3c5fcf422 100644 --- a/data/database.py +++ b/data/database.py @@ -1,5 +1,6 @@ import string import logging +import uuid from random import SystemRandom from datetime import datetime @@ -20,6 +21,10 @@ def random_string_generator(length=16): return random_string +def uuid_generator(): + return str(uuid.uuid4()) + + class BaseModel(Model): class Meta: database = db @@ -125,7 +130,7 @@ class RepositoryPermission(BaseModel): class PermissionPrototype(BaseModel): org = ForeignKeyField(User, index=True, related_name='orgpermissionproto') - uuid = CharField() + uuid = CharField(default=uuid_generator) activating_user = ForeignKeyField(User, index=True, null=True, related_name='userpermissionproto') delegate_user = ForeignKeyField(User, related_name='receivingpermission', @@ -204,13 +209,12 @@ class RepositoryTag(BaseModel): class RepositoryBuild(BaseModel): - repository = ForeignKeyField(Repository) + uuid = CharField(default=uuid_generator, index=True) + repository = ForeignKeyField(Repository, index=True) access_token = ForeignKeyField(AccessToken) resource_key = CharField() tag = CharField() - build_node_id = IntegerField(null=True) phase = CharField(default='waiting') - status_url = CharField(null=True) class QueueItem(BaseModel): diff --git a/data/model.py b/data/model.py index c047e1602..72c4e1861 100644 --- a/data/model.py +++ b/data/model.py @@ -4,9 +4,7 @@ import datetime import dateutil.parser import operator import json -import uuid -from datetime import timedelta from database import * from util.validation import * @@ -728,8 +726,7 @@ def update_prototype_permission(org, uid, role_name): def add_prototype_permission(org, role_name, activating_user, delegate_user=None, delegate_team=None): new_role = Role.get(Role.name == role_name) - uid = str(uuid.uuid4()) - return PermissionPrototype.create(org=org, uuid=uid, role=new_role, + return PermissionPrototype.create(org=org, role=new_role, activating_user=activating_user, delegate_user=delegate_user, delegate_team=delegate_team) @@ -1248,13 +1245,18 @@ def load_token_data(code): raise InvalidTokenException('Invalid delegate token code: %s' % code) -def get_repository_build(request_dbid): - try: - return RepositoryBuild.get(RepositoryBuild.id == request_dbid) - except RepositoryBuild.DoesNotExist: - msg = 'Unable to locate a build by id: %s' % request_dbid +def get_repository_build(namespace_name, repository_name, build_uuid): + joined = RepositoryBuild.select().join(Repository) + fetched = list(joined.where(Repository.name == repository_name, + Repository.namespace == namespace_name, + RepositoryBuild.uuid == build_uuid)) + + if not fetched: + msg = 'Unable to locate a build by id: %s' % build_uuid raise InvalidRepositoryBuildException(msg) + return fetched[0] + def list_repository_builds(namespace_name, repository_name, include_inactive=True): diff --git a/endpoints/api.py b/endpoints/api.py index c3f9cf0ad..a953a901d 100644 --- a/endpoints/api.py +++ b/endpoints/api.py @@ -31,6 +31,7 @@ from datetime import datetime, timedelta store = app.config['STORAGE'] user_files = app.config['USERFILES'] +build_logs = app.config['BUILDLOGS'] logger = logging.getLogger(__name__) route_data = None @@ -1141,35 +1142,65 @@ def get_repo(namespace, repository): abort(403) # Permission denied +def build_status_view(build_obj): + status = build_logs.get_status(build_obj.uuid) + return { + 'id': build_obj.uuid, + 'phase': build_obj.phase, + 'status': status, + } + + @api.route('/repository//build/', methods=['GET']) @parse_repository_name def get_repo_builds(namespace, repository): permission = ReadRepositoryPermission(namespace, repository) is_public = model.repository_is_public(namespace, repository) if permission.can() or is_public: - def build_view(build_obj): - # TODO(jake): Filter these logs if the current user can only *read* the repo. - if build_obj.status_url: - # Delegate the status to the build node - node_status = requests.get(build_obj.status_url).json() - node_status['id'] = build_obj.id - return node_status - - # If there was no status url, do the best we can - # The format of this block should mirror that of the buildserver. - return { - 'id': build_obj.id, - 'total_commands': None, - 'current_command': None, - 'push_completion': 0.0, - 'status': build_obj.phase, - 'message': None, - 'image_completion': {}, - } - builds = model.list_repository_builds(namespace, repository) return jsonify({ - 'builds': [build_view(build) for build in builds] + 'builds': [build_status_view(build) for build in builds] + }) + + abort(403) # Permission denied + + +@api.route('/repository//build//status', + methods=['GET']) +@parse_repository_name +def get_repo_build_status(namespace, repository, build_uuid): + permission = ReadRepositoryPermission(namespace, repository) + is_public = model.repository_is_public(namespace, repository) + if permission.can() or is_public: + build = model.get_repository_build(namespace, repository, build_uuid) + return jsonify(build_status_view(build)) + + abort(403) # Permission denied + + +@api.route('/repository//build//logs', + methods=['GET']) +@parse_repository_name +def get_repo_build_logs(namespace, repository, build_uuid): + permission = ModifyRepositoryPermission(namespace, repository) + if permission.can(): + build = model.get_repository_build(namespace, repository, build_uuid) + + start = request.args.get('start', -1000) + end = request.args.get('end', -1) + count, logs = build_logs.get_log_entries(build.uuid, start, end) + + if start < 0: + start = max(0, count + start) + + if end < 0: + end = count + end + + return jsonify({ + 'start': start, + 'end': end, + 'total': count, + 'logs': [log for log in logs], }) abort(403) # Permission denied @@ -1191,15 +1222,21 @@ def request_repo_build(namespace, repository): tag = '%s/%s/%s' % (host, repo.namespace, repo.name) build_request = model.create_repository_build(repo, token, dockerfile_id, tag) - dockerfile_build_queue.put(json.dumps({'build_id': build_request.id})) + dockerfile_build_queue.put(json.dumps({ + 'build_uuid': build_request.uuid, + 'namespace': namespace, + 'repository': repository, + })) log_action('build_dockerfile', namespace, {'repo': repository, 'namespace': namespace, 'fileid': dockerfile_id}, repo=repo) - resp = jsonify({ - 'started': True - }) + resp = jsonify(build_status_view(build_request)) + repo_string = '%s/%s' % (namespace, repository) + resp.headers['Location'] = url_for('api.get_repo_build_status', + repository=repo_string, + build_uuid=build_request.uuid) resp.status_code = 201 return resp diff --git a/requirements-nover.txt b/requirements-nover.txt index c430edf5a..bfcc34ba6 100644 --- a/requirements-nover.txt +++ b/requirements-nover.txt @@ -19,3 +19,5 @@ paramiko python-digitalocean xhtml2pdf logstash_formatter +redis +hiredis \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 7438e6dce..9973c4778 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,6 +17,7 @@ ecdsa==0.10 gevent==1.0 greenlet==0.4.2 gunicorn==18.0 +hiredis==0.1.2 html5lib==1.0b3 itsdangerous==0.23 lockfile==0.9.1 @@ -31,6 +32,7 @@ pycrypto==2.6.1 python-daemon==1.6 python-dateutil==2.2 python-digitalocean==0.6 +redis==2.9.1 reportlab==2.7 requests==2.2.1 six==1.5.2 diff --git a/static/js/app.js b/static/js/app.js index cf48945bd..52a09174c 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -2475,13 +2475,13 @@ quayApp.directive('buildStatus', function () { }, controller: function($scope, $element) { $scope.getBuildProgress = function(buildInfo) { - switch (buildInfo.status) { + switch (buildInfo.phase) { case 'building': - return (buildInfo.current_command / buildInfo.total_commands) * 100; + return (buildInfo.status.current_command / buildInfo.status.total_commands) * 100; break; case 'pushing': - return buildInfo.push_completion * 100; + return buildInfo.status.push_completion * 100; break; case 'complete': @@ -2499,7 +2499,7 @@ quayApp.directive('buildStatus', function () { }; $scope.getBuildMessage = function(buildInfo) { - switch (buildInfo.status) { + switch (buildInfo.phase) { case 'initializing': return 'Starting Dockerfile build'; break; @@ -2519,7 +2519,7 @@ quayApp.directive('buildStatus', function () { break; case 'error': - return 'Dockerfile build failed: ' + buildInfo.message; + return 'Dockerfile build failed.'; break; } }; diff --git a/test/data/test.db b/test/data/test.db index be6291cdaf3680fcaa53c5ac81823dcc8be9fd92..6062f0e97acdba97e216177332b9f6bd35f1664e 100644 GIT binary patch delta 6203 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 delta 5380 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