Add ability to cancel builds that are in the waiting state
This commit is contained in:
parent
ae8bb5fc13
commit
81ce4c771e
8 changed files with 489 additions and 257 deletions
|
@ -473,6 +473,7 @@ class BUILD_PHASE(object):
|
||||||
PULLING = 'pulling'
|
PULLING = 'pulling'
|
||||||
BUILDING = 'building'
|
BUILDING = 'building'
|
||||||
PUSHING = 'pushing'
|
PUSHING = 'pushing'
|
||||||
|
WAITING = 'waiting'
|
||||||
COMPLETE = 'complete'
|
COMPLETE = 'complete'
|
||||||
|
|
||||||
|
|
||||||
|
@ -491,7 +492,7 @@ class RepositoryBuild(BaseModel):
|
||||||
access_token = ForeignKeyField(AccessToken)
|
access_token = ForeignKeyField(AccessToken)
|
||||||
resource_key = CharField(index=True)
|
resource_key = CharField(index=True)
|
||||||
job_config = TextField()
|
job_config = TextField()
|
||||||
phase = CharField(default='waiting')
|
phase = CharField(default=BUILD_PHASE.WAITING)
|
||||||
started = DateTimeField(default=datetime.now)
|
started = DateTimeField(default=datetime.now)
|
||||||
display_name = CharField()
|
display_name = CharField()
|
||||||
trigger = ForeignKeyField(RepositoryBuildTrigger, null=True, index=True)
|
trigger = ForeignKeyField(RepositoryBuildTrigger, null=True, index=True)
|
||||||
|
|
|
@ -14,7 +14,7 @@ from data.database import (User, Repository, Image, AccessToken, Role, Repositor
|
||||||
ExternalNotificationEvent, ExternalNotificationMethod,
|
ExternalNotificationEvent, ExternalNotificationMethod,
|
||||||
RepositoryNotification, RepositoryAuthorizedEmail, TeamMemberInvite,
|
RepositoryNotification, RepositoryAuthorizedEmail, TeamMemberInvite,
|
||||||
DerivedImageStorage, ImageStorageTransformation, random_string_generator,
|
DerivedImageStorage, ImageStorageTransformation, random_string_generator,
|
||||||
db, BUILD_PHASE, QuayUserField, ImageStorageSignature,
|
db, BUILD_PHASE, QuayUserField, ImageStorageSignature, QueueItem,
|
||||||
ImageStorageSignatureKind, validate_database_url, db_for_update)
|
ImageStorageSignatureKind, validate_database_url, db_for_update)
|
||||||
from peewee import JOIN_LEFT_OUTER, fn
|
from peewee import JOIN_LEFT_OUTER, fn
|
||||||
from util.validation import (validate_username, validate_email, validate_password,
|
from util.validation import (validate_username, validate_email, validate_password,
|
||||||
|
@ -2431,6 +2431,32 @@ def confirm_team_invite(code, user):
|
||||||
found.delete_instance()
|
found.delete_instance()
|
||||||
return (team, inviter)
|
return (team, inviter)
|
||||||
|
|
||||||
|
def cancel_repository_build(build):
|
||||||
|
with config.app_config['DB_TRANSACTION_FACTORY'](db):
|
||||||
|
# Reload the build for update.
|
||||||
|
try:
|
||||||
|
build = db_for_update(RepositoryBuild.select().where(RepositoryBuild.id == build.id)).get()
|
||||||
|
except RepositoryBuild.DoesNotExist:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if build.phase != BUILD_PHASE.WAITING or not build.queue_item:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Load the build queue item for update.
|
||||||
|
try:
|
||||||
|
queue_item = db_for_update(QueueItem.select()
|
||||||
|
.where(QueueItem.id == build.queue_item.id)).get()
|
||||||
|
except QueueItem.DoesNotExist:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check the queue item.
|
||||||
|
if not queue_item.available or queue_item.retries_remaining == 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Delete the queue item and build.
|
||||||
|
queue_item.delete_instance(recursive=True)
|
||||||
|
build.delete_instance()
|
||||||
|
return True
|
||||||
|
|
||||||
def get_repository_usage():
|
def get_repository_usage():
|
||||||
one_month_ago = date.today() - timedelta(weeks=4)
|
one_month_ago = date.today() - timedelta(weeks=4)
|
||||||
|
|
|
@ -9,7 +9,7 @@ from app import app, userfiles as user_files, build_logs, log_archive
|
||||||
from endpoints.api import (RepositoryParamResource, parse_args, query_param, nickname, resource,
|
from endpoints.api import (RepositoryParamResource, parse_args, query_param, nickname, resource,
|
||||||
require_repo_read, require_repo_write, validate_json_request,
|
require_repo_read, require_repo_write, validate_json_request,
|
||||||
ApiResource, internal_only, format_date, api, Unauthorized, NotFound,
|
ApiResource, internal_only, format_date, api, Unauthorized, NotFound,
|
||||||
path_param)
|
path_param, InvalidRequest, require_repo_admin)
|
||||||
from endpoints.common import start_build
|
from endpoints.common import start_build
|
||||||
from endpoints.trigger import BuildTrigger
|
from endpoints.trigger import BuildTrigger
|
||||||
from data import model, database
|
from data import model, database
|
||||||
|
@ -207,6 +207,31 @@ class RepositoryBuildList(RepositoryParamResource):
|
||||||
return resp, 201, headers
|
return resp, 201, headers
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@resource('/v1/repository/<repopath:repository>/build/<build_uuid>')
|
||||||
|
@path_param('repository', 'The full path of the repository. e.g. namespace/name')
|
||||||
|
@path_param('build_uuid', 'The UUID of the build')
|
||||||
|
class RepositoryBuildResource(RepositoryParamResource):
|
||||||
|
""" Resource for dealing with repository builds. """
|
||||||
|
@require_repo_admin
|
||||||
|
@nickname('cancelRepoBuild')
|
||||||
|
def delete(self, namespace, repository, build_uuid):
|
||||||
|
""" Cancels a repository build if it has not yet been picked up by a build worker. """
|
||||||
|
try:
|
||||||
|
build = model.get_repository_build(build_uuid)
|
||||||
|
except model.InvalidRepositoryBuildException:
|
||||||
|
raise NotFound()
|
||||||
|
|
||||||
|
if build.repository.name != repository or build.repository.namespace_user.username != namespace:
|
||||||
|
raise NotFound()
|
||||||
|
|
||||||
|
if model.cancel_repository_build(build):
|
||||||
|
return 'Okay', 201
|
||||||
|
else:
|
||||||
|
raise InvalidRequest('Build is currently running or has finished')
|
||||||
|
|
||||||
|
|
||||||
@resource('/v1/repository/<repopath:repository>/build/<build_uuid>/status')
|
@resource('/v1/repository/<repopath:repository>/build/<build_uuid>/status')
|
||||||
@path_param('repository', 'The full path of the repository. e.g. namespace/name')
|
@path_param('repository', 'The full path of the repository. e.g. namespace/name')
|
||||||
@path_param('build_uuid', 'The UUID of the build')
|
@path_param('build_uuid', 'The UUID of the build')
|
||||||
|
|
|
@ -1072,257 +1072,6 @@ function BuildPackageCtrl($scope, Restangular, ApiService, DataFileService, $rou
|
||||||
getBuildInfo();
|
getBuildInfo();
|
||||||
}
|
}
|
||||||
|
|
||||||
function RepoBuildCtrl($scope, Restangular, ApiService, $routeParams, $rootScope, $location, $interval, $sanitize,
|
|
||||||
ansi2html, AngularViewArray, AngularPollChannel) {
|
|
||||||
var namespace = $routeParams.namespace;
|
|
||||||
var name = $routeParams.name;
|
|
||||||
|
|
||||||
// Watch for changes to the current parameter.
|
|
||||||
$scope.$on('$routeUpdate', function(){
|
|
||||||
if ($location.search().current) {
|
|
||||||
$scope.setCurrentBuild($location.search().current, false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$scope.builds = null;
|
|
||||||
$scope.pollChannel = null;
|
|
||||||
$scope.buildDialogShowCounter = 0;
|
|
||||||
|
|
||||||
$scope.showNewBuildDialog = function() {
|
|
||||||
$scope.buildDialogShowCounter++;
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.handleBuildStarted = function(newBuild) {
|
|
||||||
if (!$scope.builds) { return; }
|
|
||||||
|
|
||||||
$scope.builds.unshift(newBuild);
|
|
||||||
$scope.setCurrentBuild(newBuild['id'], true);
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.adjustLogHeight = function() {
|
|
||||||
var triggerOffset = 0;
|
|
||||||
if ($scope.currentBuild && $scope.currentBuild.trigger) {
|
|
||||||
triggerOffset = 85;
|
|
||||||
}
|
|
||||||
$('.build-logs').height($(window).height() - 415 - triggerOffset);
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.askRestartBuild = function(build) {
|
|
||||||
$('#confirmRestartBuildModal').modal({});
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.restartBuild = function(build) {
|
|
||||||
$('#confirmRestartBuildModal').modal('hide');
|
|
||||||
|
|
||||||
var subdirectory = '';
|
|
||||||
if (build['job_config']) {
|
|
||||||
subdirectory = build['job_config']['build_subdir'] || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
var data = {
|
|
||||||
'file_id': build['resource_key'],
|
|
||||||
'subdirectory': subdirectory,
|
|
||||||
'docker_tags': build['job_config']['docker_tags']
|
|
||||||
};
|
|
||||||
|
|
||||||
if (build['pull_robot']) {
|
|
||||||
data['pull_robot'] = build['pull_robot']['name'];
|
|
||||||
}
|
|
||||||
|
|
||||||
var params = {
|
|
||||||
'repository': namespace + '/' + name
|
|
||||||
};
|
|
||||||
|
|
||||||
ApiService.requestRepoBuild(data, params).then(function(newBuild) {
|
|
||||||
if (!$scope.builds) { return; }
|
|
||||||
|
|
||||||
$scope.builds.unshift(newBuild);
|
|
||||||
$scope.setCurrentBuild(newBuild['id'], true);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.hasLogs = function(container) {
|
|
||||||
return container.logs.hasEntries;
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.setCurrentBuild = function(buildId, opt_updateURL) {
|
|
||||||
if (!$scope.builds) { return; }
|
|
||||||
|
|
||||||
// Find the build.
|
|
||||||
for (var i = 0; i < $scope.builds.length; ++i) {
|
|
||||||
if ($scope.builds[i].id == buildId) {
|
|
||||||
$scope.setCurrentBuildInternal(i, $scope.builds[i], opt_updateURL);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.processANSI = function(message, container) {
|
|
||||||
var filter = container.logs._filter = (container.logs._filter || ansi2html.create());
|
|
||||||
|
|
||||||
// Note: order is important here.
|
|
||||||
var setup = filter.getSetupHtml();
|
|
||||||
var stream = filter.addInputToStream(message);
|
|
||||||
var teardown = filter.getTeardownHtml();
|
|
||||||
return setup + stream + teardown;
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.setCurrentBuildInternal = function(index, build, opt_updateURL) {
|
|
||||||
if (build == $scope.currentBuild) { return; }
|
|
||||||
|
|
||||||
$scope.logEntries = null;
|
|
||||||
$scope.logStartIndex = null;
|
|
||||||
$scope.currentParentEntry = null;
|
|
||||||
|
|
||||||
$scope.currentBuild = build;
|
|
||||||
|
|
||||||
if (opt_updateURL) {
|
|
||||||
if (build) {
|
|
||||||
$location.search('current', build.id);
|
|
||||||
} else {
|
|
||||||
$location.search('current', null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Timeout needed to ensure the log element has been created
|
|
||||||
// before its height is adjusted.
|
|
||||||
setTimeout(function() {
|
|
||||||
$scope.adjustLogHeight();
|
|
||||||
}, 1);
|
|
||||||
|
|
||||||
// Stop any existing polling.
|
|
||||||
if ($scope.pollChannel) {
|
|
||||||
$scope.pollChannel.stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a new channel for polling the build status and logs.
|
|
||||||
var conductStatusAndLogRequest = function(callback) {
|
|
||||||
getBuildStatusAndLogs(build, callback);
|
|
||||||
};
|
|
||||||
|
|
||||||
$scope.pollChannel = AngularPollChannel.create($scope, conductStatusAndLogRequest, 5 * 1000 /* 5s */);
|
|
||||||
$scope.pollChannel.start();
|
|
||||||
};
|
|
||||||
|
|
||||||
var processLogs = function(logs, startIndex, endIndex) {
|
|
||||||
if (!$scope.logEntries) { $scope.logEntries = []; }
|
|
||||||
|
|
||||||
// If the start index given is less than that requested, then we've received a larger
|
|
||||||
// pool of logs, and we need to only consider the new ones.
|
|
||||||
if (startIndex < $scope.logStartIndex) {
|
|
||||||
logs = logs.slice($scope.logStartIndex - startIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (var i = 0; i < logs.length; ++i) {
|
|
||||||
var entry = logs[i];
|
|
||||||
var type = entry['type'] || 'entry';
|
|
||||||
if (type == 'command' || type == 'phase' || type == 'error') {
|
|
||||||
entry['logs'] = AngularViewArray.create();
|
|
||||||
entry['index'] = $scope.logStartIndex + i;
|
|
||||||
|
|
||||||
$scope.logEntries.push(entry);
|
|
||||||
$scope.currentParentEntry = entry;
|
|
||||||
} else if ($scope.currentParentEntry) {
|
|
||||||
$scope.currentParentEntry['logs'].push(entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return endIndex;
|
|
||||||
};
|
|
||||||
|
|
||||||
var getBuildStatusAndLogs = function(build, callback) {
|
|
||||||
var params = {
|
|
||||||
'repository': namespace + '/' + name,
|
|
||||||
'build_uuid': build.id
|
|
||||||
};
|
|
||||||
|
|
||||||
ApiService.getRepoBuildStatus(null, params, true).then(function(resp) {
|
|
||||||
if (build != $scope.currentBuild) { callback(false); return; }
|
|
||||||
|
|
||||||
// Note: We use extend here rather than replacing as Angular is depending on the
|
|
||||||
// root build object to remain the same object.
|
|
||||||
var matchingBuilds = $.grep($scope.builds, function(elem) {
|
|
||||||
return elem['id'] == resp['id']
|
|
||||||
});
|
|
||||||
|
|
||||||
var currentBuild = matchingBuilds.length > 0 ? matchingBuilds[0] : null;
|
|
||||||
if (currentBuild) {
|
|
||||||
currentBuild = $.extend(true, currentBuild, resp);
|
|
||||||
} else {
|
|
||||||
currentBuild = resp;
|
|
||||||
$scope.builds.push(currentBuild);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load the updated logs for the build.
|
|
||||||
var options = {
|
|
||||||
'start': $scope.logStartIndex
|
|
||||||
};
|
|
||||||
|
|
||||||
ApiService.getRepoBuildLogsAsResource(params, true).withOptions(options).get(function(resp) {
|
|
||||||
if (build != $scope.currentBuild) { callback(false); return; }
|
|
||||||
|
|
||||||
// Process the logs we've received.
|
|
||||||
$scope.logStartIndex = processLogs(resp['logs'], resp['start'], resp['total']);
|
|
||||||
|
|
||||||
// If the build status is an error, open the last two log entries.
|
|
||||||
if (currentBuild['phase'] == 'error' && $scope.logEntries.length > 1) {
|
|
||||||
var openLogEntries = function(entry) {
|
|
||||||
if (entry.logs) {
|
|
||||||
entry.logs.setVisible(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
openLogEntries($scope.logEntries[$scope.logEntries.length - 2]);
|
|
||||||
openLogEntries($scope.logEntries[$scope.logEntries.length - 1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the build phase is an error or a complete, then we mark the channel
|
|
||||||
// as closed.
|
|
||||||
callback(currentBuild['phase'] != 'error' && currentBuild['phase'] != 'complete');
|
|
||||||
}, function() {
|
|
||||||
callback(false);
|
|
||||||
});
|
|
||||||
}, function() {
|
|
||||||
callback(false);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
var fetchRepository = function() {
|
|
||||||
var params = {'repository': namespace + '/' + name};
|
|
||||||
$rootScope.title = 'Loading Repository...';
|
|
||||||
$scope.repository = ApiService.getRepoAsResource(params).get(function(repo) {
|
|
||||||
if (!repo.can_write) {
|
|
||||||
$rootScope.title = 'Unknown builds';
|
|
||||||
$scope.accessDenied = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$rootScope.title = 'Repository Builds';
|
|
||||||
$scope.repo = repo;
|
|
||||||
|
|
||||||
getBuildInfo();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
var getBuildInfo = function(repo) {
|
|
||||||
var params = {
|
|
||||||
'repository': namespace + '/' + name
|
|
||||||
};
|
|
||||||
|
|
||||||
ApiService.getRepoBuilds(null, params).then(function(resp) {
|
|
||||||
$scope.builds = resp.builds;
|
|
||||||
|
|
||||||
if ($location.search().current) {
|
|
||||||
$scope.setCurrentBuild($location.search().current, false);
|
|
||||||
} else if ($scope.builds.length > 0) {
|
|
||||||
$scope.setCurrentBuild($scope.builds[0].id, true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchRepository();
|
|
||||||
}
|
|
||||||
|
|
||||||
function RepoAdminCtrl($scope, Restangular, ApiService, KeyService, TriggerService, $routeParams,
|
function RepoAdminCtrl($scope, Restangular, ApiService, KeyService, TriggerService, $routeParams,
|
||||||
$rootScope, $location, UserService, Config, Features, ExternalNotificationData) {
|
$rootScope, $location, UserService, Config, Features, ExternalNotificationData) {
|
||||||
|
|
||||||
|
|
272
static/js/controllers/repo-build.js
Normal file
272
static/js/controllers/repo-build.js
Normal file
|
@ -0,0 +1,272 @@
|
||||||
|
function RepoBuildCtrl($scope, Restangular, ApiService, $routeParams, $rootScope, $location, $interval, $sanitize,
|
||||||
|
ansi2html, AngularViewArray, AngularPollChannel) {
|
||||||
|
var namespace = $routeParams.namespace;
|
||||||
|
var name = $routeParams.name;
|
||||||
|
|
||||||
|
// Watch for changes to the current parameter.
|
||||||
|
$scope.$on('$routeUpdate', function(){
|
||||||
|
if ($location.search().current) {
|
||||||
|
$scope.setCurrentBuild($location.search().current, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$scope.builds = null;
|
||||||
|
$scope.pollChannel = null;
|
||||||
|
$scope.buildDialogShowCounter = 0;
|
||||||
|
|
||||||
|
$scope.showNewBuildDialog = function() {
|
||||||
|
$scope.buildDialogShowCounter++;
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.handleBuildStarted = function(newBuild) {
|
||||||
|
if (!$scope.builds) { return; }
|
||||||
|
|
||||||
|
$scope.builds.unshift(newBuild);
|
||||||
|
$scope.setCurrentBuild(newBuild['id'], true);
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.adjustLogHeight = function() {
|
||||||
|
var triggerOffset = 0;
|
||||||
|
if ($scope.currentBuild && $scope.currentBuild.trigger) {
|
||||||
|
triggerOffset = 85;
|
||||||
|
}
|
||||||
|
$('.build-logs').height($(window).height() - 415 - triggerOffset);
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.askRestartBuild = function(build) {
|
||||||
|
$('#confirmRestartBuildModal').modal({});
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.askCancelBuild = function(build) {
|
||||||
|
bootbox.confirm('Are you sure you want to cancel this build?', function(r) {
|
||||||
|
if (r) {
|
||||||
|
var params = {
|
||||||
|
'repository': namespace + '/' + name,
|
||||||
|
'build_uuid': build.id
|
||||||
|
};
|
||||||
|
|
||||||
|
ApiService.cancelRepoBuild(null, params).then(function() {
|
||||||
|
if (!$scope.builds) { return; }
|
||||||
|
$scope.builds.splice($.inArray(build, $scope.builds), 1);
|
||||||
|
|
||||||
|
if ($scope.builds.length) {
|
||||||
|
$scope.currentBuild = $scope.builds[0];
|
||||||
|
} else {
|
||||||
|
$scope.currentBuild = null;
|
||||||
|
}
|
||||||
|
}, ApiService.errorDisplay('Cannot cancel build'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.restartBuild = function(build) {
|
||||||
|
$('#confirmRestartBuildModal').modal('hide');
|
||||||
|
|
||||||
|
var subdirectory = '';
|
||||||
|
if (build['job_config']) {
|
||||||
|
subdirectory = build['job_config']['build_subdir'] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = {
|
||||||
|
'file_id': build['resource_key'],
|
||||||
|
'subdirectory': subdirectory,
|
||||||
|
'docker_tags': build['job_config']['docker_tags']
|
||||||
|
};
|
||||||
|
|
||||||
|
if (build['pull_robot']) {
|
||||||
|
data['pull_robot'] = build['pull_robot']['name'];
|
||||||
|
}
|
||||||
|
|
||||||
|
var params = {
|
||||||
|
'repository': namespace + '/' + name
|
||||||
|
};
|
||||||
|
|
||||||
|
ApiService.requestRepoBuild(data, params).then(function(newBuild) {
|
||||||
|
if (!$scope.builds) { return; }
|
||||||
|
|
||||||
|
$scope.builds.unshift(newBuild);
|
||||||
|
$scope.setCurrentBuild(newBuild['id'], true);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.hasLogs = function(container) {
|
||||||
|
return container.logs.hasEntries;
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.setCurrentBuild = function(buildId, opt_updateURL) {
|
||||||
|
if (!$scope.builds) { return; }
|
||||||
|
|
||||||
|
// Find the build.
|
||||||
|
for (var i = 0; i < $scope.builds.length; ++i) {
|
||||||
|
if ($scope.builds[i].id == buildId) {
|
||||||
|
$scope.setCurrentBuildInternal(i, $scope.builds[i], opt_updateURL);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.processANSI = function(message, container) {
|
||||||
|
var filter = container.logs._filter = (container.logs._filter || ansi2html.create());
|
||||||
|
|
||||||
|
// Note: order is important here.
|
||||||
|
var setup = filter.getSetupHtml();
|
||||||
|
var stream = filter.addInputToStream(message);
|
||||||
|
var teardown = filter.getTeardownHtml();
|
||||||
|
return setup + stream + teardown;
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.setCurrentBuildInternal = function(index, build, opt_updateURL) {
|
||||||
|
if (build == $scope.currentBuild) { return; }
|
||||||
|
|
||||||
|
$scope.logEntries = null;
|
||||||
|
$scope.logStartIndex = null;
|
||||||
|
$scope.currentParentEntry = null;
|
||||||
|
|
||||||
|
$scope.currentBuild = build;
|
||||||
|
|
||||||
|
if (opt_updateURL) {
|
||||||
|
if (build) {
|
||||||
|
$location.search('current', build.id);
|
||||||
|
} else {
|
||||||
|
$location.search('current', null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeout needed to ensure the log element has been created
|
||||||
|
// before its height is adjusted.
|
||||||
|
setTimeout(function() {
|
||||||
|
$scope.adjustLogHeight();
|
||||||
|
}, 1);
|
||||||
|
|
||||||
|
// Stop any existing polling.
|
||||||
|
if ($scope.pollChannel) {
|
||||||
|
$scope.pollChannel.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new channel for polling the build status and logs.
|
||||||
|
var conductStatusAndLogRequest = function(callback) {
|
||||||
|
getBuildStatusAndLogs(build, callback);
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.pollChannel = AngularPollChannel.create($scope, conductStatusAndLogRequest, 5 * 1000 /* 5s */);
|
||||||
|
$scope.pollChannel.start();
|
||||||
|
};
|
||||||
|
|
||||||
|
var processLogs = function(logs, startIndex, endIndex) {
|
||||||
|
if (!$scope.logEntries) { $scope.logEntries = []; }
|
||||||
|
|
||||||
|
// If the start index given is less than that requested, then we've received a larger
|
||||||
|
// pool of logs, and we need to only consider the new ones.
|
||||||
|
if (startIndex < $scope.logStartIndex) {
|
||||||
|
logs = logs.slice($scope.logStartIndex - startIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < logs.length; ++i) {
|
||||||
|
var entry = logs[i];
|
||||||
|
var type = entry['type'] || 'entry';
|
||||||
|
if (type == 'command' || type == 'phase' || type == 'error') {
|
||||||
|
entry['logs'] = AngularViewArray.create();
|
||||||
|
entry['index'] = $scope.logStartIndex + i;
|
||||||
|
|
||||||
|
$scope.logEntries.push(entry);
|
||||||
|
$scope.currentParentEntry = entry;
|
||||||
|
} else if ($scope.currentParentEntry) {
|
||||||
|
$scope.currentParentEntry['logs'].push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return endIndex;
|
||||||
|
};
|
||||||
|
|
||||||
|
var getBuildStatusAndLogs = function(build, callback) {
|
||||||
|
var params = {
|
||||||
|
'repository': namespace + '/' + name,
|
||||||
|
'build_uuid': build.id
|
||||||
|
};
|
||||||
|
|
||||||
|
ApiService.getRepoBuildStatus(null, params, true).then(function(resp) {
|
||||||
|
if (build != $scope.currentBuild) { callback(false); return; }
|
||||||
|
|
||||||
|
// Note: We use extend here rather than replacing as Angular is depending on the
|
||||||
|
// root build object to remain the same object.
|
||||||
|
var matchingBuilds = $.grep($scope.builds, function(elem) {
|
||||||
|
return elem['id'] == resp['id']
|
||||||
|
});
|
||||||
|
|
||||||
|
var currentBuild = matchingBuilds.length > 0 ? matchingBuilds[0] : null;
|
||||||
|
if (currentBuild) {
|
||||||
|
currentBuild = $.extend(true, currentBuild, resp);
|
||||||
|
} else {
|
||||||
|
currentBuild = resp;
|
||||||
|
$scope.builds.push(currentBuild);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the updated logs for the build.
|
||||||
|
var options = {
|
||||||
|
'start': $scope.logStartIndex
|
||||||
|
};
|
||||||
|
|
||||||
|
ApiService.getRepoBuildLogsAsResource(params, true).withOptions(options).get(function(resp) {
|
||||||
|
if (build != $scope.currentBuild) { callback(false); return; }
|
||||||
|
|
||||||
|
// Process the logs we've received.
|
||||||
|
$scope.logStartIndex = processLogs(resp['logs'], resp['start'], resp['total']);
|
||||||
|
|
||||||
|
// If the build status is an error, open the last two log entries.
|
||||||
|
if (currentBuild['phase'] == 'error' && $scope.logEntries.length > 1) {
|
||||||
|
var openLogEntries = function(entry) {
|
||||||
|
if (entry.logs) {
|
||||||
|
entry.logs.setVisible(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
openLogEntries($scope.logEntries[$scope.logEntries.length - 2]);
|
||||||
|
openLogEntries($scope.logEntries[$scope.logEntries.length - 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the build phase is an error or a complete, then we mark the channel
|
||||||
|
// as closed.
|
||||||
|
callback(currentBuild['phase'] != 'error' && currentBuild['phase'] != 'complete');
|
||||||
|
}, function() {
|
||||||
|
callback(false);
|
||||||
|
});
|
||||||
|
}, function() {
|
||||||
|
callback(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
var fetchRepository = function() {
|
||||||
|
var params = {'repository': namespace + '/' + name};
|
||||||
|
$rootScope.title = 'Loading Repository...';
|
||||||
|
$scope.repository = ApiService.getRepoAsResource(params).get(function(repo) {
|
||||||
|
if (!repo.can_write) {
|
||||||
|
$rootScope.title = 'Unknown builds';
|
||||||
|
$scope.accessDenied = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rootScope.title = 'Repository Builds';
|
||||||
|
$scope.repo = repo;
|
||||||
|
|
||||||
|
getBuildInfo();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
var getBuildInfo = function(repo) {
|
||||||
|
var params = {
|
||||||
|
'repository': namespace + '/' + name
|
||||||
|
};
|
||||||
|
|
||||||
|
ApiService.getRepoBuilds(null, params).then(function(resp) {
|
||||||
|
$scope.builds = resp.builds;
|
||||||
|
|
||||||
|
if ($location.search().current) {
|
||||||
|
$scope.setCurrentBuild($location.search().current, false);
|
||||||
|
} else if ($scope.builds.length > 0) {
|
||||||
|
$scope.setCurrentBuild($scope.builds[0].id, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchRepository();
|
||||||
|
}
|
|
@ -94,13 +94,20 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-top: 10px">
|
<div style="margin-top: 10px">
|
||||||
<span class="quay-spinner" ng-show="pollChannel.working"></span>
|
<button class="btn btn-default"
|
||||||
|
ng-show="build.phase == 'waiting' && build.resource_key"
|
||||||
|
ng-click="askCancelBuild(build)">
|
||||||
|
<i class="fa fa-times-circle" style="margin-right: 6px; display: inline-block;"></i>
|
||||||
|
Cancel Build
|
||||||
|
</button>
|
||||||
|
|
||||||
<button class="btn" ng-show="(build.phase == 'error' || build.phase == 'complete') && build.resource_key"
|
<button class="btn" ng-show="(build.phase == 'error' || build.phase == 'complete') && build.resource_key"
|
||||||
ng-class="build.phase == 'error' ? 'btn-success' : 'btn-default'"
|
ng-class="build.phase == 'error' ? 'btn-success' : 'btn-default'"
|
||||||
ng-click="askRestartBuild(build)">
|
ng-click="askRestartBuild(build)">
|
||||||
<i class="fa fa-refresh"></i>
|
<i class="fa fa-refresh"></i>
|
||||||
Run Build Again
|
Run Build Again
|
||||||
</button>
|
</button>
|
||||||
|
<span class="quay-spinner" ng-show="pollChannel.working"></span>
|
||||||
<span class="build-id">{{ build.id }}</span>
|
<span class="build-id">{{ build.id }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -15,7 +15,7 @@ from endpoints.api.tag import RepositoryTagImages, RepositoryTag
|
||||||
from endpoints.api.search import FindRepositories, EntitySearch
|
from endpoints.api.search import FindRepositories, EntitySearch
|
||||||
from endpoints.api.image import RepositoryImageChanges, RepositoryImage, RepositoryImageList
|
from endpoints.api.image import RepositoryImageChanges, RepositoryImage, RepositoryImageList
|
||||||
from endpoints.api.build import (FileDropResource, RepositoryBuildStatus, RepositoryBuildLogs,
|
from endpoints.api.build import (FileDropResource, RepositoryBuildStatus, RepositoryBuildLogs,
|
||||||
RepositoryBuildList)
|
RepositoryBuildList, RepositoryBuildResource)
|
||||||
from endpoints.api.robot import (UserRobotList, OrgRobot, OrgRobotList, UserRobot,
|
from endpoints.api.robot import (UserRobotList, OrgRobot, OrgRobotList, UserRobot,
|
||||||
RegenerateOrgRobot, RegenerateUserRobot)
|
RegenerateOrgRobot, RegenerateUserRobot)
|
||||||
|
|
||||||
|
@ -1571,6 +1571,60 @@ class TestRepositoryBuildStatusFg86BuynlargeOrgrepo(ApiTestCase):
|
||||||
self._run_test('GET', 400, 'devtable', None)
|
self._run_test('GET', 400, 'devtable', None)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRepositoryBuildResourceFg86PublicPublicrepo(ApiTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
ApiTestCase.setUp(self)
|
||||||
|
self._set_url(RepositoryBuildResource, build_uuid="FG86", repository="public/publicrepo")
|
||||||
|
|
||||||
|
def test_delete_anonymous(self):
|
||||||
|
self._run_test('DELETE', 401, None, None)
|
||||||
|
|
||||||
|
def test_delete_freshuser(self):
|
||||||
|
self._run_test('DELETE', 403, 'freshuser', None)
|
||||||
|
|
||||||
|
def test_delete_reader(self):
|
||||||
|
self._run_test('DELETE', 403, 'reader', None)
|
||||||
|
|
||||||
|
def test_delete_devtable(self):
|
||||||
|
self._run_test('DELETE', 403, 'devtable', None)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRepositoryBuildResourceFg86DevtableShared(ApiTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
ApiTestCase.setUp(self)
|
||||||
|
self._set_url(RepositoryBuildResource, build_uuid="FG86", repository="devtable/shared")
|
||||||
|
|
||||||
|
def test_delete_anonymous(self):
|
||||||
|
self._run_test('DELETE', 401, None, None)
|
||||||
|
|
||||||
|
def test_delete_freshuser(self):
|
||||||
|
self._run_test('DELETE', 403, 'freshuser', None)
|
||||||
|
|
||||||
|
def test_delete_reader(self):
|
||||||
|
self._run_test('DELETE', 403, 'reader', None)
|
||||||
|
|
||||||
|
def test_delete_devtable(self):
|
||||||
|
self._run_test('DELETE', 404, 'devtable', None)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRepositoryBuildResourceFg86BuynlargeOrgrepo(ApiTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
ApiTestCase.setUp(self)
|
||||||
|
self._set_url(RepositoryBuildResource, build_uuid="FG86", repository="buynlarge/orgrepo")
|
||||||
|
|
||||||
|
def test_delete_anonymous(self):
|
||||||
|
self._run_test('DELETE', 401, None, None)
|
||||||
|
|
||||||
|
def test_delete_freshuser(self):
|
||||||
|
self._run_test('DELETE', 403, 'freshuser', None)
|
||||||
|
|
||||||
|
def test_delete_reader(self):
|
||||||
|
self._run_test('DELETE', 403, 'reader', None)
|
||||||
|
|
||||||
|
def test_delete_devtable(self):
|
||||||
|
self._run_test('DELETE', 404, 'devtable', None)
|
||||||
|
|
||||||
|
|
||||||
class TestRepositoryBuildLogsS5j8PublicPublicrepo(ApiTestCase):
|
class TestRepositoryBuildLogsS5j8PublicPublicrepo(ApiTestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
ApiTestCase.setUp(self)
|
ApiTestCase.setUp(self)
|
||||||
|
|
|
@ -17,7 +17,8 @@ from endpoints.api.team import TeamMember, TeamMemberList, TeamMemberInvite, Org
|
||||||
from endpoints.api.tag import RepositoryTagImages, RepositoryTag
|
from endpoints.api.tag import RepositoryTagImages, RepositoryTag
|
||||||
from endpoints.api.search import FindRepositories, EntitySearch
|
from endpoints.api.search import FindRepositories, EntitySearch
|
||||||
from endpoints.api.image import RepositoryImage, RepositoryImageList
|
from endpoints.api.image import RepositoryImage, RepositoryImageList
|
||||||
from endpoints.api.build import RepositoryBuildStatus, RepositoryBuildLogs, RepositoryBuildList
|
from endpoints.api.build import (RepositoryBuildStatus, RepositoryBuildLogs, RepositoryBuildList,
|
||||||
|
RepositoryBuildResource)
|
||||||
from endpoints.api.robot import (UserRobotList, OrgRobot, OrgRobotList, UserRobot,
|
from endpoints.api.robot import (UserRobotList, OrgRobot, OrgRobotList, UserRobot,
|
||||||
RegenerateUserRobot, RegenerateOrgRobot)
|
RegenerateUserRobot, RegenerateOrgRobot)
|
||||||
from endpoints.api.trigger import (BuildTriggerActivate, BuildTriggerSources, BuildTriggerSubdirs,
|
from endpoints.api.trigger import (BuildTriggerActivate, BuildTriggerSources, BuildTriggerSubdirs,
|
||||||
|
@ -1303,6 +1304,103 @@ class TestGetRepository(ApiTestCase):
|
||||||
self.assertEquals(True, json['is_organization'])
|
self.assertEquals(True, json['is_organization'])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class TestRepositoryBuildResource(ApiTestCase):
|
||||||
|
def test_cancel_invalidbuild(self):
|
||||||
|
self.login(ADMIN_ACCESS_USER)
|
||||||
|
|
||||||
|
self.deleteResponse(RepositoryBuildResource,
|
||||||
|
params=dict(repository=ADMIN_ACCESS_USER + '/simple', build_uuid='invalid'),
|
||||||
|
expected_code=404)
|
||||||
|
|
||||||
|
def test_cancel_waitingbuild(self):
|
||||||
|
self.login(ADMIN_ACCESS_USER)
|
||||||
|
|
||||||
|
# Request a (fake) build.
|
||||||
|
json = self.postJsonResponse(RepositoryBuildList,
|
||||||
|
params=dict(repository=ADMIN_ACCESS_USER + '/simple'),
|
||||||
|
data=dict(file_id='foobarbaz'),
|
||||||
|
expected_code=201)
|
||||||
|
|
||||||
|
uuid = json['id']
|
||||||
|
|
||||||
|
# Check for the build.
|
||||||
|
json = self.getJsonResponse(RepositoryBuildList,
|
||||||
|
params=dict(repository=ADMIN_ACCESS_USER + '/simple'))
|
||||||
|
|
||||||
|
self.assertEquals(1, len(json['builds']))
|
||||||
|
self.assertEquals(uuid, json['builds'][0]['id'])
|
||||||
|
|
||||||
|
# Cancel the build.
|
||||||
|
self.deleteResponse(RepositoryBuildResource,
|
||||||
|
params=dict(repository=ADMIN_ACCESS_USER + '/simple', build_uuid=uuid),
|
||||||
|
expected_code=201)
|
||||||
|
|
||||||
|
# Check for the build.
|
||||||
|
json = self.getJsonResponse(RepositoryBuildList,
|
||||||
|
params=dict(repository=ADMIN_ACCESS_USER + '/simple'))
|
||||||
|
|
||||||
|
self.assertEquals(0, len(json['builds']))
|
||||||
|
|
||||||
|
|
||||||
|
def test_attemptcancel_scheduledbuild(self):
|
||||||
|
self.login(ADMIN_ACCESS_USER)
|
||||||
|
|
||||||
|
# Request a (fake) build.
|
||||||
|
json = self.postJsonResponse(RepositoryBuildList,
|
||||||
|
params=dict(repository=ADMIN_ACCESS_USER + '/simple'),
|
||||||
|
data=dict(file_id='foobarbaz'),
|
||||||
|
expected_code=201)
|
||||||
|
|
||||||
|
uuid = json['id']
|
||||||
|
|
||||||
|
# Check for the build.
|
||||||
|
json = self.getJsonResponse(RepositoryBuildList,
|
||||||
|
params=dict(repository=ADMIN_ACCESS_USER + '/simple'))
|
||||||
|
|
||||||
|
self.assertEquals(1, len(json['builds']))
|
||||||
|
self.assertEquals(uuid, json['builds'][0]['id'])
|
||||||
|
|
||||||
|
# Set queue item to be picked up.
|
||||||
|
qi = database.QueueItem.get(id=1)
|
||||||
|
qi.available = False
|
||||||
|
qi.save()
|
||||||
|
|
||||||
|
# Try to cancel the build.
|
||||||
|
self.deleteResponse(RepositoryBuildResource,
|
||||||
|
params=dict(repository=ADMIN_ACCESS_USER + '/simple', build_uuid=uuid),
|
||||||
|
expected_code=400)
|
||||||
|
|
||||||
|
|
||||||
|
def test_attemptcancel_workingbuild(self):
|
||||||
|
self.login(ADMIN_ACCESS_USER)
|
||||||
|
|
||||||
|
# Request a (fake) build.
|
||||||
|
json = self.postJsonResponse(RepositoryBuildList,
|
||||||
|
params=dict(repository=ADMIN_ACCESS_USER + '/simple'),
|
||||||
|
data=dict(file_id='foobarbaz'),
|
||||||
|
expected_code=201)
|
||||||
|
|
||||||
|
uuid = json['id']
|
||||||
|
|
||||||
|
# Check for the build.
|
||||||
|
json = self.getJsonResponse(RepositoryBuildList,
|
||||||
|
params=dict(repository=ADMIN_ACCESS_USER + '/simple'))
|
||||||
|
|
||||||
|
self.assertEquals(1, len(json['builds']))
|
||||||
|
self.assertEquals(uuid, json['builds'][0]['id'])
|
||||||
|
|
||||||
|
# Set the build to a different phase.
|
||||||
|
rb = database.RepositoryBuild.get(uuid=uuid)
|
||||||
|
rb.phase = database.BUILD_PHASE.BUILDING
|
||||||
|
rb.save()
|
||||||
|
|
||||||
|
# Try to cancel the build.
|
||||||
|
self.deleteResponse(RepositoryBuildResource,
|
||||||
|
params=dict(repository=ADMIN_ACCESS_USER + '/simple', build_uuid=uuid),
|
||||||
|
expected_code=400)
|
||||||
|
|
||||||
|
|
||||||
class TestRepoBuilds(ApiTestCase):
|
class TestRepoBuilds(ApiTestCase):
|
||||||
def test_getrepo_nobuilds(self):
|
def test_getrepo_nobuilds(self):
|
||||||
self.login(ADMIN_ACCESS_USER)
|
self.login(ADMIN_ACCESS_USER)
|
||||||
|
|
Reference in a new issue