Copy over more services for polling

Use a class for rollout status response

Add some better errors

Add override styles for success case
This commit is contained in:
Sam Chow 2018-08-24 14:02:13 -04:00
parent d936d778da
commit 128cf0a28d
8 changed files with 318 additions and 72 deletions

View file

@ -42,7 +42,7 @@ class QEDeploymentRolloutStatus(ApiResource):
@kubernetes_only @kubernetes_only
@nickname('scGetDeploymentRolloutStatus') @nickname('scGetDeploymentRolloutStatus')
def get(self, deployment): def get(self, deployment):
return KubernetesAccessorSingleton.get_instance().get_deployment_rollout_status(deployment) return KubernetesAccessorSingleton.get_instance().get_deployment_rollout_status(deployment).to_dict()
@resource('/v1/superuser/config/kubernetes') @resource('/v1/superuser/config/kubernetes')

View file

@ -13,6 +13,22 @@ logger = logging.getLogger(__name__)
QE_DEPLOYMENT_LABEL = 'quay-enterprise-component' QE_DEPLOYMENT_LABEL = 'quay-enterprise-component'
class _DeploymentRolloutStatus:
"""
Class containing response of the deployment rollout status method.
status is one of: 'failed' | 'progressing' | 'available'
message is any string describing the state.
"""
def __init__(self, status, message):
self.status = status
self.message = message
def to_dict(self):
return {
'status': self.status,
'message': self.message
}
class KubernetesAccessorSingleton(object): class KubernetesAccessorSingleton(object):
""" Singleton allowing access to kubernetes operations """ """ Singleton allowing access to kubernetes operations """
@ -98,11 +114,8 @@ class KubernetesAccessorSingleton(object):
def get_deployment_rollout_status(self, deployment_name): def get_deployment_rollout_status(self, deployment_name):
"""" """"
Returns the status of a rollout of a given deployment in the form: Returns the status of a rollout of a given deployment
{ :return _DeploymentRolloutStatus
'status': 'failed' | 'progressing' | 'available'
'message': <string>
}
""" """
deployment_selector_url = 'namespaces/%s/deployments/%s' % ( deployment_selector_url = 'namespaces/%s/deployments/%s' % (
self.kube_config.qe_namespace, deployment_name self.kube_config.qe_namespace, deployment_name
@ -110,7 +123,7 @@ class KubernetesAccessorSingleton(object):
response = self._execute_k8s_api('GET', deployment_selector_url, api_prefix='apis/apps/v1') response = self._execute_k8s_api('GET', deployment_selector_url, api_prefix='apis/apps/v1')
if response.status_code != 200: if response.status_code != 200:
return None return _DeploymentRolloutStatus('failed', 'Could not get deployment. Please check that the deployment exists')
deployment = json.loads(response.text) deployment = json.loads(response.text)
# Logic for rollout status pulled from the `kubectl rollout status` command: # Logic for rollout status pulled from the `kubectl rollout status` command:
@ -118,42 +131,47 @@ class KubernetesAccessorSingleton(object):
if deployment['metadata']['generation'] <= deployment['status']['observedGeneration']: if deployment['metadata']['generation'] <= deployment['status']['observedGeneration']:
for cond in deployment['status']['conditions']: for cond in deployment['status']['conditions']:
if cond['type'] == 'Progressing' and cond['reason'] == 'ProgressDeadlineExceeded': if cond['type'] == 'Progressing' and cond['reason'] == 'ProgressDeadlineExceeded':
return { return _DeploymentRolloutStatus(
'status': 'failed', 'failed',
'message': 'Deployment %s\'s rollout failed. Please try again later.' % deployment_name 'Deployment %s\'s rollout failed. Please try again later.' % deployment_name
} )
desired_replicas = deployment['spec']['replicas'] desired_replicas = deployment['spec']['replicas']
current_replicas = deployment['status']['replicas'] current_replicas = deployment['status'].get('replicas', 0)
if current_replicas == 0:
return _DeploymentRolloutStatus(
'available',
'Deployment %s updated (no replicas, so nothing to roll out)'
)
# Some fields are optional in the spec, so if they're omitted, replace with defaults that won't indicate a wrong status # Some fields are optional in the spec, so if they're omitted, replace with defaults that won't indicate a wrong status
available_replicas = deployment['status'].get('availableReplicas', 0) available_replicas = deployment['status'].get('availableReplicas', 0)
updated_replicas = deployment['status'].get('updatedReplicas', 0) updated_replicas = deployment['status'].get('updatedReplicas', 0)
if updated_replicas < desired_replicas: if updated_replicas < desired_replicas:
return { return _DeploymentRolloutStatus(
'status': 'progressing', 'progressing',
'message': 'Waiting for rollout to finish: %d out of %d new replicas have been updated...' % (updated_replicas, desired_replicas) 'Waiting for rollout to finish: %d out of %d new replicas have been updated...' % (updated_replicas, desired_replicas)
} )
if current_replicas > updated_replicas: if current_replicas > updated_replicas:
return { return _DeploymentRolloutStatus(
'status': 'progressing', 'progressing',
'message': 'Waiting for rollout to finish: %d old replicas are pending termination...' % (current_replicas - updated_replicas) 'Waiting for rollout to finish: %d old replicas are pending termination...' % (current_replicas - updated_replicas)
} )
if available_replicas < updated_replicas: if available_replicas < updated_replicas:
return { return _DeploymentRolloutStatus(
'status': 'progressing', 'progressing',
'message': 'Waiting for rollout to finish: %d of %d updated replicas are available...' % (available_replicas, updated_replicas) 'Waiting for rollout to finish: %d of %d updated replicas are available...' % (available_replicas, updated_replicas)
} )
return { return _DeploymentRolloutStatus(
'status': 'available', 'available',
'message': 'Deployment %s successfully rolled out.' % deployment_name 'Deployment %s successfully rolled out.' % deployment_name
} )
return { return _DeploymentRolloutStatus(
'status': 'progressing', 'progressing',
'message': 'Waiting for deployment spec to be updated...' 'Waiting for deployment spec to be updated...'
} )
def get_qe_deployments(self): def get_qe_deployments(self):
"""" """"
@ -187,10 +205,11 @@ class KubernetesAccessorSingleton(object):
'containers': [{ 'containers': [{
# Note: this name MUST match the deployment template's pod template # Note: this name MUST match the deployment template's pod template
# (e.g. <template>.spec.template.spec.containers[0] == 'quay-enterprise-app') # (e.g. <template>.spec.template.spec.containers[0] == 'quay-enterprise-app')
'name': 'quay-enterprise-app', 'env': [{ 'name': 'quay-enterprise-app',
'env': [{
'name': 'RESTART_TIME', 'name': 'RESTART_TIME',
'value': str(datetime.datetime.now()) 'value': str(datetime.datetime.now())
}] }],
}] }]
} }
} }

View file

@ -43,12 +43,16 @@
<code>{{deployment.name}}</code>: {{deployment.message || 'Waiting for deployment information...'}} <code>{{deployment.name}}</code>: {{deployment.message || 'Waiting for deployment information...'}}
</li> </li>
</div> </div>
<div ng-if="$ctrl.state === 'deployed'">
Configuration successfully deployed!
</div> </div>
</div> </div>
<div ng-if="$ctrl.state === 'error'"> <div ng-if="$ctrl.state === 'deployed'" class="modal-footer co-alert co-alert-success">
Configuration successfully rolled out and deployed!
<br>Note: The web interface of the Quay app may take a few minutes to come up.
</div>
<div ng-if="$ctrl.state === 'error'" class="modal-footer alert alert-danger">
{{ $ctrl.errorMessage }} {{ $ctrl.errorMessage }}
<div ng-if="$ctrl.offerRollback">
// todo
</div> </div>
</div> </div>
</div><!-- /.modal-content --> </div><!-- /.modal-content -->

View file

@ -1,4 +1,5 @@
import {Component, EventEmitter, Inject} from 'ng-metadata/core'; import {Component, EventEmitter, Inject, OnDestroy } from 'ng-metadata/core';
import {AngularPollChannel, PollHandle} from "../../services/services.types";
const templateUrl = require('./kube-deploy-modal.component.html'); const templateUrl = require('./kube-deploy-modal.component.html');
const styleUrl = require('./kube-deploy-modal.css'); const styleUrl = require('./kube-deploy-modal.css');
@ -8,12 +9,19 @@ type DeploymentRollout = {
message: string message: string
}; };
type DeploymentStatus = {
name: string,
numPods: number,
message?: string,
pollHandler?: PollHandle,
}
@Component({ @Component({
selector: 'kube-deploy-modal', selector: 'kube-deploy-modal',
templateUrl, templateUrl,
styleUrls: [ styleUrl ], styleUrls: [ styleUrl ],
}) })
export class KubeDeployModalComponent { export class KubeDeployModalComponent implements OnDestroy {
private state private state
: 'loadingDeployments' : 'loadingDeployments'
| 'readyToDeploy' | 'readyToDeploy'
@ -21,13 +29,13 @@ export class KubeDeployModalComponent {
| 'cyclingDeployments' | 'cyclingDeployments'
| 'deployed' | 'deployed'
| 'error'; | 'error';
private errorMessage: string; private errorMessage: string;
private offerRollback: boolean;
private deploymentsStatus: { name: string, numPods: number, message?: string }[] = []; private deploymentsStatus: DeploymentStatus[] = [];
private deploymentsCycled: number = 0; private deploymentsCycled: number = 0;
private onDestroyListeners: Function[] = [];
constructor(@Inject('ApiService') private ApiService) { constructor(@Inject('ApiService') private ApiService, @Inject('AngularPollChannel') private AngularPollChannel: AngularPollChannel) {
this.state = 'loadingDeployments'; this.state = 'loadingDeployments';
ApiService.scGetNumDeployments().then(resp => { ApiService.scGetNumDeployments().then(resp => {
@ -42,6 +50,14 @@ export class KubeDeployModalComponent {
}) })
} }
// Call all listeners of the onDestroy
ngOnDestroy(): any {
this.onDestroyListeners.forEach(fn => {
fn()
});
}
deployConfiguration(): void { deployConfiguration(): void {
this.ApiService.scDeployConfiguration().then(() => { this.ApiService.scDeployConfiguration().then(() => {
this.state = 'deployingConfiguration'; this.state = 'deployingConfiguration';
@ -62,34 +78,45 @@ export class KubeDeployModalComponent {
watchDeployments(): void { watchDeployments(): void {
this.deploymentsStatus.forEach(deployment => { this.deploymentsStatus.forEach(deployment => {
// Query each deployment every 500ms, and stop polling once it's either available or failed this.AngularPollChannel.create( {
const id: number = window.setInterval(() => { // Have to mock the scope object for the poll channel since we're calling into angular1 code
// We register the onDestroy function to be called later when this object is destroyed
'$on': (_, onDestruction) => { this.onDestroyListeners.push(onDestruction) }
}, this.getDeploymentStatus(deployment), 5000 /* 5 seconds */)
.start();
});
}
// Query each deployment every 5s, and stop polling once it's either available or failed
getDeploymentStatus(deployment: DeploymentStatus): (boolean) => void {
return (continue_callback: (shouldContinue: boolean) => void) => {
const params = { const params = {
'deployment': deployment.name 'deployment': deployment.name
}; };
this.ApiService.scGetDeploymentRolloutStatus(null, params).then((deploymentRollout: DeploymentRollout) => { this.ApiService.scGetDeploymentRolloutStatus(null, params).then((deploymentRollout: DeploymentRollout) => {
if (deploymentRollout.status === 'available') { if (deploymentRollout.status === 'available') {
window.clearInterval(id); continue_callback(false);
this.deploymentsCycled++; this.deploymentsCycled++;
if (this.deploymentsCycled === this.deploymentsStatus.length) { if (this.deploymentsCycled === this.deploymentsStatus.length) {
this.state = 'deployed'; this.state = 'deployed';
} }
} else if (deploymentRollout.status === 'progressing') { } else if (deploymentRollout.status === 'progressing') {
continue_callback(true);
deployment.message = deploymentRollout.message; deployment.message = deploymentRollout.message;
} else { // deployment rollout failed } else { // deployment rollout failed
window.clearInterval(id); this.state = 'error';
continue_callback(false);
deployment.message = deploymentRollout.message; deployment.message = deploymentRollout.message;
this.errorMessage = `Could not cycle deployments: ${deploymentRollout.message}`;
this.offerRollback = true;
} }
}).catch(err => { }).catch(err => {
window.clearInterval(id); continue_callback(false);
this.state = 'error'; this.state = 'error';
this.errorMessage = `Could not cycle the deployments with the new configuration. Error: ${err.toString()}`; this.errorMessage = `Could not cycle the deployments with the new configuration. Error: ${err.toString()}`;
}); });
}, 500); }
});
} }
} }

View file

@ -0,0 +1,107 @@
/**
* Specialized class for conducting an HTTP poll, while properly preventing multiple calls.
*/
angular.module('quay-config').factory('AngularPollChannel',
['ApiService', '$timeout', 'DocumentVisibilityService', 'CORE_EVENT', '$rootScope',
function(ApiService, $timeout, DocumentVisibilityService, CORE_EVENT, $rootScope) {
var _PollChannel = function(scope, requester, opt_sleeptime) {
this.scope_ = scope;
this.requester_ = requester;
this.sleeptime_ = opt_sleeptime || (60 * 1000 /* 60s */);
this.timer_ = null;
this.working = false;
this.polling = false;
this.skipping = false;
var that = this;
var visibilityHandler = $rootScope.$on(CORE_EVENT.DOC_VISIBILITY_CHANGE, function() {
// If the poll channel was skipping because the visibility was hidden, call it immediately.
if (that.skipping && !DocumentVisibilityService.isHidden()) {
that.call_();
}
});
scope.$on('$destroy', function() {
that.stop();
visibilityHandler();
});
};
_PollChannel.prototype.setSleepTime = function(sleepTime) {
this.sleeptime_ = sleepTime;
this.stop();
this.start(true);
};
_PollChannel.prototype.stop = function() {
if (this.timer_) {
$timeout.cancel(this.timer_);
this.timer_ = null;
this.polling = false;
}
this.skipping = false;
this.working = false;
};
_PollChannel.prototype.start = function(opt_skipFirstCall) {
// Make sure we invoke call outside the normal digest cycle, since
// we'll call $scope.$apply ourselves.
var that = this;
setTimeout(function() {
if (opt_skipFirstCall) {
that.setupTimer_();
return;
}
that.call_();
}, 0);
};
_PollChannel.prototype.call_ = function() {
if (this.working) { return; }
// If the document is currently hidden, skip the call.
if (DocumentVisibilityService.isHidden()) {
this.skipping = true;
this.setupTimer_();
return;
}
var that = this;
this.working = true;
$timeout(function() {
that.requester_(function(status) {
if (status) {
that.working = false;
that.skipping = false;
that.setupTimer_();
} else {
that.stop();
}
});
}, 0);
};
_PollChannel.prototype.setupTimer_ = function() {
if (this.timer_) { return; }
var that = this;
this.polling = true;
this.timer_ = $timeout(function() {
that.timer_ = null;
that.call_();
}, this.sleeptime_)
};
var service = {
'create': function(scope, requester, opt_sleeptime) {
return new _PollChannel(scope, requester, opt_sleeptime);
}
};
return service;
}]);

View file

@ -0,0 +1,60 @@
/**
* Helper service which fires off events when the document's visibility changes, as well as allowing
* other Angular code to query the state of the document's visibility directly.
*/
angular.module('quay-config').constant('CORE_EVENT', {
DOC_VISIBILITY_CHANGE: 'core.event.doc_visibility_change'
});
angular.module('quay-config').factory('DocumentVisibilityService', ['$rootScope', '$document', 'CORE_EVENT',
function($rootScope, $document, CORE_EVENT) {
var document = $document[0],
features,
detectedFeature;
function broadcastChangeEvent() {
$rootScope.$broadcast(CORE_EVENT.DOC_VISIBILITY_CHANGE,
document[detectedFeature.propertyName]);
}
features = {
standard: {
eventName: 'visibilitychange',
propertyName: 'hidden'
},
moz: {
eventName: 'mozvisibilitychange',
propertyName: 'mozHidden'
},
ms: {
eventName: 'msvisibilitychange',
propertyName: 'msHidden'
},
webkit: {
eventName: 'webkitvisibilitychange',
propertyName: 'webkitHidden'
}
};
Object.keys(features).some(function(feature) {
if (document[features[feature].propertyName] !== undefined) {
detectedFeature = features[feature];
return true;
}
});
if (detectedFeature) {
$document.on(detectedFeature.eventName, broadcastChangeEvent);
}
return {
/**
* Is the window currently hidden or not.
*/
isHidden: function() {
if (detectedFeature) {
return document[detectedFeature.propertyName];
}
}
};
}]);

View file

@ -0,0 +1,12 @@
export interface AngularPollChannel {
create(scope: { '$on': Function },
requester: (boolean) => void, // function that receives a callback to continue or halt polling
opt_sleeptime?: number
): PollHandle,
}
export interface PollHandle {
start(opt_skipFirstCall?: boolean): void,
stop(): void,
setSleepTime(sleepTime: number): void,
}

View file

@ -28,6 +28,8 @@
text-decoration: none; text-decoration: none;
} }
/* Overrides for fixing old quay styles*/
.quay-config-app .alert-danger { .quay-config-app .alert-danger {
padding: 25px; padding: 25px;
display: flex; display: flex;
@ -43,7 +45,22 @@
text-align: center; text-align: center;
} }
/* Overrides for fixing old quay styles*/ .quay-config-app .co-alert.co-alert-success {
padding: 25px;
display: flex;
margin-bottom: 0;
text-align: left;
}
.quay-config-app .co-alert.co-alert-success:before {
font-family: Font Awesome\ 5 Free;
font-weight: 900;
font-size: 30px;
padding-right: 15px;
color: green;
text-align: center;
position: static;
}
/* Fixes the transition to font awesome 5 */ /* Fixes the transition to font awesome 5 */