Use a FSM to determine application next state

- Separate cluster application UI state from server-side app status
- Use a state machine to determine cluster application next state
- Instead of using two variables to keep track of when an app
is installing or updating, just use the app status property and control
server-side and user events using the FSM service.
This commit is contained in:
Enrique Alcántara 2019-04-26 08:15:44 +00:00 committed by Phil Hughes
parent 336a0a8745
commit 690382dda6
11 changed files with 480 additions and 227 deletions

View File

@ -7,15 +7,7 @@ import Flash from '../flash';
import Poll from '../lib/utils/poll';
import initSettingsPanels from '../settings_panels';
import eventHub from './event_hub';
import {
APPLICATION_STATUS,
REQUEST_SUBMITTED,
REQUEST_FAILURE,
UPGRADE_REQUESTED,
UPGRADE_REQUEST_FAILURE,
INGRESS,
INGRESS_DOMAIN_SUFFIX,
} from './constants';
import { APPLICATION_STATUS, INGRESS, INGRESS_DOMAIN_SUFFIX } from './constants';
import ClustersService from './services/clusters_service';
import ClustersStore from './stores/clusters_store';
import Applications from './components/applications.vue';
@ -137,7 +129,7 @@ export default class Clusters {
if (this.showTokenButton) this.showTokenButton.addEventListener('click', this.showToken);
eventHub.$on('installApplication', this.installApplication);
eventHub.$on('upgradeApplication', data => this.upgradeApplication(data));
eventHub.$on('upgradeFailed', appId => this.upgradeFailed(appId));
eventHub.$on('dismissUpgradeSuccess', appId => this.dismissUpgradeSuccess(appId));
eventHub.$on('saveKnativeDomain', data => this.saveKnativeDomain(data));
eventHub.$on('setKnativeHostname', data => this.setKnativeHostname(data));
}
@ -146,7 +138,7 @@ export default class Clusters {
if (this.showTokenButton) this.showTokenButton.removeEventListener('click', this.showToken);
eventHub.$off('installApplication', this.installApplication);
eventHub.$off('upgradeApplication', this.upgradeApplication);
eventHub.$off('upgradeFailed', this.upgradeFailed);
eventHub.$off('dismissUpgradeSuccess', this.dismissUpgradeSuccess);
eventHub.$off('saveKnativeDomain');
eventHub.$off('setKnativeHostname');
}
@ -259,12 +251,13 @@ export default class Clusters {
installApplication(data) {
const appId = data.id;
this.store.updateAppProperty(appId, 'requestStatus', REQUEST_SUBMITTED);
this.store.updateAppProperty(appId, 'requestReason', null);
this.store.updateAppProperty(appId, 'statusReason', null);
this.store.installApplication(appId);
return this.service.installApplication(appId, data.params).catch(() => {
this.store.updateAppProperty(appId, 'requestStatus', REQUEST_FAILURE);
this.store.notifyInstallFailure(appId);
this.store.updateAppProperty(
appId,
'requestReason',
@ -275,13 +268,15 @@ export default class Clusters {
upgradeApplication(data) {
const appId = data.id;
this.store.updateAppProperty(appId, 'requestStatus', UPGRADE_REQUESTED);
this.store.updateAppProperty(appId, 'status', APPLICATION_STATUS.UPDATING);
this.service.installApplication(appId, data.params).catch(() => this.upgradeFailed(appId));
this.store.updateApplication(appId);
this.service.installApplication(appId, data.params).catch(() => {
this.store.notifyUpdateFailure(appId);
});
}
upgradeFailed(appId) {
this.store.updateAppProperty(appId, 'requestStatus', UPGRADE_REQUEST_FAILURE);
dismissUpgradeSuccess(appId) {
this.store.acknowledgeSuccessfulUpdate(appId);
}
toggleIngressDomainHelpText(ingressPreviousState, ingressNewState) {

View File

@ -8,12 +8,7 @@ import identicon from '../../vue_shared/components/identicon.vue';
import loadingButton from '../../vue_shared/components/loading_button.vue';
import UninstallApplicationButton from './uninstall_application_button.vue';
import {
APPLICATION_STATUS,
REQUEST_SUBMITTED,
REQUEST_FAILURE,
UPGRADE_REQUESTED,
} from '../constants';
import { APPLICATION_STATUS } from '../constants';
export default {
components: {
@ -63,10 +58,6 @@ export default {
type: String,
required: false,
},
requestStatus: {
type: String,
required: false,
},
requestReason: {
type: String,
required: false,
@ -76,6 +67,11 @@ export default {
required: false,
default: false,
},
installFailed: {
type: Boolean,
required: false,
default: false,
},
version: {
type: String,
required: false,
@ -88,6 +84,21 @@ export default {
type: Boolean,
required: false,
},
updateSuccessful: {
type: Boolean,
required: false,
default: false,
},
updateFailed: {
type: Boolean,
required: false,
default: false,
},
updateAcknowledged: {
type: Boolean,
required: false,
default: true,
},
installApplicationRequestParams: {
type: Object,
required: false,
@ -102,21 +113,12 @@ export default {
return Object.values(APPLICATION_STATUS).includes(this.status);
},
isInstalling() {
return (
this.status === APPLICATION_STATUS.SCHEDULED ||
this.status === APPLICATION_STATUS.INSTALLING ||
(this.requestStatus === REQUEST_SUBMITTED && !this.statusReason && !this.installed)
);
return this.status === APPLICATION_STATUS.INSTALLING;
},
canInstall() {
if (this.isInstalling) {
return false;
}
return (
this.status === APPLICATION_STATUS.NOT_INSTALLABLE ||
this.status === APPLICATION_STATUS.INSTALLABLE ||
this.status === APPLICATION_STATUS.ERROR ||
this.isUnknownStatus
);
},
@ -137,7 +139,7 @@ export default {
return !this.installed || !this.uninstallable;
},
installButtonLoading() {
return !this.status || this.status === APPLICATION_STATUS.SCHEDULED || this.isInstalling;
return !this.status || this.isInstalling;
},
installButtonDisabled() {
// Avoid the potential for the real-time data to say APPLICATION_STATUS.INSTALLABLE but
@ -168,19 +170,13 @@ export default {
manageButtonLabel() {
return s__('ClusterIntegration|Manage');
},
hasError() {
return (
!this.isInstalling &&
(this.status === APPLICATION_STATUS.ERROR || this.requestStatus === REQUEST_FAILURE)
);
},
generalErrorDescription() {
return sprintf(s__('ClusterIntegration|Something went wrong while installing %{title}'), {
title: this.title,
});
},
versionLabel() {
if (this.upgradeFailed) {
if (this.updateFailed) {
return s__('ClusterIntegration|Upgrade failed');
} else if (this.isUpgrading) {
return s__('ClusterIntegration|Upgrading');
@ -188,19 +184,6 @@ export default {
return s__('ClusterIntegration|Upgraded');
},
upgradeRequested() {
return this.requestStatus === UPGRADE_REQUESTED;
},
upgradeSuccessful() {
return this.status === APPLICATION_STATUS.UPDATED;
},
upgradeFailed() {
if (this.isUpgrading) {
return false;
}
return this.status === APPLICATION_STATUS.UPDATE_ERRORED;
},
upgradeFailureDescription() {
return s__('ClusterIntegration|Update failed. Please check the logs and try again.');
},
@ -211,11 +194,11 @@ export default {
},
upgradeButtonLabel() {
let label;
if (this.upgradeAvailable && !this.upgradeFailed && !this.isUpgrading) {
if (this.upgradeAvailable && !this.updateFailed && !this.isUpgrading) {
label = s__('ClusterIntegration|Upgrade');
} else if (this.isUpgrading) {
label = s__('ClusterIntegration|Updating');
} else if (this.upgradeFailed) {
} else if (this.updateFailed) {
label = s__('ClusterIntegration|Retry update');
}
@ -223,25 +206,18 @@ export default {
},
isUpgrading() {
// Since upgrading is handled asynchronously on the backend we need this check to prevent any delay on the frontend
return (
this.status === APPLICATION_STATUS.UPDATING ||
(this.upgradeRequested && !this.upgradeSuccessful)
);
return this.status === APPLICATION_STATUS.UPDATING;
},
shouldShowUpgradeDetails() {
// This method only returns true when;
// Upgrade was successful OR Upgrade failed
// AND new upgrade is unavailable AND version information is present.
return (
(this.upgradeSuccessful || this.upgradeFailed) && !this.upgradeAvailable && this.version
);
return (this.updateSuccessful || this.updateFailed) && !this.upgradeAvailable && this.version;
},
},
watch: {
status() {
if (this.status === APPLICATION_STATUS.UPDATE_ERRORED) {
eventHub.$emit('upgradeFailed', this.id);
} else if (this.upgradeRequested && this.upgradeSuccessful) {
updateSuccessful() {
if (this.updateSuccessful) {
this.$toast.show(this.upgradeSuccessDescription);
}
},
@ -296,7 +272,7 @@ export default {
</strong>
<slot name="description"></slot>
<div
v-if="hasError || isUnknownStatus"
v-if="installFailed || isUnknownStatus"
class="cluster-application-error text-danger prepend-top-10"
>
<p class="js-cluster-application-general-error-message append-bottom-0">
@ -317,10 +293,10 @@ export default {
class="form-text text-muted label p-0 js-cluster-application-upgrade-details"
>
{{ versionLabel }}
<span v-if="upgradeSuccessful">to</span>
<span v-if="updateSuccessful">to</span>
<gl-link
v-if="upgradeSuccessful"
v-if="updateSuccessful"
:href="chartRepo"
target="_blank"
class="js-cluster-application-upgrade-version"
@ -329,13 +305,13 @@ export default {
</div>
<div
v-if="upgradeFailed && !isUpgrading"
v-if="updateFailed && !isUpgrading"
class="bs-callout bs-callout-danger cluster-application-banner mt-2 mb-0 js-cluster-application-upgrade-failure-message"
>
{{ upgradeFailureDescription }}
</div>
<loading-button
v-if="upgradeAvailable || upgradeFailed || isUpgrading"
v-if="upgradeAvailable || updateFailed || isUpgrading"
class="btn btn-primary js-cluster-application-upgrade-button mt-2"
:loading="isUpgrading"
:disabled="isUpgrading"
@ -349,9 +325,9 @@ export default {
role="gridcell"
>
<div v-if="showManageButton" class="btn-group table-action-buttons">
<a :href="manageLink" :class="{ disabled: disabled }" class="btn">{{
manageButtonLabel
}}</a>
<a :href="manageLink" :class="{ disabled: disabled }" class="btn">
{{ manageButtonLabel }}
</a>
</div>
<div class="btn-group table-action-buttons">
<loading-button

View File

@ -224,9 +224,9 @@ export default {
<p class="append-bottom-0">
{{
s__(`ClusterIntegration|Choose which applications to install on your Kubernetes cluster.
Helm Tiller is required to install any of the following applications.`)
Helm Tiller is required to install any of the following applications.`)
}}
<a :href="helpPath"> {{ __('More information') }} </a>
<a :href="helpPath">{{ __('More information') }}</a>
</p>
<div class="cluster-application-list prepend-top-10">
@ -239,15 +239,16 @@ export default {
:request-status="applications.helm.requestStatus"
:request-reason="applications.helm.requestReason"
:installed="applications.helm.installed"
:install-failed="applications.helm.installFailed"
class="rounded-top"
title-link="https://docs.helm.sh/"
>
<div slot="description">
{{
s__(`ClusterIntegration|Helm streamlines installing
and managing Kubernetes applications.
Tiller runs inside of your Kubernetes Cluster,
and manages releases of your charts.`)
and managing Kubernetes applications.
Tiller runs inside of your Kubernetes Cluster,
and manages releases of your charts.`)
}}
</div>
</application-row>
@ -255,7 +256,7 @@ export default {
<div class="svg-container" v-html="helmInstallIllustration"></div>
{{
s__(`ClusterIntegration|You must first install Helm Tiller before
installing the applications below`)
installing the applications below`)
}}
</div>
<application-row
@ -267,6 +268,7 @@ export default {
:request-status="applications.ingress.requestStatus"
:request-reason="applications.ingress.requestReason"
:installed="applications.ingress.installed"
:install-failed="applications.ingress.installFailed"
:disabled="!helmInstalled"
title-link="https://kubernetes.io/docs/concepts/services-networking/ingress/"
>
@ -274,16 +276,14 @@ export default {
<p>
{{
s__(`ClusterIntegration|Ingress gives you a way to route
requests to services based on the request host or path,
centralizing a number of services into a single entrypoint.`)
requests to services based on the request host or path,
centralizing a number of services into a single entrypoint.`)
}}
</p>
<template v-if="ingressInstalled">
<div class="form-group">
<label for="ingress-endpoint">
{{ s__('ClusterIntegration|Ingress Endpoint') }}
</label>
<label for="ingress-endpoint">{{ s__('ClusterIntegration|Ingress Endpoint') }}</label>
<div v-if="ingressExternalEndpoint" class="input-group">
<input
id="ingress-endpoint"
@ -309,8 +309,8 @@ export default {
<p class="form-text text-muted">
{{
s__(`ClusterIntegration|Point a wildcard DNS to this
generated endpoint in order to access
your application after it has been deployed.`)
generated endpoint in order to access
your application after it has been deployed.`)
}}
<a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">
{{ __('More information') }}
@ -321,10 +321,9 @@ export default {
<p v-if="!ingressExternalEndpoint" class="settings-message js-no-endpoint-message">
{{
s__(`ClusterIntegration|The endpoint is in
the process of being assigned. Please check your Kubernetes
cluster or Quotas on Google Kubernetes Engine if it takes a long time.`)
the process of being assigned. Please check your Kubernetes
cluster or Quotas on Google Kubernetes Engine if it takes a long time.`)
}}
<a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">
{{ __('More information') }}
</a>
@ -344,6 +343,7 @@ export default {
:request-status="applications.cert_manager.requestStatus"
:request-reason="applications.cert_manager.requestReason"
:installed="applications.cert_manager.installed"
:install-failed="applications.cert_manager.installFailed"
:install-application-request-params="{ email: applications.cert_manager.email }"
:disabled="!helmInstalled"
title-link="https://cert-manager.readthedocs.io/en/latest/#"
@ -366,15 +366,14 @@ export default {
<p class="form-text text-muted">
{{
s__(`ClusterIntegration|Issuers represent a certificate authority.
You must provide an email address for your Issuer. `)
You must provide an email address for your Issuer. `)
}}
<a
href="http://docs.cert-manager.io/en/latest/reference/issuers.html?highlight=email"
target="_blank"
rel="noopener noreferrer"
>{{ __('More information') }}</a
>
{{ __('More information') }}
</a>
</p>
</div>
</div>
@ -391,6 +390,7 @@ export default {
:request-status="applications.prometheus.requestStatus"
:request-reason="applications.prometheus.requestReason"
:installed="applications.prometheus.installed"
:install-failed="applications.prometheus.installFailed"
:disabled="!helmInstalled"
title-link="https://prometheus.io/docs/introduction/overview/"
>
@ -408,15 +408,18 @@ export default {
:chart-repo="applications.runner.chartRepo"
:upgrade-available="applications.runner.upgradeAvailable"
:installed="applications.runner.installed"
:install-failed="applications.runner.installFailed"
:update-successful="applications.runner.updateSuccessful"
:update-failed="applications.runner.updateFailed"
:disabled="!helmInstalled"
title-link="https://docs.gitlab.com/runner/"
>
<div slot="description">
{{
s__(`ClusterIntegration|GitLab Runner connects to the
repository and executes CI/CD jobs,
pushing results back and deploying
applications to production.`)
repository and executes CI/CD jobs,
pushing results back and deploying
applications to production.`)
}}
</div>
</application-row>
@ -430,6 +433,7 @@ export default {
:request-status="applications.jupyter.requestStatus"
:request-reason="applications.jupyter.requestReason"
:installed="applications.jupyter.installed"
:install-failed="applications.jupyter.installFailed"
:install-application-request-params="{ hostname: applications.jupyter.hostname }"
:disabled="!helmInstalled"
title-link="https://jupyterhub.readthedocs.io/en/stable/"
@ -438,18 +442,16 @@ export default {
<p>
{{
s__(`ClusterIntegration|JupyterHub, a multi-user Hub, spawns,
manages, and proxies multiple instances of the single-user
Jupyter notebook server. JupyterHub can be used to serve
notebooks to a class of students, a corporate data science group,
or a scientific research group.`)
manages, and proxies multiple instances of the single-user
Jupyter notebook server. JupyterHub can be used to serve
notebooks to a class of students, a corporate data science group,
or a scientific research group.`)
}}
</p>
<template v-if="ingressExternalEndpoint">
<div class="form-group">
<label for="jupyter-hostname">
{{ s__('ClusterIntegration|Jupyter Hostname') }}
</label>
<label for="jupyter-hostname">{{ s__('ClusterIntegration|Jupyter Hostname') }}</label>
<div class="input-group">
<input
@ -470,7 +472,7 @@ export default {
<p v-if="ingressInstalled" class="form-text text-muted">
{{
s__(`ClusterIntegration|Replace this with your own hostname if you want.
If you do so, point hostname to Ingress IP Address from above.`)
If you do so, point hostname to Ingress IP Address from above.`)
}}
<a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">
{{ __('More information') }}
@ -490,8 +492,10 @@ export default {
:request-status="applications.knative.requestStatus"
:request-reason="applications.knative.requestReason"
:installed="applications.knative.installed"
:install-failed="applications.knative.installFailed"
:install-application-request-params="{ hostname: applications.knative.hostname }"
:disabled="!helmInstalled"
v-bind="applications.knative"
title-link="https://github.com/knative/docs"
>
<div slot="description">
@ -499,7 +503,7 @@ export default {
<p v-if="!rbac" class="rbac-notice bs-callout bs-callout-info append-bottom-0">
{{
s__(`ClusterIntegration|You must have an RBAC-enabled cluster
to install Knative.`)
to install Knative.`)
}}
<a :href="helpPath" target="_blank" rel="noopener noreferrer">
{{ __('More information') }}
@ -510,9 +514,9 @@ export default {
<p>
{{
s__(`ClusterIntegration|Knative extends Kubernetes to provide
a set of middleware components that are essential to build modern,
source-centric, and container-based applications that can run
anywhere: on premises, in the cloud, or even in a third-party data center.`)
a set of middleware components that are essential to build modern,
source-centric, and container-based applications that can run
anywhere: on premises, in the cloud, or even in a third-party data center.`)
}}
</p>
@ -523,9 +527,7 @@ export default {
class="form-group col-sm-12 mb-0"
>
<label for="knative-domainname">
<strong>
{{ s__('ClusterIntegration|Knative Domain Name:') }}
</strong>
<strong>{{ s__('ClusterIntegration|Knative Domain Name:') }}</strong>
</label>
<input
id="knative-domainname"
@ -538,9 +540,7 @@ export default {
<template v-if="knativeInstalled">
<div class="form-group col-sm-12 col-md-6 pl-md-0 mb-0 mt-3 mt-md-0">
<label for="knative-endpoint">
<strong>
{{ s__('ClusterIntegration|Knative Endpoint:') }}
</strong>
<strong>{{ s__('ClusterIntegration|Knative Endpoint:') }}</strong>
</label>
<div v-if="knativeExternalEndpoint" class="input-group">
<input
@ -583,8 +583,8 @@ export default {
>
{{
s__(`ClusterIntegration|The endpoint is in
the process of being assigned. Please check your Kubernetes
cluster or Quotas on Google Kubernetes Engine if it takes a long time.`)
the process of being assigned. Please check your Kubernetes
cluster or Quotas on Google Kubernetes Engine if it takes a long time.`)
}}
</p>

View File

@ -7,6 +7,7 @@ export const CLUSTER_TYPE = {
// These need to match what is returned from the server
export const APPLICATION_STATUS = {
NO_STATUS: null,
NOT_INSTALLABLE: 'not_installable',
INSTALLABLE: 'installable',
SCHEDULED: 'scheduled',
@ -27,17 +28,13 @@ export const APPLICATION_STATUS = {
export const APPLICATION_INSTALLED_STATUSES = [
APPLICATION_STATUS.INSTALLED,
APPLICATION_STATUS.UPDATING,
APPLICATION_STATUS.UPDATED,
APPLICATION_STATUS.UPDATE_ERRORED,
APPLICATION_STATUS.UNINSTALLING,
APPLICATION_STATUS.UNINSTALL_ERRORED,
];
// These are only used client-side
export const REQUEST_SUBMITTED = 'request-submitted';
export const REQUEST_FAILURE = 'request-failure';
export const UPGRADE_REQUESTED = 'upgrade-requested';
export const UPGRADE_REQUEST_FAILURE = 'upgrade-request-failure';
export const UPDATE_EVENT = 'update';
export const INSTALL_EVENT = 'install';
export const INGRESS = 'ingress';
export const JUPYTER = 'jupyter';
export const KNATIVE = 'knative';

View File

@ -0,0 +1,141 @@
import { APPLICATION_STATUS, UPDATE_EVENT, INSTALL_EVENT } from '../constants';
const {
NO_STATUS,
SCHEDULED,
NOT_INSTALLABLE,
INSTALLABLE,
INSTALLING,
INSTALLED,
ERROR,
UPDATING,
UPDATED,
UPDATE_ERRORED,
} = APPLICATION_STATUS;
const applicationStateMachine = {
/* When the application initially loads, it will have `NO_STATUS`
* It will transition from `NO_STATUS` once the async backend call is completed
*/
[NO_STATUS]: {
on: {
[SCHEDULED]: {
target: INSTALLING,
},
[NOT_INSTALLABLE]: {
target: NOT_INSTALLABLE,
},
[INSTALLABLE]: {
target: INSTALLABLE,
},
[INSTALLING]: {
target: INSTALLING,
},
[INSTALLED]: {
target: INSTALLED,
},
[ERROR]: {
target: INSTALLABLE,
effects: {
installFailed: true,
},
},
[UPDATING]: {
target: UPDATING,
},
[UPDATED]: {
target: INSTALLED,
},
[UPDATE_ERRORED]: {
target: INSTALLED,
effects: {
updateFailed: true,
},
},
},
},
[NOT_INSTALLABLE]: {
on: {
[INSTALLABLE]: {
target: INSTALLABLE,
},
},
},
[INSTALLABLE]: {
on: {
[INSTALL_EVENT]: {
target: INSTALLING,
effects: {
installFailed: false,
},
},
// This is possible in artificial environments for E2E testing
[INSTALLED]: {
target: INSTALLED,
},
},
},
[INSTALLING]: {
on: {
[INSTALLED]: {
target: INSTALLED,
},
[ERROR]: {
target: INSTALLABLE,
effects: {
installFailed: true,
},
},
},
},
[INSTALLED]: {
on: {
[UPDATE_EVENT]: {
target: UPDATING,
effects: {
updateFailed: false,
updateSuccessful: false,
},
},
},
},
[UPDATING]: {
on: {
[UPDATED]: {
target: INSTALLED,
effects: {
updateSuccessful: true,
updateAcknowledged: false,
},
},
[UPDATE_ERRORED]: {
target: INSTALLED,
effects: {
updateFailed: true,
},
},
},
},
};
/**
* Determines an application new state based on the application current state
* and an event. If the application current state cannot handle a given event,
* the current state is returned.
*
* @param {*} application
* @param {*} event
*/
const transitionApplicationState = (application, event) => {
const newState = applicationStateMachine[application.status].on[event];
return newState
? {
...application,
status: newState.target,
...newState.effects,
}
: application;
};
export default transitionApplicationState;

View File

@ -7,7 +7,11 @@ import {
CERT_MANAGER,
RUNNER,
APPLICATION_INSTALLED_STATUSES,
APPLICATION_STATUS,
INSTALL_EVENT,
UPDATE_EVENT,
} from '../constants';
import transitionApplicationState from '../services/application_state_machine';
const isApplicationInstalled = appStatus => APPLICATION_INSTALLED_STATUSES.includes(appStatus);
@ -15,8 +19,8 @@ const applicationInitialState = {
status: null,
statusReason: null,
requestReason: null,
requestStatus: null,
installed: false,
installFailed: false,
};
export default class ClusterStore {
@ -49,6 +53,9 @@ export default class ClusterStore {
version: null,
chartRepo: 'https://gitlab.com/charts/gitlab-runner',
upgradeAvailable: null,
updateAcknowledged: true,
updateSuccessful: false,
updateFailed: false,
},
prometheus: {
...applicationInitialState,
@ -93,6 +100,32 @@ export default class ClusterStore {
this.state.statusReason = reason;
}
installApplication(appId) {
this.handleApplicationEvent(appId, INSTALL_EVENT);
}
notifyInstallFailure(appId) {
this.handleApplicationEvent(appId, APPLICATION_STATUS.ERROR);
}
updateApplication(appId) {
this.handleApplicationEvent(appId, UPDATE_EVENT);
}
notifyUpdateFailure(appId) {
this.handleApplicationEvent(appId, APPLICATION_STATUS.UPDATE_ERRORED);
}
handleApplicationEvent(appId, event) {
const currentAppState = this.state.applications[appId];
this.state.applications[appId] = transitionApplicationState(currentAppState, event);
}
acknowledgeSuccessfulUpdate(appId) {
this.state.applications[appId].updateAcknowledged = true;
}
updateAppProperty(appId, prop, value) {
this.state.applications[appId][prop] = value;
}
@ -109,12 +142,16 @@ export default class ClusterStore {
version,
update_available: upgradeAvailable,
} = serverAppEntry;
const currentApplicationState = this.state.applications[appId] || {};
const nextApplicationState = transitionApplicationState(currentApplicationState, status);
this.state.applications[appId] = {
...(this.state.applications[appId] || {}),
status,
...currentApplicationState,
...nextApplicationState,
statusReason,
installed: isApplicationInstalled(status),
installed: isApplicationInstalled(nextApplicationState.status),
// Make sure uninstallable is always false until this feature is unflagged
uninstallable: false,
};
if (appId === INGRESS) {

View File

@ -1,16 +1,13 @@
import Clusters from '~/clusters/clusters_bundle';
import {
REQUEST_SUBMITTED,
REQUEST_FAILURE,
APPLICATION_STATUS,
INGRESS_DOMAIN_SUFFIX,
} from '~/clusters/constants';
import { APPLICATION_STATUS, INGRESS_DOMAIN_SUFFIX } from '~/clusters/constants';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import { loadHTMLFixture } from 'helpers/fixtures';
import { setTestTimeout } from 'helpers/timeout';
import $ from 'jquery';
const { INSTALLING, INSTALLABLE, INSTALLED, NOT_INSTALLABLE } = APPLICATION_STATUS;
describe('Clusters', () => {
setTestTimeout(1000);
@ -93,7 +90,7 @@ describe('Clusters', () => {
it('does not show alert when things transition from initial null state to something', () => {
cluster.checkForNewInstalls(INITIAL_APP_MAP, {
...INITIAL_APP_MAP,
helm: { status: APPLICATION_STATUS.INSTALLABLE, title: 'Helm Tiller' },
helm: { status: INSTALLABLE, title: 'Helm Tiller' },
});
const flashMessage = document.querySelector('.js-cluster-application-notice .flash-text');
@ -105,11 +102,11 @@ describe('Clusters', () => {
cluster.checkForNewInstalls(
{
...INITIAL_APP_MAP,
helm: { status: APPLICATION_STATUS.INSTALLING, title: 'Helm Tiller' },
helm: { status: INSTALLING, title: 'Helm Tiller' },
},
{
...INITIAL_APP_MAP,
helm: { status: APPLICATION_STATUS.INSTALLED, title: 'Helm Tiller' },
helm: { status: INSTALLED, title: 'Helm Tiller' },
},
);
@ -125,13 +122,13 @@ describe('Clusters', () => {
cluster.checkForNewInstalls(
{
...INITIAL_APP_MAP,
helm: { status: APPLICATION_STATUS.INSTALLING, title: 'Helm Tiller' },
ingress: { status: APPLICATION_STATUS.INSTALLABLE, title: 'Ingress' },
helm: { status: INSTALLING, title: 'Helm Tiller' },
ingress: { status: INSTALLABLE, title: 'Ingress' },
},
{
...INITIAL_APP_MAP,
helm: { status: APPLICATION_STATUS.INSTALLED, title: 'Helm Tiller' },
ingress: { status: APPLICATION_STATUS.INSTALLED, title: 'Ingress' },
helm: { status: INSTALLED, title: 'Helm Tiller' },
ingress: { status: INSTALLED, title: 'Ingress' },
},
);
@ -218,11 +215,11 @@ describe('Clusters', () => {
it('tries to install helm', () => {
jest.spyOn(cluster.service, 'installApplication').mockResolvedValueOnce();
expect(cluster.store.state.applications.helm.requestStatus).toEqual(null);
cluster.store.state.applications.helm.status = INSTALLABLE;
cluster.installApplication({ id: 'helm' });
expect(cluster.store.state.applications.helm.requestStatus).toEqual(REQUEST_SUBMITTED);
expect(cluster.store.state.applications.helm.status).toEqual(INSTALLING);
expect(cluster.store.state.applications.helm.requestReason).toEqual(null);
expect(cluster.service.installApplication).toHaveBeenCalledWith('helm', undefined);
});
@ -230,11 +227,11 @@ describe('Clusters', () => {
it('tries to install ingress', () => {
jest.spyOn(cluster.service, 'installApplication').mockResolvedValueOnce();
expect(cluster.store.state.applications.ingress.requestStatus).toEqual(null);
cluster.store.state.applications.ingress.status = INSTALLABLE;
cluster.installApplication({ id: 'ingress' });
expect(cluster.store.state.applications.ingress.requestStatus).toEqual(REQUEST_SUBMITTED);
expect(cluster.store.state.applications.ingress.status).toEqual(INSTALLING);
expect(cluster.store.state.applications.ingress.requestReason).toEqual(null);
expect(cluster.service.installApplication).toHaveBeenCalledWith('ingress', undefined);
});
@ -242,11 +239,11 @@ describe('Clusters', () => {
it('tries to install runner', () => {
jest.spyOn(cluster.service, 'installApplication').mockResolvedValueOnce();
expect(cluster.store.state.applications.runner.requestStatus).toEqual(null);
cluster.store.state.applications.runner.status = INSTALLABLE;
cluster.installApplication({ id: 'runner' });
expect(cluster.store.state.applications.runner.requestStatus).toEqual(REQUEST_SUBMITTED);
expect(cluster.store.state.applications.runner.status).toEqual(INSTALLING);
expect(cluster.store.state.applications.runner.requestReason).toEqual(null);
expect(cluster.service.installApplication).toHaveBeenCalledWith('runner', undefined);
});
@ -254,13 +251,12 @@ describe('Clusters', () => {
it('tries to install jupyter', () => {
jest.spyOn(cluster.service, 'installApplication').mockResolvedValueOnce();
expect(cluster.store.state.applications.jupyter.requestStatus).toEqual(null);
cluster.installApplication({
id: 'jupyter',
params: { hostname: cluster.store.state.applications.jupyter.hostname },
});
expect(cluster.store.state.applications.jupyter.requestStatus).toEqual(REQUEST_SUBMITTED);
cluster.store.state.applications.jupyter.status = INSTALLABLE;
expect(cluster.store.state.applications.jupyter.requestReason).toEqual(null);
expect(cluster.service.installApplication).toHaveBeenCalledWith('jupyter', {
hostname: cluster.store.state.applications.jupyter.hostname,
@ -272,16 +268,18 @@ describe('Clusters', () => {
.spyOn(cluster.service, 'installApplication')
.mockRejectedValueOnce(new Error('STUBBED ERROR'));
expect(cluster.store.state.applications.helm.requestStatus).toEqual(null);
cluster.store.state.applications.helm.status = INSTALLABLE;
const promise = cluster.installApplication({ id: 'helm' });
expect(cluster.store.state.applications.helm.requestStatus).toEqual(REQUEST_SUBMITTED);
expect(cluster.store.state.applications.helm.status).toEqual(INSTALLING);
expect(cluster.store.state.applications.helm.requestReason).toEqual(null);
expect(cluster.service.installApplication).toHaveBeenCalled();
return promise.then(() => {
expect(cluster.store.state.applications.helm.requestStatus).toEqual(REQUEST_FAILURE);
expect(cluster.store.state.applications.helm.status).toEqual(INSTALLABLE);
expect(cluster.store.state.applications.helm.installFailed).toBe(true);
expect(cluster.store.state.applications.helm.requestReason).toBeDefined();
});
});
@ -315,7 +313,6 @@ describe('Clusters', () => {
});
describe('toggleIngressDomainHelpText', () => {
const { INSTALLED, INSTALLABLE, NOT_INSTALLABLE } = APPLICATION_STATUS;
let ingressPreviousState;
let ingressNewState;

View File

@ -1,11 +1,6 @@
import Vue from 'vue';
import eventHub from '~/clusters/event_hub';
import {
APPLICATION_STATUS,
REQUEST_SUBMITTED,
REQUEST_FAILURE,
UPGRADE_REQUESTED,
} from '~/clusters/constants';
import { APPLICATION_STATUS } from '~/clusters/constants';
import applicationRow from '~/clusters/components/application_row.vue';
import mountComponent from 'helpers/vue_mount_component_helper';
import { DEFAULT_APPLICATION_STATE } from '../services/mock_data';
@ -85,17 +80,6 @@ describe('Application Row', () => {
expect(vm.installButtonDisabled).toEqual(false);
});
it('has loading "Installing" when APPLICATION_STATUS.SCHEDULED', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
status: APPLICATION_STATUS.SCHEDULED,
});
expect(vm.installButtonLabel).toEqual('Installing');
expect(vm.installButtonLoading).toEqual(true);
expect(vm.installButtonDisabled).toEqual(true);
});
it('has loading "Installing" when APPLICATION_STATUS.INSTALLING', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
@ -107,18 +91,6 @@ describe('Application Row', () => {
expect(vm.installButtonDisabled).toEqual(true);
});
it('has loading "Installing" when REQUEST_SUBMITTED', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
status: APPLICATION_STATUS.INSTALLABLE,
requestStatus: REQUEST_SUBMITTED,
});
expect(vm.installButtonLabel).toEqual('Installing');
expect(vm.installButtonLoading).toEqual(true);
expect(vm.installButtonDisabled).toEqual(true);
});
it('has disabled "Installed" when application is installed and not uninstallable', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
@ -144,10 +116,11 @@ describe('Application Row', () => {
expect(installBtn).toBe(null);
});
it('has enabled "Install" when APPLICATION_STATUS.ERROR', () => {
it('has enabled "Install" when install fails', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
status: APPLICATION_STATUS.ERROR,
status: APPLICATION_STATUS.INSTALLABLE,
installFailed: true,
});
expect(vm.installButtonLabel).toEqual('Install');
@ -159,7 +132,6 @@ describe('Application Row', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
status: APPLICATION_STATUS.INSTALLABLE,
requestStatus: REQUEST_FAILURE,
});
expect(vm.installButtonLabel).toEqual('Install');
@ -251,15 +223,15 @@ describe('Application Row', () => {
expect(upgradeBtn.innerHTML).toContain('Upgrade');
});
it('has enabled "Retry update" when APPLICATION_STATUS.UPDATE_ERRORED', () => {
it('has enabled "Retry update" when update process fails', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
status: APPLICATION_STATUS.UPDATE_ERRORED,
status: APPLICATION_STATUS.INSTALLED,
updateFailed: true,
});
const upgradeBtn = vm.$el.querySelector('.js-cluster-application-upgrade-button');
expect(upgradeBtn).not.toBe(null);
expect(vm.upgradeFailed).toBe(true);
expect(upgradeBtn.innerHTML).toContain('Retry update');
});
@ -279,7 +251,8 @@ describe('Application Row', () => {
jest.spyOn(eventHub, '$emit');
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
status: APPLICATION_STATUS.UPDATE_ERRORED,
status: APPLICATION_STATUS.INSTALLED,
upgradeAvailable: true,
});
const upgradeBtn = vm.$el.querySelector('.js-cluster-application-upgrade-button');
@ -308,7 +281,8 @@ describe('Application Row', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
title: 'GitLab Runner',
status: APPLICATION_STATUS.UPDATE_ERRORED,
status: APPLICATION_STATUS.INSTALLED,
updateFailed: true,
});
const failureMessage = vm.$el.querySelector(
'.js-cluster-application-upgrade-failure-message',
@ -324,12 +298,11 @@ describe('Application Row', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
title: 'GitLab Runner',
requestStatus: UPGRADE_REQUESTED,
status: APPLICATION_STATUS.UPDATE_ERRORED,
updateSuccessful: false,
});
vm.$toast = { show: jest.fn() };
vm.status = APPLICATION_STATUS.UPDATED;
vm.updateSuccessful = true;
vm.$nextTick(() => {
expect(vm.$toast.show).toHaveBeenCalledWith('GitLab Runner upgraded successfully.');
@ -342,7 +315,8 @@ describe('Application Row', () => {
const version = '0.1.45';
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
status: APPLICATION_STATUS.UPDATED,
status: APPLICATION_STATUS.INSTALLED,
updateSuccessful: true,
version,
});
const upgradeDetails = vm.$el.querySelector('.js-cluster-application-upgrade-details');
@ -358,7 +332,8 @@ describe('Application Row', () => {
const chartRepo = 'https://gitlab.com/charts/gitlab-runner';
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
status: APPLICATION_STATUS.UPDATED,
status: APPLICATION_STATUS.INSTALLED,
updateSuccessful: true,
chartRepo,
version,
});
@ -372,7 +347,8 @@ describe('Application Row', () => {
const version = '0.1.45';
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
status: APPLICATION_STATUS.UPDATE_ERRORED,
status: APPLICATION_STATUS.INSTALLED,
updateFailed: true,
version,
});
const upgradeDetails = vm.$el.querySelector('.js-cluster-application-upgrade-details');
@ -388,7 +364,6 @@ describe('Application Row', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
status: null,
requestStatus: null,
});
const generalErrorMessage = vm.$el.querySelector(
'.js-cluster-application-general-error-message',
@ -397,12 +372,13 @@ describe('Application Row', () => {
expect(generalErrorMessage).toBeNull();
});
it('shows status reason when APPLICATION_STATUS.ERROR', () => {
it('shows status reason when install fails', () => {
const statusReason = 'We broke it 0.0';
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
status: APPLICATION_STATUS.ERROR,
statusReason,
installFailed: true,
});
const generalErrorMessage = vm.$el.querySelector(
'.js-cluster-application-general-error-message',
@ -423,7 +399,7 @@ describe('Application Row', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
status: APPLICATION_STATUS.INSTALLABLE,
requestStatus: REQUEST_FAILURE,
installFailed: true,
requestReason,
});
const generalErrorMessage = vm.$el.querySelector(

View File

@ -0,0 +1,134 @@
import transitionApplicationState from '~/clusters/services/application_state_machine';
import { APPLICATION_STATUS, UPDATE_EVENT, INSTALL_EVENT } from '~/clusters/constants';
const {
NO_STATUS,
SCHEDULED,
NOT_INSTALLABLE,
INSTALLABLE,
INSTALLING,
INSTALLED,
ERROR,
UPDATING,
UPDATED,
UPDATE_ERRORED,
} = APPLICATION_STATUS;
const NO_EFFECTS = 'no effects';
describe('applicationStateMachine', () => {
const noEffectsToEmptyObject = effects => (typeof effects === 'string' ? {} : effects);
describe(`current state is ${NO_STATUS}`, () => {
it.each`
expectedState | event | effects
${INSTALLING} | ${SCHEDULED} | ${NO_EFFECTS}
${NOT_INSTALLABLE} | ${NOT_INSTALLABLE} | ${NO_EFFECTS}
${INSTALLABLE} | ${INSTALLABLE} | ${NO_EFFECTS}
${INSTALLING} | ${INSTALLING} | ${NO_EFFECTS}
${INSTALLED} | ${INSTALLED} | ${NO_EFFECTS}
${INSTALLABLE} | ${ERROR} | ${{ installFailed: true }}
${UPDATING} | ${UPDATING} | ${NO_EFFECTS}
${INSTALLED} | ${UPDATED} | ${NO_EFFECTS}
${INSTALLED} | ${UPDATE_ERRORED} | ${{ updateFailed: true }}
`(`transitions to $expectedState on $event event and applies $effects`, data => {
const { expectedState, event, effects } = data;
const currentAppState = {
status: NO_STATUS,
};
expect(transitionApplicationState(currentAppState, event)).toEqual({
status: expectedState,
...noEffectsToEmptyObject(effects),
});
});
});
describe(`current state is ${NOT_INSTALLABLE}`, () => {
it.each`
expectedState | event | effects
${INSTALLABLE} | ${INSTALLABLE} | ${NO_EFFECTS}
`(`transitions to $expectedState on $event event and applies $effects`, data => {
const { expectedState, event, effects } = data;
const currentAppState = {
status: NOT_INSTALLABLE,
};
expect(transitionApplicationState(currentAppState, event)).toEqual({
status: expectedState,
...noEffectsToEmptyObject(effects),
});
});
});
describe(`current state is ${INSTALLABLE}`, () => {
it.each`
expectedState | event | effects
${INSTALLING} | ${INSTALL_EVENT} | ${{ installFailed: false }}
${INSTALLED} | ${INSTALLED} | ${NO_EFFECTS}
`(`transitions to $expectedState on $event event and applies $effects`, data => {
const { expectedState, event, effects } = data;
const currentAppState = {
status: INSTALLABLE,
};
expect(transitionApplicationState(currentAppState, event)).toEqual({
status: expectedState,
...noEffectsToEmptyObject(effects),
});
});
});
describe(`current state is ${INSTALLING}`, () => {
it.each`
expectedState | event | effects
${INSTALLED} | ${INSTALLED} | ${NO_EFFECTS}
${INSTALLABLE} | ${ERROR} | ${{ installFailed: true }}
`(`transitions to $expectedState on $event event and applies $effects`, data => {
const { expectedState, event, effects } = data;
const currentAppState = {
status: INSTALLING,
};
expect(transitionApplicationState(currentAppState, event)).toEqual({
status: expectedState,
...noEffectsToEmptyObject(effects),
});
});
});
describe(`current state is ${INSTALLED}`, () => {
it.each`
expectedState | event | effects
${UPDATING} | ${UPDATE_EVENT} | ${{ updateFailed: false, updateSuccessful: false }}
`(`transitions to $expectedState on $event event and applies $effects`, data => {
const { expectedState, event, effects } = data;
const currentAppState = {
status: INSTALLED,
};
expect(transitionApplicationState(currentAppState, event)).toEqual({
status: expectedState,
...effects,
});
});
});
describe(`current state is ${UPDATING}`, () => {
it.each`
expectedState | event | effects
${INSTALLED} | ${UPDATED} | ${{ updateSuccessful: true, updateAcknowledged: false }}
${INSTALLED} | ${UPDATE_ERRORED} | ${{ updateFailed: true }}
`(`transitions to $expectedState on $event event and applies $effects`, data => {
const { expectedState, event, effects } = data;
const currentAppState = {
status: UPDATING,
};
expect(transitionApplicationState(currentAppState, event)).toEqual({
status: expectedState,
...effects,
});
});
});
});

View File

@ -113,7 +113,6 @@ const DEFAULT_APPLICATION_STATE = {
description: 'Some description about this interesting application!',
status: null,
statusReason: null,
requestStatus: null,
requestReason: null,
};

View File

@ -32,15 +32,6 @@ describe('Clusters Store', () => {
});
describe('updateAppProperty', () => {
it('should store new request status', () => {
expect(store.state.applications.helm.requestStatus).toEqual(null);
const newStatus = APPLICATION_STATUS.INSTALLING;
store.updateAppProperty('helm', 'requestStatus', newStatus);
expect(store.state.applications.helm.requestStatus).toEqual(newStatus);
});
it('should store new request reason', () => {
expect(store.state.applications.helm.requestReason).toEqual(null);
@ -68,80 +59,90 @@ describe('Clusters Store', () => {
title: 'Helm Tiller',
status: mockResponseData.applications[0].status,
statusReason: mockResponseData.applications[0].status_reason,
requestStatus: null,
requestReason: null,
installed: false,
installFailed: false,
uninstallable: false,
},
ingress: {
title: 'Ingress',
status: mockResponseData.applications[1].status,
status: APPLICATION_STATUS.INSTALLABLE,
statusReason: mockResponseData.applications[1].status_reason,
requestStatus: null,
requestReason: null,
externalIp: null,
externalHostname: null,
installed: false,
installFailed: true,
uninstallable: false,
},
runner: {
title: 'GitLab Runner',
status: mockResponseData.applications[2].status,
statusReason: mockResponseData.applications[2].status_reason,
requestStatus: null,
requestReason: null,
version: mockResponseData.applications[2].version,
upgradeAvailable: mockResponseData.applications[2].update_available,
chartRepo: 'https://gitlab.com/charts/gitlab-runner',
installed: false,
installFailed: false,
updateAcknowledged: true,
updateFailed: false,
updateSuccessful: false,
uninstallable: false,
},
prometheus: {
title: 'Prometheus',
status: mockResponseData.applications[3].status,
status: APPLICATION_STATUS.INSTALLABLE,
statusReason: mockResponseData.applications[3].status_reason,
requestStatus: null,
requestReason: null,
installed: false,
installFailed: true,
uninstallable: false,
},
jupyter: {
title: 'JupyterHub',
status: mockResponseData.applications[4].status,
statusReason: mockResponseData.applications[4].status_reason,
requestStatus: null,
requestReason: null,
hostname: '',
installed: false,
installFailed: false,
uninstallable: false,
},
knative: {
title: 'Knative',
status: mockResponseData.applications[5].status,
statusReason: mockResponseData.applications[5].status_reason,
requestStatus: null,
requestReason: null,
hostname: null,
isEditingHostName: false,
externalIp: null,
externalHostname: null,
installed: false,
installFailed: false,
uninstallable: false,
},
cert_manager: {
title: 'Cert-Manager',
status: mockResponseData.applications[6].status,
status: APPLICATION_STATUS.INSTALLABLE,
installFailed: true,
statusReason: mockResponseData.applications[6].status_reason,
requestStatus: null,
requestReason: null,
email: mockResponseData.applications[6].email,
installed: false,
uninstallable: false,
},
},
});
});
describe.each(APPLICATION_INSTALLED_STATUSES)('given the current app status is %s', () => {
describe.each(APPLICATION_INSTALLED_STATUSES)('given the current app status is %s', status => {
it('marks application as installed', () => {
const mockResponseData =
CLUSTERS_MOCK_DATA.GET['/gitlab-org/gitlab-shell/clusters/2/status.json'].data;
const runnerAppIndex = 2;
mockResponseData.applications[runnerAppIndex].status = APPLICATION_STATUS.INSTALLED;
mockResponseData.applications[runnerAppIndex].status = status;
store.updateStateFromServer(mockResponseData);