From 797e758beb882d67f32b87db6453cad41c0b931f Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 24 Oct 2017 07:56:39 +0300 Subject: [PATCH 01/17] Add applications section to GKE clusters page --- app/assets/javascripts/clusters.js | 123 --------- .../javascripts/clusters/clusters_bundle.js | 216 ++++++++++++++++ .../clusters/components/application_row.vue | 168 +++++++++++++ .../clusters/components/applications.vue | 105 ++++++++ app/assets/javascripts/clusters/constants.js | 10 + app/assets/javascripts/clusters/event_hub.js | 3 + .../clusters/services/clusters_service.js | 24 ++ .../clusters/stores/clusters_store.js | 68 +++++ app/assets/javascripts/dispatcher.js | 9 +- .../vue_shared/components/loading_button.vue | 7 +- app/assets/stylesheets/framework/buttons.scss | 1 + app/assets/stylesheets/pages/clusters.scss | 5 + app/views/projects/clusters/show.html.haml | 19 +- ...-35958-add-cluster-application-section.yml | 6 + .../clusters/img/cluster-applications.png | Bin 0 -> 38153 bytes doc/user/project/clusters/index.md | 9 + .../clusters/clusters_bundle_spec.js | 235 ++++++++++++++++++ .../components/application_row_spec.js | 213 ++++++++++++++++ .../clusters/components/applications_spec.js | 42 ++++ .../clusters/services/mock_data.js | 50 ++++ .../clusters/stores/clusters_store_spec.js | 86 +++++++ spec/javascripts/clusters_spec.js | 79 ------ 22 files changed, 1268 insertions(+), 210 deletions(-) delete mode 100644 app/assets/javascripts/clusters.js create mode 100644 app/assets/javascripts/clusters/clusters_bundle.js create mode 100644 app/assets/javascripts/clusters/components/application_row.vue create mode 100644 app/assets/javascripts/clusters/components/applications.vue create mode 100644 app/assets/javascripts/clusters/constants.js create mode 100644 app/assets/javascripts/clusters/event_hub.js create mode 100644 app/assets/javascripts/clusters/services/clusters_service.js create mode 100644 app/assets/javascripts/clusters/stores/clusters_store.js create mode 100644 changelogs/unreleased/36629-35958-add-cluster-application-section.yml create mode 100644 doc/user/project/clusters/img/cluster-applications.png create mode 100644 spec/javascripts/clusters/clusters_bundle_spec.js create mode 100644 spec/javascripts/clusters/components/application_row_spec.js create mode 100644 spec/javascripts/clusters/components/applications_spec.js create mode 100644 spec/javascripts/clusters/services/mock_data.js create mode 100644 spec/javascripts/clusters/stores/clusters_store_spec.js delete mode 100644 spec/javascripts/clusters_spec.js diff --git a/app/assets/javascripts/clusters.js b/app/assets/javascripts/clusters.js deleted file mode 100644 index c9fef94efea..00000000000 --- a/app/assets/javascripts/clusters.js +++ /dev/null @@ -1,123 +0,0 @@ -/* globals Flash */ -import Visibility from 'visibilityjs'; -import axios from 'axios'; -import setAxiosCsrfToken from './lib/utils/axios_utils'; -import Poll from './lib/utils/poll'; -import { s__ } from './locale'; -import initSettingsPanels from './settings_panels'; -import Flash from './flash'; - -/** - * Cluster page has 2 separate parts: - * Toggle button - * - * - Polling status while creating or scheduled - * -- Update status area with the response result - */ - -class ClusterService { - constructor(options = {}) { - this.options = options; - setAxiosCsrfToken(); - } - fetchData() { - return axios.get(this.options.endpoint); - } -} - -export default class Clusters { - constructor() { - initSettingsPanels(); - - const dataset = document.querySelector('.js-edit-cluster-form').dataset; - - this.state = { - statusPath: dataset.statusPath, - clusterStatus: dataset.clusterStatus, - clusterStatusReason: dataset.clusterStatusReason, - toggleStatus: dataset.toggleStatus, - }; - - this.service = new ClusterService({ endpoint: this.state.statusPath }); - this.toggleButton = document.querySelector('.js-toggle-cluster'); - this.toggleInput = document.querySelector('.js-toggle-input'); - this.errorContainer = document.querySelector('.js-cluster-error'); - this.successContainer = document.querySelector('.js-cluster-success'); - this.creatingContainer = document.querySelector('.js-cluster-creating'); - this.errorReasonContainer = this.errorContainer.querySelector('.js-error-reason'); - - this.toggleButton.addEventListener('click', this.toggle.bind(this)); - - if (this.state.clusterStatus !== 'created') { - this.updateContainer(this.state.clusterStatus, this.state.clusterStatusReason); - } - - if (this.state.statusPath) { - this.initPolling(); - } - } - - toggle() { - this.toggleButton.classList.toggle('checked'); - this.toggleInput.setAttribute('value', this.toggleButton.classList.contains('checked').toString()); - } - - initPolling() { - this.poll = new Poll({ - resource: this.service, - method: 'fetchData', - successCallback: data => this.handleSuccess(data), - errorCallback: () => Clusters.handleError(), - }); - - if (!Visibility.hidden()) { - this.poll.makeRequest(); - } else { - this.service.fetchData() - .then(data => this.handleSuccess(data)) - .catch(() => Clusters.handleError()); - } - - Visibility.change(() => { - if (!Visibility.hidden()) { - this.poll.restart(); - } else { - this.poll.stop(); - } - }); - } - - static handleError() { - Flash(s__('ClusterIntegration|Something went wrong on our end.')); - } - - handleSuccess(data) { - const { status, status_reason } = data.data; - this.updateContainer(status, status_reason); - } - - hideAll() { - this.errorContainer.classList.add('hidden'); - this.successContainer.classList.add('hidden'); - this.creatingContainer.classList.add('hidden'); - } - - updateContainer(status, error) { - this.hideAll(); - switch (status) { - case 'created': - this.successContainer.classList.remove('hidden'); - break; - case 'errored': - this.errorContainer.classList.remove('hidden'); - this.errorReasonContainer.textContent = error; - break; - case 'scheduled': - case 'creating': - this.creatingContainer.classList.remove('hidden'); - break; - default: - this.hideAll(); - } - } -} diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js new file mode 100644 index 00000000000..053f11cc3c4 --- /dev/null +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -0,0 +1,216 @@ +import Visibility from 'visibilityjs'; +import Vue from 'vue'; +import { s__, sprintf } from '../locale'; +import Flash from '../flash'; +import Poll from '../lib/utils/poll'; +import initSettingsPanels from '../settings_panels'; +import eventHub from './event_hub'; +import { + APPLICATION_INSTALLED, + REQUEST_LOADING, + REQUEST_SUCCESS, + REQUEST_FAILURE, +} from './constants'; +import ClustersService from './services/clusters_service'; +import ClustersStore from './stores/clusters_store'; +import applications from './components/applications.vue'; + +/** + * Cluster page has 2 separate parts: + * Toggle button and applications section + * + * - Polling status while creating or scheduled + * - Update status area with the response result + */ + +export default class Clusters { + constructor() { + const { + statusPath, + installHelmPath, + installIngressPath, + installRunnerPath, + clusterStatus, + clusterStatusReason, + helpPath, + } = document.querySelector('.js-edit-cluster-form').dataset; + + this.store = new ClustersStore(); + this.store.setHelpPath(helpPath); + this.store.updateStatus(clusterStatus); + this.store.updateStatusReason(clusterStatusReason); + this.service = new ClustersService({ + endpoint: statusPath, + installHelmEndpoint: installHelmPath, + installIngresEndpoint: installIngressPath, + installRunnerEndpoint: installRunnerPath, + }); + + this.toggle = this.toggle.bind(this); + this.installApplication = this.installApplication.bind(this); + + this.toggleButton = document.querySelector('.js-toggle-cluster'); + this.toggleInput = document.querySelector('.js-toggle-input'); + this.errorContainer = document.querySelector('.js-cluster-error'); + this.successContainer = document.querySelector('.js-cluster-success'); + this.creatingContainer = document.querySelector('.js-cluster-creating'); + this.errorReasonContainer = this.errorContainer.querySelector('.js-error-reason'); + this.successApplicationContainer = document.querySelector('.js-cluster-application-notice'); + + initSettingsPanels(); + this.initApplications(); + + if (this.store.state.status !== 'created') { + this.updateContainer(this.store.state.status, this.store.state.statusReason); + } + + this.addListeners(); + if (statusPath) { + this.initPolling(); + } + } + + initApplications() { + const store = this.store; + const el = document.querySelector('#js-cluster-applications'); + + this.applications = new Vue({ + el, + components: { + applications, + }, + data() { + return { + state: store.state, + }; + }, + render(createElement) { + return createElement('applications', { + props: { + applications: this.state.applications, + helpPath: this.state.helpPath, + }, + }); + }, + }); + } + + addListeners() { + this.toggleButton.addEventListener('click', this.toggle); + eventHub.$on('installApplication', this.installApplication); + } + + removeListeners() { + this.toggleButton.removeEventListener('click', this.toggle); + eventHub.$off('installApplication', this.installApplication); + } + + initPolling() { + this.poll = new Poll({ + resource: this.service, + method: 'fetchData', + successCallback: data => this.handleSuccess(data), + errorCallback: () => Clusters.handleError(), + }); + + if (!Visibility.hidden()) { + this.poll.makeRequest(); + } else { + this.service.fetchData() + .then(data => this.handleSuccess(data)) + .catch(() => Clusters.handleError()); + } + + Visibility.change(() => { + if (!Visibility.hidden() && !this.destroyed) { + this.poll.restart(); + } else { + this.poll.stop(); + } + }); + } + + static handleError() { + Flash(s__('ClusterIntegration|Something went wrong on our end.')); + } + + handleSuccess(data) { + const prevApplicationMap = Object.assign({}, this.store.state.applications); + this.store.updateStateFromServer(data.data); + this.checkForNewInstalls(prevApplicationMap, this.store.state.applications); + this.updateContainer(this.store.state.status, this.store.state.statusReason); + } + + toggle() { + this.toggleButton.classList.toggle('checked'); + this.toggleInput.setAttribute('value', this.toggleButton.classList.contains('checked').toString()); + } + + hideAll() { + this.errorContainer.classList.add('hidden'); + this.successContainer.classList.add('hidden'); + this.creatingContainer.classList.add('hidden'); + } + + checkForNewInstalls(prevApplicationMap, newApplicationMap) { + const appTitles = Object.keys(newApplicationMap) + .filter(appId => newApplicationMap[appId].status === APPLICATION_INSTALLED && + prevApplicationMap[appId].status !== APPLICATION_INSTALLED && + prevApplicationMap[appId].status !== null) + .map(appId => newApplicationMap[appId].title); + + if (appTitles.length > 0) { + this.successApplicationContainer.textContent = sprintf(s__('ClusterIntegration|%{appList} was successfully installed on your cluster'), { + appList: appTitles.join(', '), + }); + this.successApplicationContainer.classList.remove('hidden'); + } else { + this.successApplicationContainer.classList.add('hidden'); + } + } + + updateContainer(status, error) { + this.hideAll(); + switch (status) { + case 'created': + this.successContainer.classList.remove('hidden'); + break; + case 'errored': + this.errorContainer.classList.remove('hidden'); + this.errorReasonContainer.textContent = error; + break; + case 'scheduled': + case 'creating': + this.creatingContainer.classList.remove('hidden'); + break; + default: + this.hideAll(); + } + } + + installApplication(appId) { + this.store.updateAppProperty(appId, 'requestStatus', REQUEST_LOADING); + this.store.updateAppProperty(appId, 'requestReason', null); + + this.service.installApplication(appId) + .then(() => { + this.store.updateAppProperty(appId, 'requestStatus', REQUEST_SUCCESS); + }) + .catch(() => { + this.store.updateAppProperty(appId, 'requestStatus', REQUEST_FAILURE); + this.store.updateAppProperty(appId, 'requestReason', s__('ClusterIntegration|Request to begin installing failed')); + }); + } + + destroy() { + this.destroyed = true; + + this.removeListeners(); + + if (this.poll) { + this.poll.stop(); + } + + this.applications.$destroy(); + } +} diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue new file mode 100644 index 00000000000..f8d53fcc4b7 --- /dev/null +++ b/app/assets/javascripts/clusters/components/application_row.vue @@ -0,0 +1,168 @@ + + + diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue new file mode 100644 index 00000000000..865fa4f702e --- /dev/null +++ b/app/assets/javascripts/clusters/components/applications.vue @@ -0,0 +1,105 @@ + + + diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js new file mode 100644 index 00000000000..3f202435716 --- /dev/null +++ b/app/assets/javascripts/clusters/constants.js @@ -0,0 +1,10 @@ +// These need to match what is returned from the server +export const APPLICATION_INSTALLABLE = 'installable'; +export const APPLICATION_INSTALLING = 'installing'; +export const APPLICATION_INSTALLED = 'installed'; +export const APPLICATION_ERROR = 'error'; + +// These are only used client-side +export const REQUEST_LOADING = 'request-loading'; +export const REQUEST_SUCCESS = 'request-success'; +export const REQUEST_FAILURE = 'request-failure'; diff --git a/app/assets/javascripts/clusters/event_hub.js b/app/assets/javascripts/clusters/event_hub.js new file mode 100644 index 00000000000..0948c2e5352 --- /dev/null +++ b/app/assets/javascripts/clusters/event_hub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); diff --git a/app/assets/javascripts/clusters/services/clusters_service.js b/app/assets/javascripts/clusters/services/clusters_service.js new file mode 100644 index 00000000000..0ac8e68187d --- /dev/null +++ b/app/assets/javascripts/clusters/services/clusters_service.js @@ -0,0 +1,24 @@ +import axios from 'axios'; +import setAxiosCsrfToken from '../../lib/utils/axios_utils'; + +export default class ClusterService { + constructor(options = {}) { + setAxiosCsrfToken(); + + this.options = options; + this.appInstallEndpointMap = { + helm: this.options.installHelmEndpoint, + ingress: this.options.installIngressEndpoint, + runner: this.options.installRunnerEndpoint, + }; + } + + fetchData() { + return axios.get(this.options.endpoint); + } + + installApplication(appId) { + const endpoint = this.appInstallEndpointMap[appId]; + return axios.post(endpoint); + } +} diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js new file mode 100644 index 00000000000..e731cdc3042 --- /dev/null +++ b/app/assets/javascripts/clusters/stores/clusters_store.js @@ -0,0 +1,68 @@ +import { s__ } from '../../locale'; + +export default class ClusterStore { + constructor() { + this.state = { + helpPath: null, + status: null, + statusReason: null, + applications: { + helm: { + title: s__('ClusterIntegration|Helm Tiller'), + status: null, + statusReason: null, + requestStatus: null, + requestReason: null, + }, + ingress: { + title: s__('ClusterIntegration|Ingress'), + status: null, + statusReason: null, + requestStatus: null, + requestReason: null, + }, + runner: { + title: s__('ClusterIntegration|GitLab Runner'), + status: null, + statusReason: null, + requestStatus: null, + requestReason: null, + }, + }, + }; + } + + setHelpPath(helpPath) { + this.state.helpPath = helpPath; + } + + updateStatus(status) { + this.state.status = status; + } + + updateStatusReason(reason) { + this.state.statusReason = reason; + } + + updateAppProperty(appId, prop, value) { + this.state.applications[appId][prop] = value; + } + + updateStateFromServer(serverState = {}) { + this.state.status = serverState.status; + this.state.statusReason = serverState.status_reason; + serverState.applications.forEach((serverAppEntry) => { + const { + name: appId, + status, + status_reason: statusReason, + } = serverAppEntry; + + this.state.applications[appId] = { + ...(this.state.applications[appId] || {}), + status, + statusReason, + }; + }); + } +} diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 760fb0cdf67..44606989395 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -1,4 +1,5 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-arrow-callback, wrap-iife, no-shadow, consistent-return, one-var, one-var-declaration-per-line, camelcase, default-case, no-new, quotes, no-duplicate-case, no-case-declarations, no-fallthrough, max-len */ +import { s__ } from './locale'; /* global ProjectSelect */ import IssuableIndex from './issuable_index'; /* global Milestone */ @@ -32,6 +33,7 @@ import Labels from './labels'; import LabelManager from './label_manager'; /* global Sidebar */ +import Flash from './flash'; import CommitsList from './commits'; import Issue from './issue'; import BindInOut from './behaviors/bind_in_out'; @@ -543,9 +545,12 @@ import Diff from './diff'; new DueDateSelectors(); break; case 'projects:clusters:show': - import(/* webpackChunkName: "clusters" */ './clusters') + import(/* webpackChunkName: "clusters" */ './clusters/clusters_bundle') .then(cluster => new cluster.default()) // eslint-disable-line new-cap - .catch(() => {}); + .catch((err) => { + Flash(s__('ClusterIntegration|Problem setting up the cluster JavaScript')); + throw err; + }); break; } switch (path[0]) { diff --git a/app/assets/javascripts/vue_shared/components/loading_button.vue b/app/assets/javascripts/vue_shared/components/loading_button.vue index 6670b554faf..0cc2653761c 100644 --- a/app/assets/javascripts/vue_shared/components/loading_button.vue +++ b/app/assets/javascripts/vue_shared/components/loading_button.vue @@ -26,6 +26,11 @@ export default { required: false, default: false, }, + disabled: { + type: Boolean, + required: false, + default: false, + }, label: { type: String, required: false, @@ -47,7 +52,7 @@ export default { class="btn btn-align-content" @click="onClick" type="button" - :disabled="loading" + :disabled="loading || disabled" > 9E|6C4(I3liMj-Q6Js2n1gs5Zv9}-Q5Wi+}+(4m&NVlzMuN4-d|6> z@6^_w-99}%y**c-xu(zAFcl?fR3t(q2nYyNSs4j62naYZ1OyZs0`#XuWtMOS0sZ`Zphs>uXOR(|4by#FcY4SQvf0n3*0QAH_aRGufA#(0<%4 zrA~txe>7b^d=R*g?O)tK?KVDwZ=YXY){3;7x1RRiKdLLMO>LT=<_8bA))@^;D>t4# zu7>X}Pl;{1hOa(8ULJ42k7XWBA1{05#U+my`yXfBA0H==3wa+8H#X&4AMYCt_S9>8 z;G(tX=go@a9zQ0rJdgUV^};s)u0yr7>8)xjdiT~uNrs-o_mZ;O)6>)Lp6=6&n~kmg zvCXTd?#aj9q0z2}k$Bb9kB{r&_~4MBkGs>&dY6xnz4g`I6kWQ_xsmDVDMtOYrkUf7 z^G79{=KHhtkMpHYe}4C-BfXOC;i>V-EMrE)x}?P9!)CXWqeFwNso^r;GEKUq5%AFR z!Tx03zp0J=-Tm!>g6pX)(9O$1Z(NHfGgH1EUGCKF{?S?6&{|&8P(xLn zog~xE*}>9Kr*}r<+QK?`cdo9oBu|o_KW(ABY_PJa?PztVr@1nrXRSM{{>oT5Hk|-pGli9Obd2r>veeCe1%_{bvma?`f`@?{|Acy(7W_ zJ)BY;l|x(p)l=>TL-c;VO^uDVQMH}%T21Z(0owFg1BV~`^*3)H@$M~KZN9$0e+Ni1 z8V2Nc2CH=sju1KxFpCG(FW8xd>lUqK7mR4TWn9lUFpDL*1a*xTcD5G$an)ql zdj6=4Y@OcO5pl>W^;OsOYY;W{XO=WN>+8)=0H!+d7vyA3R=S<;%>IpQ*{B-@iDetq`#G)c(os5U_R5IQgeCmUuE%NknyZq{uyK zBo@Jcz8q;>RyjPue5Tg_YpW$zqDdGrZ7@2#t$(fa8ascqrg4;-=_bJ*IVm* zTbAS=#g7a6H|eBS zY;U3bYWU-(3K+}M?}bdcugmcc=`|TmD*TQb$3f4}eD119G4l$dyJRJyi$0?1+rz;f(8T!_6Z5=MeZEatX^Xu|72#O46Ms zTd*~0lNO5%qh3DMw|We;g$5cK@q(!%at2Y~75QG=Fg^{vxn0S{b)TIio(3MQn4e{& zx>Od>)|6JWB-@GDkEs+FzUG$TjQD7dL2$XwXSA2}@EI8oo-a2>d;+R(+=4!wc%Z7H ztN`!p7JIX#@`swuIyzhOzcE&(=3rtF==e@^uI6NmFVCxXA@uuwK~z}!XJ7;IM}8T% zb7UCWL(TAWR`izlp3&piMKaI)7|PCD8_{)T!pz6IIf7@z{w9=?`t9O^ zTsw^M2mlp3>MPU|JC_(WL*+7`o?SmA;#UklTKGYkjS=IYeoDu;=So_rKYLj}&k*Ix zFL4Rd(o!dU+PjI*c1e_%*k2xtG(yIL9+Mko+PgY#`bKI;^G1l{_7r(x@D-@U_~wXr zSFCQPe(f-rV8M<(<0b#uS1TSnEP*tco)7pOd_8$aXipF#t!(5%PHnuIaS~1wmfg9{ zXJOIcCShN0A`y}hG0c`@ebmSBA0$yP>A209g7OP!<1pCCHfcv^aD$JlCFEz1_#)JWG5T2 z?J?=T0*9KeEN)k%Mi=)pgmTZTl?v5Nr4WjyfIwdN}OIOq#p6wLl&M%v0Z`TuRX*~Q3>2-(8bC9Mh z_MgE+UK(G5ZB(|6%8xs?dl&N0J_)|u8~K%NW*`E$sGEWzk_kyGdHun~V-R#x`PPQK zwp*OK=+Z=5CKC{6{3w4y^{3s;oKl&1-k_?yL#J->kJQjNUY+$mO{U`P;-TO_? zd8`6K#OA1ZR-aJ!{DB<`Xi89anR^)_v7d?95ZJf`N0pB26;l8qATX@-FNlzpN{cLt zm}fIhB_>*Gk332L1E={k!Bb_dyRCesj6s@)ANksq1Ry>j=b~?DDnS?4)7t`0@kq(i zU7dt7No{vhPYUar+~?1M`*399aBRN)mCsC3O^R<%3SV%<9kI7UbQh54)!=gG(xX2v z1-7kSCY_p}nTufRd@UB*h_jnfH|}(vNu7{iZV3Cd;{8m#cPF%8kq0-6n;>w`?qU~_ z?cGLm$~~`Wttee%|2QjFw_>M9R42{apJAKiIQAJ1m&lsaFD#6Lt($bQ-LM-^O30EOU z((@agc9xh1qfoKOV(usy`vk|Gd$EiC6MwH#V2T~{S2H*Whz~3YbO?xdqENu6m>XR0 zN^bsN0X3|eMeDv?9D%e_kXs>UH~z|e+5rHT>j<%TSI!W!r{}z z2)cGQxL7rF|4)mkN?i`CTHe?QW9FiupEi^Fl5=Zi_jB&5NVwE8veH;RG?(ldjvDb%bsTF}jh z-itG6U3x@?P9u0%_ToK{>12lL3j?WloHQ{u%uWl2(O@QRsA1ljagFj|QIk#aexNJ} zPbO0)!pMUGehsNXo8qf~)5`}1jcM;KAT#}}zT!Bh{mxqA@<>ZTk~mS|#7;Fog=M&` z-H~Z#?6NiHMNWTEO<+VtEW+q9osdpF*O?qJ$R>*Mn4#zR6ybjfAR0l(yP*9wpChF}V| z<0zEN2b(B5o^2WI`(NQf%^t$=4fJWJo2x5DtGJ$yb6^l7A`nEud}r!*mj4bv z&Ha+}L8;AUhQ;nu*uoP$;bNcsF%k+$`mv{CG816_aZG_1*c01xRtOK34Su)Z^_nHR zh_y1|dOPx^1TM<5I(C@Qc0EGRAfreqW57rXNYrF>PyKRtyS93EKMv)eLaKS4C_H*<<-kxo3o31!$?D7ysAwer)WP%T{@4`e++&Q`y>!Q@6~Dnc+@KWO-x~L7bzfN2WGj^Y z-ufV5U=)QVC`g-2=DZk5^G9A^yK6b-%RQApWQG2ex}-eVg&2f~%Zes`fFIm~?Eo7& zS*~Sof3OO`M#SGf#ln2fL^*9$tP~{CG)Ri#Ae~1ZI(57@<7s{*FS_h9`$xTHcd1`z z1}lK@z*N-z%)hLz+t=hX>fnF-ZHBR_HM?4&s};g^ZldcS_uFm}jx$Zh&TC?O=@}sX zTh-Pg(ADW*5s;063}^+@Vm`O&R;@S1c%nY-=7<=bR?E`+S7K{!24IlBr(=rje?>CW zZ`gj?rIT<4_5EJRw6+dsrx{sCN#a{`dSCj_P$Lg}G&mw9th|RpcYNaT^`=r`L>h07 zEM>5*8H(h^8p`vT$n}l4xl8jc(GQy=l;%HM73M1mU67 z(XYNPb;9bgb#TS`O)LSADNWzwWvVK2WvoIUhWw#Ip$L`Icqb+NclX}esK}1a8X{R@ zh$6J^wyT(@X@j}8n3nG;Iis0@6TD(Zw{F-(QfWTC z%tY}nZ23no_0{HtTvYR$oF>qd%+<%d?F1|_xZ^wR`iWQPF=KlyZajb;QDV~`ar}1( zqV#zA=d|$*%GpE?koH>9SC9nYYj=~VJ~_Fm(WU}b-K~=P@>uTAzGQf;F8_<|qK?>a zVSFMrXLy3X0POCtN6kNGG{lhF-$BiP=xc{0AbxfsO?=;C@{)%tu9IX%ml!J&up&}} zxR;PXPj1Uo+ZJm)P6`z;Xr96AY7(Jkq3%-UzzQc43w|}sXL!vIfoJ|>or|M8>6w3o zKcsQXTrFyj-PHr_yZmuD5&HPpP$6(@;dYawIMzNl3D^Xqwb zn99mXJ@*PsG|tO0h}Bj9J(YWY%*dXTBc)gWU-0i0?Xrv3-~0YM)%Pp#r4Al4iN|-T zy_ve6w@P;jR3s85Q>|w~H05UY`Fi%sjI?XbX_XnK9_BSDjx^#L-oFj*ZKd;!w0Hoh zX-}42eOG3V16BR7^)cFSm8?=Ml&LM4d88IY4%QKXL1mtntcUNZB=O8Zn)qeUnr!id zzf_gKsfr?2i!L4%XXIfad$)OCtXR1TSDI;5heAB+SwWUzfZb#DnrR7{^*JaVfH<90 zpW@#+mm`k@pB$T*FoaI@fe_kJQSdI9t4369hXEpR&3 z*;cb#ZS`AVu4QRz^~Nxkp=pE>dX?!9hNQXk+meQ?)5ZJlUj-AxBh}_E$}2r|D%aHx z+B6hEe#WGZe3IsVT$mlNeCv1PP0h%~6&U#^ zb3tFSX7X^NjYFj{JO6H?wzy&cMlucK$sD*u;FfNBX+Q2Y$73wS&&T7qjE!H44l9L4 zaT~j)}PnbyB&or>qFXy|XF<*)*U6qxzo&J=l5?Bqo6lHcSWyrZE%3m4}O+O(y zs@O07#z3L568}If#!{wWV4P~xZJfCcb`(t{gj_ECl%haI7>LR_0R?A`YIp{@<2)yl zY0Aj-lg*xduN=UVbFrIv3=#1KMF8XQt@;((nT+PMP8@Cf6-O5_>;WthFsE|&{%fO$ z6XH&$AE3YtN}H2LZe9Y?ZWf*fvpFSY30=G<{GwUCpkJ*dB6q5Y?a(Xox*sWbOC< znJ@+<#{MkhD29m!VgVbKM`DSmog4X`_!$Oi?MovVyiMXV=4ijRjb@yxAE1~!314GK zr+cKYIdPx?Qv}+35xewiHElWdBI3E?=IZUOe4mFQOMp}^GgG25D)NZ0+XxAY?U>Zy zU;omzY=oF$} z^O`W@Oh4kJ+ku{MytC8T8(i9y>mDmQV!3zWN8!K3Stmn_2z2Qn4{>Qxm9Y?x zEq844Rp;(HH(GM7N6iaa9BkoCk%k>h1!(lkgZKR(lBUhyBVK=I6=eGX&ec)qv49tD zw8N#A-jrBC>%F6iIX zgSl4M&_JV>v7csQ!bpP4$KNv=IDhzZZAMK~k|4@CiHfkwYZqGU_3MDXH`FUBvPWRhAA=hC1c~12Ob~ z)!14@`C-&20wyzGDz(1h8gW)S_Ym;1+sM~Hb2dE?T}@CxKA!?A@MB3QSzbtGV!pG( zFZC7XKZ#dw3e3uzLH%aP)I*OXyIgdbtYCiz&m@P@=UKtF6vy z){H*nS&PL_kL|l9{+|Bb&bZS;%Nb4rD`=tPm82HHi<(;OoL8vfc8wt^;BYL-&0~$) zf`V>;fIw%Okb~%j@vX!k5l$a}`tHWm1+_Du|zwI(MRplO=h_l0!RdKQEG z=YSZli(}L0d$d(@MZ^kOHU3WhKra-~K-iSS#mSDvB5rz*J)HCOERO9zGn3cfEaDY6 z4J-JZ#j=I}_I9WP}2CF6h{G`Z@6^jf4z)wE7L9b{)f& zn5UySWY8^fe3f?=+SI+oDjVD;pQxS6y#E4Z3gbufux4~Ra-0>~$s zGQxrEDut6iR1)}kyj&+QBAbD*ZHLT$v1c6WSSy5_OJS>_Jh@~xCI$mWny{dZx5oto zuf33e;{%sfYLh|ffkrsd@+Zv5V;)|e)GXeJf_ADpMRBPre6)G+x6<)*Yzpvl$7!{1 zE$kkiZ&Y9fbd4|4Ku2=E57U~5vSp5hw4Np(1@M4bYaKYoR2r>xABaUZT2w7?~5#4oJ)8R63sQB}aKL}pX`AQ*Bgn>L=r4@5 z{~!)dWRDc`ZHUcnR~i`|$a1Yi&N&1>@f-+fmMbIBMyeVvjosKri5FiZg16sdOgelV zd{R$B1}a$1N5f43=uriUmuF0y*o=l za`_eIpHmryFWWrkD*3%Yr6AeQU@4S8&=Y$68JI&9T%RT0WI9b!`T)ImU9Un4VyZ2h zc+Gqz`pL#=-2qQ8oMF?P*jh(C9#F)L1&lOWpeb6&+nh7*Z}kX;xGq8NVx{{pY>pyJ z^qgDwU|%$dzGFk06gThrG&54#ef^-|>YE$P>%V2Kha;q&h@`(R$c0z1Cy573a7~BS z91RZLU+gB(PE6c$AnSSv=y3TYr*&Of=qL22R0T2RoN@Bbe4{z0WR*GYt+LPHzWC4( z#+bwmUpsW*0&M)FGD{c}cu)|V3OqjocXkF26oUefd9EXj^@0JRqZ)lo5SixDy$W*- z3Ru%bhoV&~x_zsc@j=sG`K9J(DL*atgYY_q-Tub$(Yw)?s|%ej51w|gxdca94`cLO z1{OJqvgKJ|06CgahHMJ`QxV$ohBB>^3XG=XYj%bFO;@?&$}ut09UM~m+;z_@AoyEWzf^bC72*g1U(n4MR8_}vHpvk?J1OzVI5YB5NB*sEuavhY zge>7lXdB&v;e4nw!M~>p@bB01KrakE9AWW;g|?ETW-b~_l)FjmfVg!(8k&RlbM7sF zGS=NgV!{i|RVrOA<`ycmTpDhCcw#)zJ1^(gGa`V%_v&f-q zj_7cG&cuVl*c1*1j>~^h%MCch(rX62hy8+4Qf!z=IU^0AMJ4(P(_M0qnoQ~D!lTv} zJcJLxL8HB!Y<64-=)gWtnITV`#!?(7nYpl=lRzV)!^q>=TYilW;bE4pI};83-P&7% zs$58#QcKVB?M%638P~&p6QGnc(I(IqKsOVxgL)2Z`*jO^`;KsnN0kfjZoVL$O2bw; z?fWP(6hy}j%Jcx=hzl^QUX=cqGc@d*kmo(Pf3H+~x9I;+p=Vk9@pXw;F3#z_V(ODq zydxeb(Ko7e}|0w0p>As=$irt+%-izA@06!P6nbF%6arCXja$6Xqk31n754|BM zbU{|u8XFRsepuik@&o(3A_641`SWy9EfAZLo``?U#7C}~@TPAIO#X0lb2CerQmT6Y z_tbZx6}#jxo9~Y7>Qnms_y4*U+xLI1`M*_k%gbxw;XkX;pSM2VYoc=hchW_Dq z7iymfO7MJF*8^`xI7E0W3e#*E72RF3C$AzwkL}*2#9TI z9gq62kPxvo$TehqpGRha@c5*w&l@o#l}fLH96K-&7RapSxzi_*5VLKkBnh5)5MRv$ z0T3o!hXg?RQ%)(buJhoRsXf*d74F4~{JdZ1PF^!M5pz%PE2PgUw#17sQpR=cKN7cl zyVHXIevx!~%{00l1aL(8STmWPy)3t!Mf%{?*Nhk?OVF*f)PKQRNkBkEyzW(#>x1`J z3IDPivF?s%UHuY#z4?{n(Z`eHaNTQ)_rdpQ%aJ)+*&jun|3&Tzv$780l3T{oPb8xF z;@nk$R!Az@_loy)s!%i0>eQHEJ{YBc*!-qm`RmGAD|)UJaeA&keIa4*fU?DA=Boxr zncuXt;|>|zn21&x;b8umDtw){;8kcBL>(&zV9-5g*x@SD5zYdjYRUHamMAhnd4(K_ z7?e+TjDdt2U2Lm5*~d_QC<4^oWSlf<5QoBZTbP`2~4P5;<@5=OL2WHnvY#0Xdu zQVjM!>H!sHpKPcME<_|RyrkaRB0zAGVE7SpcZsEO3C4TZDp)C^r=ubHaSzzkWSgf0 z8u6#G;z#)B0??rIxBr39K%uRK0;xueVDaLk|AyU;bhx#h)M;1qXaTDYQunK%QCtY! ziOh3J^Dlx&t`aIryQLK+jkmY+ywlDjg{H52;3>ZIK;U^ab0B(5A7`W-Wo#PP7 zv5`&#;Qg(*uN|~f1~EBu9siz@Z3RSa*=%oWjkG1||u?ZeTiTNqVglJQ|L^BU7R~_?jQ6h`G-~2l7ENF6Yscw0C z z(B(S-E;elBxuuXTUiw=5TK7TMNn@ zbxly1GV%*D3XD^2uI?gzLVlZksdXOq)K51K$JE$^77kCT+_H}2P-U{XEDy}wz}pv` zflryqgJ+VSETfwH5z{Iupfx2msbrGcFex3kMJ}_8JSI}j1|acY3X<&-o6LR(E35L0WB>DlI1U`*$<( zomx|z-&?%B#j6r2*^J>;B0N+<8RU1J??)^=n;Pwst1;B7cwL`-ZW^Ce{K5I3B>WC6 zv?Q2+L7dejR9&h>3SG{y9IP$ap7sXjG_DgVVpIu_6V?1@anuTEtq1>!(JE*N?Lq>UOzyKpXQ1wQ%Xp?R`UZz7+ zL9_P;6wo(f+gyWTJ!0Wg?~7`tu^ORjMXQw?u-3zFN(j6dZATa<{s1?mINyZL;14I zrpv0dqB9?w5yg2xZ(oHr7ajD&!!SvLj(HaN?`1FD3>AD{)1_xhZ@y*Ppcu|v#2MKv zj|gsIvtYR4x<@ULyH@{{h+ZE2$cm2BE>*>j+pIJk)0ocpiOsfeWh)&X>kxu6hoS!C zZ_HAG^jjmp(v8kW09N5-U=xsrby z|72_u<~(ur*#<$+FW*S3?zcrl>-&?lo>G9$Z|V~;gM&BW)7pFmX8E_W8tRH_R(+*` zb|h90XmFA$@{4v32Td{*M5ONu_S_!`pz}u9Llrv~=LRVEM(tC(A>FKJCRvHUVN)Pv zDW9-~RBumC6HUp8&9GJ5+tYbqf<`CWVikk|!3`CyP`4 z;CYtr94kUq{#6&vHeRh}?s2;atFMXR_JF@I_T;#(Wn?>c(@L(*npt5VC5@Kt}K*8le5V%LSniY6!nk4ysLiRqH5>5$O;_>M1KY)pPi zMA19)p%FvDqJMl($d0k3YcmR#*eJ&Usj6_4Ax@i_P+$m4pS#Mx9YL7V@qR2=CmIfN>H0S?N+1f$Lmn+1>b#salyhfIY1=QEw%fygCR^^FM z2o7()#T%)Mc}gVaCi+1o$(Q@*VAXPkg^-kXcrIBj1ryc7BtR^S0cpmYXhd@C-*56@ zBuf$Vmidu+wenD4^@11-DndRuWn*e96X`;5D#r_lzESq4LomY?+nK7WF*v7uAu}*p zq%Ay@so@4KXdKa(y$?_d?zVHv#8ioYj}4SXCus`VUF~^pX3t&Zx}CjUjKcauIF)+X zquW~;f=vQaEDr`X6yC>FP4Zto!+;cHgY_aYKojvmSuM8GHre=_)^cSGE|EPf&z?+1=LT@xGjHqm`$N|;~hUpyDDgDg#jGsg;^xZOK^^;~NYDdC44x^CS zEHrg}8^-MIg`N9Pos{iKg!egG!NHs0#CuiGXrKmSTrokA~xInl&N#HO=rr+*p$gsIH- zm;P1d6s31!(_ks%GnCELagLLw6LJIIf*gh`o9x>;mxMs)(L1`KyfmL=Vw%nNt~&cF z2MvUooQ1WQDjO4!E~=&{@_}Z!EaK;b))^@T zBP`+W*dAI7t8ISG_OGi$v%|tCf~S3Q^eq~OFJz1s0(P~}%YJsT*m9{;pw6Ba-z(me zqtFVzT2>>XcU%IzcJV?zciHIUHx0=PLynv}a7~ezBf)+&#Hz2-DoC_~YUfoiL5gFM zg1DiN%b2b&HAnB=3P6u_$-_Qb<=211EOLcMd&17M(8(N>U6Hm>&m%f7(Wd~j^*IE7G zGG5+o!D~7lUa!afw4-}jpmg|scUdf;?Z`_E_E3DxP4Jnr%gS?kw2&KF7rk85U3%1K zHep6943L7A*e4sKA*nsqI;Qn@GEs@;229#eX5T+d7)>6v*mP zL@HV4orxnkDb0;V?4X?&@x}87b#lR9QxAd2#6cYl-$_ghX|Xpu&Zkbn>#H?$>v4LZ z)q>LHjae86(f`a^#b`T)y>bMSIlL$J1(YTuRL2zjg&fl0G>4#U47sxe5Ftem2IBi5 zB!wXQ2Ph6;%1aO0A?%Z7eM`rHQ}7r_k%rVm&jyhf(Vi~_Jzg&1yUdRHQi_V-P6{-b zk;%qXS(Ab^A#bg;^S*6rb?LmrXfkJ&k|862HelSM8xw* zWri^CcAV|61Q|M1u_XYI8jB60`6D~dCSI@+y<0H#F9hyYCk z<KgyS#h*_ymF-hlQljIws=D>13xn2j zR{zjex`YH|A2CW!yUO&Ku+fb@BnlhH!x&%s(Q= z00v3NVa)cMcXgpjmd>kj2P(;+>t4#hRFcyLB>+Cz&3zQrB4~siuP>T~i0<#7bJHa~ zJ4Prnfm!ITf!gXz%B}(=QA{2Tj|5IMwCpBG52hi~QbV z;IAE2ECD?JdKw~=5c7_Lngco;Iy7dY_o`+)(-k_`Ji|97T$V;lzpMD5eX#j{!xU#~I7&c=m-&^7 z{@oEN%v-Ua%+!!@_PmOIN+KA4Y^U(Z`;6BE3CpJSef|*2urIQAV+ZEz* zE|cWd-6CPJaF7dT#-KN6!xkQ$cZJ#VEv+rE;Rp2}*W!Q66t5_(pjsf5c2_>@qHhxZ zcnnml;?lJ3dDZ>0B4K5^|K&A+;yifHplQYSy>2r&27@gcy)LTVw$MAWO`v1uO2UiB zt@Zo{D&se&0n6nv6KS925r74kD2)%>JBdfBN{2wv=CD zwHNW+0(U_2YA|ZX!915M4EG<|DS`o*LndBQ(odocFI!IRXlV|&&XM5#CRX8t6#~jE zdc?7%m9SrO2yC&~p`C*gGU>_5<3C3}kl-zCzcM`R{b#L108Xu635-{nTcE%2+|E8R z`0~`bvR*#Ivtf}2^K9`g^~bMuQV_<`S0WB_)!=2caAkV2ETh-fuJ6cpJ#DBP@9yY2 z#gl%@t#(9F)Q`)QnEqj5=StN3_fDv+VmoVWbdz%b|2_rz z<^BD-EQ!QHQ;T2exIgZ7>o@Of*;5reR&9^`(C-#EewWs66el6D-5owMCJEcGZ)k$0 zdi?(J1}OifjbxAJqA+>A=oo!y&vPUd5t001oEdnKfh`w~k}L|_BJ5xy73t)s#Mpu8 zeYi6zl&)Dr@Y;y{*u~BP*PHv8ym#_C>#e7dTlqi~0+n`PDL?LX%~_e5DlgvzH$$T2 zkG?byD>`(Nu{Ma*YCan4=%fLIDj1o-nW01H*Q4#vE+nfY(3DgElH0@IYx1y==#e{y zV2f|%egV`1x2_WqXrEj?gK)l!$Z8M>N0sB($u2u}gl{Wp4O4B3LF84CULI((4e?;S zB`37U?-*xJ5s!@&)qU0AzD@u8H&Kn%5pNVyZ|ReZ%HOx6k_&$Pqt%rK@{Y(7{-SEX zaZA~g6}^{{En9PL#a52cSs!M{t!Rq4y@LwOqrmS$3*(+XvM80snk`te5A1Xp^u3mi z2LGy$4wWMbHC)1hu|FTq9tO;K;?C(8i0;|m_6g$3V$f^Xj12OJ-V^f0SbQ=X?D&rm z@S`iyYdgX3Il2FKmtk{j2w(Azw2N1IFw+^?H}8c&2fATd4I?P8Mt^ctrFYWVy(N1U z_sGr0c*-u{fx{v=FjI4&3|D3Zhst$aL>_#`maIk^;k2>vEc zj*c#M2`+d8^sL*AJP1-sz`)x`c*Hl_x8KELA;DIUxW5iU%9lNtr1nHd;xjinH;v|v zn@O9TxHS&qUL$`iwY+|BQ_Z&#uZ}!^I^wbeuNUFuhnyS({=r{AN^G_@J^v#EfX`5+f_`5@0f|0pd(mbD5)5W-JIbmn$yVtEHU8w3eah!mdf?l6d!;}{DIL6%HJ z+99_$ZtrnqmAR8J(hc_?t2<#+vvTaY2jljSM^>}_v}Hc1`S1`JeFL;1Z3G{)NS`{x zS5uD~ht7}?9Fd0dHfxC*16X(wSZO;|oMYKu=XTV@Ui#{LE@#WWAyL|=J#F|foLRIOP2ZU&(fskWF zOZNcnp5>0kWrlD%@0Jl}$yxudSvmX5D!5rSIcz#91lUzMPyE{HS=?2uFM2i@OoP^< z8s&81kJWTLadIM)_k-G{J_9fS%w|1)~Z>+_~;P z3n_gpnTWIfcKoz(Bhp{mp`RWL-IM!hy5e50C)=vQ>EOkH$2h0DR|F6J-YfnbEaCd% z!J6qOTB%Q;qN5NH~MbqZ`mY&d(mOD&$g z9P%w2@T4oxxaR&=KF5btBrhAu+GuGsn)z`|XPE0IN#%d(Ep^+oEPt4Ol-KGnU7eB` zSp|ouKK}~MzQ8&;N=s*)zT>K2E|1SQbY%!7^9_#{Zfq|tP%5iK9)ybi(9xJx%{#Ez zim72K*TdXLh0;Hj4=-*Jk>99@FOLUVq=futiid((EZXBljBGmQ1a+PWbioO*&eiL_ z(TS>hPTXLVTg2Nij4cOP9p2CWN`UPjE^Jp*ouDxA@{b4~gP6U{9uTdjS^Gy$+=GFF zaZ^`_XhC)=)p4!n4Touy@^-7k{R_fS$p&ZqL)?xxITvE zft`Ht%sokQFV)`d;dPjGW;?;iz0W>*gp4?_sM7~GS6%IxrUzZ7CI(jEgscno~(uW=a~fT+3wt3o-c>7Sv$d@R7~hM3bz-yeM#_hIdZgYBgky_=m0sB9n@iq4-$F zQGxQ|9@!*eDA>OaY}q=?ejLMHy7pL(sk90g2S{Xlq_Kb`svDLQ_kV|oAJ+|lif|-5 z5AE&f$8A-ux|XsFqMKWsDSC~K!(Ww2`0GSSW#I_RA)ElX4Hf?pun()jKxdsdSF7`F z%fyDa1&^^8E-osnAL=J+Fk%Y_c0HSOY<7c}5Ek<1WYZdC7dR^qDyV*ZNb4uN)>dt+ zrR^DmWP#}O#VtHCH6BoO;R)H`(<||nTd8$c@qnxwv5rV|DHH=j_Niry>XC5rhJdR# z*3YL!D$darTs$ViLS1W%+NFdm*Rt?_d-sIiv1l09MmNEX7G4CQ&8iEfZOZ~D2}f=3 z5#hDfeY_NW5F72y?Ik@DD+}-!F>z62Rwx8f>Q&Gsa^!T3~>HByy3jAo)P!QJ1xqj0Fa%z3Hh*C~wPrNO)K7u9|) zEq_!BQX(BPFMtB)N~&W>3L=}8;6;^>Bg8~=>BW-_-DBZW)pV|)v5;7|+x1YiG}4AF zYeg(|y*Fj2+=qz5Yz9BRkW$>)acH%r%P?oan%m>M^>M znQV4g!sFQEElP50*)H~j`U$=sgpe~On5*-`gthQ=v4dvqg^!C->l1CO@HLj+$c#J>;=&NZ$7EPmA9M3g289m%TO4a@ zV>|T;GAZD2OIy62q^cZM;@deTE2SLws#*YNpba4{SkBlg`ZY-a7v=)O74hJrIVZoU zSMQZG7e^_A9gxx;54(QMS&C-S{O6!i&2t7F^Qi&L4r68cFV#Pcr(ORRq9jDT{P4G0 zRMG~g8T#hq?3lnnW#9%9K}_h&Z=7%Bh!6t)JSI{6peSc*!9d!J!jKLNY{^1@Gk6-r z$X4j+6{=v$_I%tnx|8|1sma=Qc1j8*M6B2dH5}3A6<(v+0z!OH+sSFv4s=3!&OF+D z>$bu_U4k)8Yw|!*>C(OVpU;1g`ehd)$YU_40r*h^;f;X(0HH1#P3TZQHgpv27a@TXP2! z+qNg3*qS&K+jj04cZ@lC-tSvyt@p<{^{;z%@2(bN z)SrC7-}p2IUJnm6J@Wg_W6_at5+R9{#YJD;vh0_HI{I}rYfJW&ED^D~b(=&|Eq0Ef zU=n}$>zooT6w-5jAA@Om4jxC_Z9eC=rWURAL^YdRH##uM4(lIAX%i_j&>6L*{fZ!! zK|geGUH{#~RQp8bm~F~dh{aJo=-cZr7CV=5Adm$5jsR<}s_!S_f6k^O+LV2KVwF!b zq$dqn6#h9QKG6S_r^C$1fy4bshd$_;PzTgKDPn1xUaKh}7kFk6Dn^v4f~a39tlHmw z0dV%2C~w!qhsK4vvJn|T4U9BEacA{f=%7@l!K1r0!a-4G-U2y&9BwNHl(_YCs);6x z^ZB~;iLPc?nernPR-B99!}!U^D$@n z2}8kO&Vo2*G3OCcq;E&^>vtU*{X?LrYVVWztk{I4;PgN!Yfzf z@ZpLTbF>CMdMTB~dOFTSXo75AZZ2tckjL%f3b(U$vg=YV z=+Jc;=Wf_Faht4L5;=+~7Ni>@Eb%BY=#?w_i5N2ZQjWMtUFzM~o|)Gx$)zb9#iTY8 z!X>k&6gghGAV@#VF}2lvmhOOmX7N9o1Ll)CXoa1`l>vTUUjdjZFW?b;g$CO1{Ths2 z3qeKGCVzRy{2HExm!6Inc3O=Pqqgnia{2f;9j|m$ir!(e zZR@+6hI<$wVdYXy>y%0L?nj%MAV<=+G#X{A44+-$CI+iNe z+!cD{uymAQGBe~s=^{#YIP(nd{dSqBVS!z^XTYU@wvZ6W+_d$Y_glfEbX3KH2(+cF zpL**=hSGDx+Ef7 zTJ9_8R`Wa;v!WV(iOCKMw97WP8M1zw&Ca0rW~W5H{!M(qGvb_eq=9+YZ;X)oqfSBr>{+D}NYOBea`2Zaz}h zL?7Ag7SA=|pWIz4my9SO`6G{Qk!zaJRqFfdjNzRkX) z7utE z&v`w_c##WTbPbccaM3OYd;O0Uk|-u0tjAAqxFHDuX6bt~t(ELIJuzu%Du13&&IL5ulNUA`iaQE586CS%Op@fxRuALh{KQ& zp#017OAteEf{Pn!xD>X)JaSnN{##@>MC65&R+A5g$u@|DGuv_cYR}1~_}^5~kl4I! zd5_&s$EI^p)B_hv9z!ifD!8BsChQQH-64yhx zv{|7;lGHsFk|-$XB%@N*EAZ;K)e+Le3WX&bB7G9`g(_NLg*DM?dTdZB_JxakDpb+M z&`^W(H~pQP5^k*|P64Y-*CFcGoNvb;Oq^Z!T|k6G3=aQibfPL&&nR-AWR^SF@%mR) zj^9BSPL@9G(74aVq9t=B%q-_K-R~gIXdXnU-zMg-1FRmXJdJN;f^@{XRscNT@7vB^u!!dF;IQ2Tz}pR2{4B&Dlc%uVg~DQ|nj}z|XEx{WM~&iTB7MuxM?~6B*v~S3S>i@-wjYqu14ics@g}YjafRM4 zmZzADB&caKo5&Sx&f2a_?6YLJlk~Zv#j!Pk+~)|RI=2RVFP~F-(C@v7W6lA|bdIkN z_bJ>*8Bh9i;4lxr5EdsDA?SK2x5Q0)pFhy?UY(YvI3c#h#;P{DB7` z$m-S?c_8BA$QyP4ZtWek#h6G{_JPO3F8rL&@Qy+v$YcBo`%oc*lYb}~;Ed$G$MgHH*bjKXAd{Rev933ZDV zF)uu?GG^$6jG9+Gz|*{i=>9 zoI*aR(o0`70L8(ar$-X35VQxaVlpr^iJTq@AVUgUQ}e9lqC_z>k?fZ;k-tbTTZs7u!9LAMp8CMgI@I!1KiaH%u16 z*M;mqT)<{S=u0;RCg1a(IJrccPOIh1jt;=!e;GqT7e8&^GsAC|zst zkk;siSDD+3FxM2HmBdH!yM7`3i=0X2+LvW@d=n)zh}B&i3#2237~@5!>tsuKF zXzsrfGPD(pDcCe?PHG?9O*Bq!aCGtKj)427JLAb2#$&Y*9fejhaHFH13zDviWhWyF z7xmg)U@MFK7jj{S*Y%3+m?)^C!VzEJJ*;xYmRZ{7`gQwB&O`2`Wa-m&8TS+$N}RF9 zDkHrzfKqz|4-G|v99!wL(|R=!dj;=Er@8+ucl*;ZH+tve5Jf@-P{_Yib7E75OxD9L zU89HOOFb;-JcN+A4c9vl+F6@iq3y{#LLW%j<^YwRR7*@w-=-MnfB-;U zmYLw!69TTzeIGH&aUPb%=sS2-GWva`BoC z0t^f{*KN~;z!Z{mLK0<4QelWq#ns-vX(fwp)Zp*k@8wZI*1~Pm zoocY3m8^}Y`DSV}u8Cm*G!iRg>gzh(&&O07HAE%C9MM85G1J(SuF3KiPiOBnFfX2~ zjwYms@{nva(XvR>`fbFM4)a?;ms6-S#A<9m--*cwB{PMjofR22T7eKfyn{{xd29^S zmie8kYH$i#P1RL20V*8B8^UnLzI4Yh_TEAoA_`m8d~N>o^NNfBjR-DDR7b5iG%HG0 z<*di#EKMbrQj_kiW{h3o4{9b(y-NZ#CQ}ZxcnL+Ky zeCd;y&EvBgLstRRJYlwU9dPojRFwnRj_Vs`S#H>B_~8*Ag;%|!$C=t~n4)}h6=y4z z`e$=WPlPbd7USZBmfzq6jsd%wZ6#{8xkuRuycCZ}5ch?(ab130Hm0Kw(o}ppuX;wlZ-}PYj!iJMK{hg@_zJ^^f2bP-X`iLi(@7Yj@hfhwev9 z5Cjx0Ws)Q^UD9@u{$w^&3&e8D-wF|n)JSw{OB!0>kzZn{QGFD9C?Q%9$G0rAzsb|7 zldiu`2*`L323+)T?Dox^sb9#dJ)lxZqVQFnH->Zjw8emt2Gtme+cJ+$u4Rx%y2E!A zIuqw%J5os`el={W)|nSB=~{+nBYHLpO!7ubTMMFcnL7g$*55l}$7b*3?{9shf zsI`B4*)K3o2=}{>Tg0bWSf3N~4U~96W+EW;%`v1p+tYH2+j{!M>`PCmu&cH@x*H(r zP?gni`3~s>#b=S=9mnIYmt-BC=He%%7~6p3hhx={%kxjwdc|@evG`^0%@LP6d#)lo zp|iu(K$-n?$M;fCM#h{oOfA}Iw{9)SNtx0b~S~_nnKRQ4w?K~m{t)K2M9HP_AtaSqvH&0nVi#M@&&7! zGoQJ#Q4UHv!DazS6d9=3g$vL_=OhG)*{1D%p=b#1wE%EuSg(Cd6lM9x2B zVG8NzIW6_R$GFM;mUZQ+8eY3P&$f zV|oACD%IJaxf}$>lsgA|K_hIF?gC*$uzCYip<_F6$cAfGA*iCN{i6Hzr^~W1w0HJ~ z<Xfg{vG$}+tZ6_M$Rb)yO2r`$1&yqX8uqJ5ZVJ9xKek@6Tx8|MuKtg11 zEZvyG%+Qo?nICYGGHNFn*C?j9?V(Zzgopw>1|Z`@1`RtpjMjX=C2c=2kbP_J^@mGP zgljtE2@?IS=jzFTMSX6>?8hPrh?e$Jji>;@w{xTZrkM9-kk~CQ!0+#rhN6ey`mv0# z+2|YrB~ea~oG4qz1&t@UrxLU~sQu>smlcipIsV9n(mmSV))B612|?7 zzf8>4k=m9TcOgy;`Ncp0oV5isGHnl@nz*%_cX~?nLH-!c2>M7nuRhbz-()9V(mzox zZLxhn0K=VR@tr)`$;%4+rMz^dyvkcQhM06SY(GbcFRpliY0kysKLZNieIJyTiS3vD zv(dDM%_t-R8)fY=ZW7Ya{*QIQ_H##_!4hKYbB^$U()-i&*HOD6FPR7@_-$N*FSfV(k}6^FV4 zPGdvpVR9ILyZwm~lLJEW3|}(rEc!+iAWBjH{!7~XiIBTDOFd7&TFtS-7xlsT6hP07m-Jt1FQZ#35RCXc&I%2wv zn+qoW+Pf0ilMT}Io@RT_fu<1F(N!wT$(|chTYk)dqJV3H;waF?80w+=n=#{ofT1D^ z*LEazJZGL>B@Gb+K_l+xs$BbNQcXa%x}=2ITQeklyiZrU5^(8_^GfF}eJGCS^N51B zu~0a~2#F~<3AuyRFNZ3c$mnTaDyAskz`Lk16S}mP0xGPM$hXLGsb!*Dna|_{x++c< z`ka^J$Z$GI@?d_~d8pN3Cg0a3cH{Oe#3$JTXO_co@8jBvJh!3k+ zP@0`E+-%?jPEkqx=V|bCnh@IirMTSk-Lk0fNhA$Xws5gAK9T*hLjS@HD%byoDffQW z-4(Kb4bMzOXMvADEKz7o_kRNanJ>BGre){6;L&a9k(` zb>VED@PBS`)327y_RSpA0`6;;HPUr{&=@GhkB!mVBkSCRF8W1!ApRby#d?7_pJTVZ zyoXIf+jF;atXCXhe|>2H%N60AU-i?N^4=s|iz0@z#^r6A9|2X!yC5|-xGomw^4>n( zth{`Q=w_QV6Dt@4+9lhP#YYHoC!X8E$?Ruvr_eR=Uzu`BFzs%s$s!SMFsTI{L#URU zj8JGjXO%~u8DmlMn5p!(JeCX-_!qq@CM;kYd+JK7*nA9LBIquS!Bu&KV;&O96SuO@ zG`61LOZ%!U*bJ(YL$~>Z@pTE|qAJ{TKxueAnYsqgZAGtax-E82XmyLBof4i3fi8C-KsgE#DE2WsoQqRU@9m(8-pkEo@8U+ z;Pobbz`t#E1-BGdgp~Eh@s~8ctLL+1?m(nteRMaQ5S-K@lZ{BohP zVH5T-6))@5B=oGsPnrtt9PrgbmDJlg_)PhFi_DrC)u;k}9qL-Bo}>v=*L*6TM^ zJyUI99MQ~sE=9Ommtmw0ve&XEi6rkm{bxLrvsCj1IS@v18!6n{l2r3j@KOmAg`{&7!CZ+ifK@>jjryHT zQVvaw;$0PUKO*bpc)RJi-6>MY+4Zwq(Dx)i%Xj+gNsZ5H6EoUIaY+ewyy z7ROl;xFtOz#uM)VbCp<%UzdqevC1dG#ThsC!rz%3T@*9;TMF;M&2$L^7D%&vRcOKl47k|2YE!8Y4tfq+L^cg~7c|f2UHi(2;HAV2W0oQ_vOf>Nv?Vu30I^u(|oKWJI zceGMZ(a|kBgpb;RN09iFPf4ju+k-rB7Eu5Qy z?em#9oxA{x-m(x<5u9W(@%lxBWVLE2|h5V-+ zClMAikmPq0W_;|xrq^#6W%8{2{OD}HGcH+U*fN7T@JYIE4!Z^^9;sY!3>EEBOx^pt zsdz6w%ZR?9*3}uk9N76_m_8*PVY717wPDc&ctlc5Slm(j7#2=`w){ZbP26k6ul3ku zFoiG#v9YCxDOHqd?b*m#0H!5TV}m?M%Gu28;7aJ2{6}9_k@I?f^&n8z zE!h|8Lp&BRw`p`D1I$Zb-7G3<``}nYWAox2jUyKcl(cGov<-74APOQVya{|*wxrE5 zKeq164O5@zCz+M}!<`F@(W}NSMjgS2t9cwHr%KZachMEO`8+}K9@R7dmwbAb<0NQy zL;T)WJhyc!M6x|sl0nCajKM4j{PRz+y==c3dNN6e|lkjxRNm_?ghf$)&JwcLu2|@%%z1q)Vr7z|) zWcwOgIJ9199>{g#sBWX)_u@vfWr3ZgRJvw z5Pcw0$l74& zAK>RBBJIaB^} zPRqkg4%$dM;EB@TIv^*pvRvP58|XG4pfFBc+_iwTHsWLD56bf6Gw&2r+6Ji*c_Fdi zkM>bZ9sl6IyRYqMeNDOnXTNUcYKJDR&S)3>ms-@2Zm8Remul|RBYlCK0(qe4K>=_F zld{{Cit3O0wdWT}&yr`5j36DbKrjlB7hz))Ubw9U(3kD)>f#cN*oPzTU=hpdGv?glVFetAo(97 z#f?dPShJ4sa|MVi*YOW!(%25_7&KXTok{@J=7@BWjh4F znQ7g8V24uY$;Du3>*&&hZ1vJAR$Y>$8gI@2R>}`#8*po-FB?jvr&eTEY;OW8Rk(>z zX=s+JHD`X=8>c0?Gj8s_fg36}8I8`|=vp&ZHNy=+wA44#@we?VO+fInnkAXnbj1BN z`R=wWbRKp5lVoq*TF)I!-5|UWC=PB z<>B+ourI$hp=^LRvNnb`8Ki$XEdL-Hby==@TaBHRwoCH4P{Cz-K?VGT{$MP`Wr{h? z6p`F6j9^W+;N%eBS4oMr6ve?r0f0AE#b0jlYP`9i;+=-2{*%E~7%qrpl1b)=0fmN~ zs17&lZvE31N2&FH=FR1gH|rQMP5IrdbvaueL9hAt)jTBmIpMe{ zJsFXzfTc=O;I1{Xe=wndTwbt6p~uBBg0=$An5WKE8P;aW$)j=PDC?KKiAlkZQ{;}!u_VC5vyhE4 z2w?m0ZR1wrsg1=rVf9|)zE{{YQ$nj`Z@*h{Ds=`(x<==r0f=t~FfYeENXWpb zGj#pTT}>JG#QamwD=6|_l2hwZ0-Z1g7Wgk}JGI1{=Uu(k?M*lf+VE1*VjXuPCtPvt*MxN6z*DSg?MVkAch6DI{h7o|~3$7!Vw z!5DGMHP(NO^{Qo`r?(Hr*1}|WPbiHyli#DSZ*P+vrpdlm=RAyuPl283wuE)QlAQu< z6=Z+_eZ?s@7&{{Cv6F9K@Nh8dv*>(jtSXgQpDiNV6&JB&c6=Nu6^uE*HqMwsWWCMj zO~>hiFE;qXHwLMMHf{K_*YHRoX9jpNf5UGgcWz7bKA(^ z1Uf&~QbI-JMbqrpnuGh+Rjfi2GCUgBKW!5}1{3>U_b{64T=bYiN{IHdDLi&X9q4Nlb6+>6OuiqU9ML*+ahSRK3E@8m3bJbtK+ z)HD0nW5{oVTK>Y0Z$1^D?0pG(=ngpmtqoeYax1~=LdQTiUhFQNqj2X3wIyHTfbPf( zAqRBs)wgb_AsRKc+#*OEeZl*_#Zsw<^@(F3Eras82bw|_p}dvS#mOc1+u8HKlj z>E&EaL7O6x#Bx8R^jaI7`APBRV08R!+`gKN&Asp0@i8M$hkCcBss3*FGaZUSqpUz9 zj3jG+_FJ&-8@jkr$|>K=nU$YkiJsG&qhZ{jmkJtAsJ4FhX&B_({tLz*alL3`4*~DP zFt>k198?EZi)+^I`KdbNxR}FS3XSXn^ze97f7B*9J}(6A@8@2Ro|4dw`d`$nRb%ec zz!PBP{oWM9;XB+YmIFdnSE#b%Gh$8?oG+3<%Y~swq+kMkL(zo#wztK&C|oDq{k%~m z=OaJNbg&cOzUcf;$KI}ECQus+q^HtzGgMmH1%M}Nl$@-IkKU2S64McCXHSVmSfTgA zt%~hBL7~^!J(^U29fb;u;%-^Y{?UYJ5~hD(H+G<;J*}Qu`54?D7c$5xXL!j3Er*j4 zez!lkMVP|<19<6Jnob(jz>(8ZszR?EiVN?^d`7-MK}eCtRq|1ib-TRZvhfzRP5)_6 zBYLFD5YK#dB~W$runt%F)`eS18%O*94%bfSJR);4Ilz>>Tei&i=Y%}!^Hmy&Hrbdi z5Ztq&OisN9{O6z3r3Xe~R)Cp!^Yz{GwII$f!R3~vs+Oz5nY1a*)CXO|vh}l924T(x zwCRMuipvsyTBMgTs z(Q!>qlzE!a*Zra3qu;b-O4?NrKE^%nSGcbCa+*8(p4>mis`& z8nINQV5R?dG(rxK(u3Br5;(KltHFjUn}xoQHhqGXoH0+{Z4~>YSo;J&VP2o;1Vr9R zu1%myRh3w3{tInzu#)P0+_#v}>4&ejjT(c)5Z)e2g2$?6G)c)g5ujo~buvzDCtJoU zx}U}V1XPuFA6=yg!?QT)|C7mz=pkBExie7p-mVU4q^5H=OIbR=l%#KPWwY zeP0i1DmaNhuwAw+la~tWaGU4h_6dkrFjO{ZK}BIOqhMfr!e}MPweIu88uG5h;*L#p zB#iKeR1Gl;s=wyH<7pEI2q#0!<2&z+Sr&2H-80-ts?Uckt0UKwA{D&E)Og)O;cXbI z(gNLFjBtHF_qPZDY2fE@Y+$gC!5J4WtM+I8x|Bb(NEjm+!g6yVR`2Jbk}azR!}%ZNt?- zhZmAHo<-ASvTJ!KHCNq1QOiH4uxX0Y4UI77Sb&U2z_b=!7P4b!4gOfs!5}TuQR{RI z7EAo)rI!3(v--bu#kId?#u|d<1vM$)N_m@zTV!#nzNj(;Q5ovcQ&Mrg>7&HN{ZRx##Fe5Fsg%0p7_T2px+q29UEyE*FUG zI<4!RRH-v{i*YyOxN1D??OY-BI>9}s2?oGRM$g6rSw?J}80g1Y({d$Ip%2*f6BY_z z1AON0pUKG>?veiL_}3V(PbTsjYelQzhzZV5de@zwoG-$bl$Z}IP-HW<(ie}eBtK@s zRDHaC&>m_?dos;o(l}DBGb_ZUPyvLNY8i$}qh=tF)fV zGH4I01H`9^(ocV}=l`K&xL73iOgr1)JEVcsp;fQQ*0)7?_5RCy+S(gPfHR1e0D#@IxA8ASHx_4XrvJBV2lsM>+mAMmOi_W8e8>`ToQ%TVM2Mwm& zrEShWosB55t{4=MX&?k<5rOEYl1@pO@Hwd=0A8IL=k0BpM!V1JTsV&cq1fULldkKP z;Eo}jA=hU;_|WFn7$Z|C>%+eew zDqq84SgiPy(flcGO9)Q>3aXg)Ew0!w59%!=#5kAEP4E9OA`@ zxf_tDaJ<-29|RR+GesF`Y}&o7TAGG-8}89}+R&A3dcqrA`%B|c+v$l*sIm51VeK_Y-1eYk3e48iJfJ41^YF?&u6H}^5Rk^r7f9UIOL>n&SBVBj1C1E1jJGx)Z0AH zOS;RE1tAuKpoJVC!;Khv%mrI$DHd8O zTz%IH{vq(^YljDR(#*%Y^gvT@%k7|<=zvlFypuPKM+oUq=?Tu8!$!M&su zh%co5n|x;>)tVA77#d-OBRStzE`!^_e@!!-EXcB4kyA|=9N&z&b)+hL1&`%^IA@Nl zD1=&j;_^2j!J&hDLI!}w<6k~mL>dokn{A`&w?LOHLp>E-lUMq^3%v*bYQkT3MY=7= z{B_5n?r;0&P}vA|?Y2di`k?Y3zj`#^@QGu|>fH!zM8PqyL{fC16d(1?gg_v=wPg+L z0f_{!IwzNsHkGh1gi$_j7Z6=C@yDpnFW!0x76@2NdZb;&2ye0_C9jd|kif{X>fnO!d)vk9j ze7#EUCvo_q-5rhs9O%%y`A%Y6Tn}RybJAfQ;!F%>@mIO9_U|OYu7U(MS=Qg^g)oMH zQb0@!fEqD>avj!}(>#ura6B#~6`qD!oN7B13m-XD#1$odScRH^2`yn#;BPavS8^}% z%f7WA(G-M3&=FyYn51KZU&SqiK#S%^2Ob^8#>54uIw5ITYVA%y!jXJNOi_?ziy1Z? z&7E}m^g2$Gu_WL-TH4hO8vmTMg=Z2;&e2*4Jt{`RQVp>}cKbI!3+p>~u=!PLG1(XCmRz4BvTUp0 zkZXq&`jE9ylGkpallGJuk_gaDp$=1O!<>FrO?W0Lj3Whv_cwX-0cyK+#X0eOwG%if zm3MeS*eF!`y*;UCtqS>}af6Yw82Uz`cn^OgP4pT_I!1nI9s{ZvA+}GTkdXW$Y{=u^ zJr9-wAdx?%kd$o)#(>=HRbz8N`yQ6BWHNpnT5Zr#ui_$VnGUHO7}`}DEoF}~FK{k7aFD=w zK82=JKmljDpLTPCvw9hI9+99_pDxfGIjHvRWd^8*L;8(C;CbFtY+c}Op0Vp!E23{} z@2G{riX5Po(3bKeg^>@(v26wPQt3A{=j)(jw+f8C|0;P=nefdYcfY%={?%>fQilgvQo`WZtgV+qtKzKJLg0XQkJbJxq zEdfc}AVOa*A*L9fVf8|-9t~RIFsX%r8C(IUr10!bniK-5#u{V;aOL@?tw>atT)YZ~ zdY(z!mX+e>Se-7}887Nt`_~pupS}?qVLvY{18))kiK(gYc_3+M{3x}P;b6FO;uH6O zkUfi;1Fv$ZM%QUfP>mpYK-?z0Yhr$~TKSWXEZQjn<{Y7aDeXwH_nTZDwW*Le9=@_F zcbO6>jeASv=aSU{GB{Sugwvk~7l#tq;UEI?|?*5xBz=yo!o7KkncP(W6v;-xV z7!cl6U{xZ>RPLWwZ~>@2Zf*YeJ<8sYZ4sJ#P1QpP2rc4=CXQp&k%9GtavrFiH*4?; zr|MR^iK-}>=tTlXfM0ILi*i7vc9F?)F}&NFmoCdyE7KK%iXVV#2y|tK$44Hf5bnug zxA6#vYW$in8=9U#q{#KsL|WOhemzLeT36;;h*MJH{4%n?054l*!%CWQR5aQ{qHzhz zbPO*i05f55%q1iPbZkTIR~o@)2c=nEqDYMPDt$b%tmOud zg=MT>Pe7va47UAs2;OS!z&9T%ndZMP1TQ5lH;KlbghgiGSL@Vr2!1fgn1Rd9*wrx{ z5D`~WR~hw;y8W6MmhEUp8^HTh49ywES>6PetiS}h0w^)hk{|i!*$khg$xB77sl$Rq zT+@5JE8(rcS{fmNye#kht5<4>rYAbDleSY%rPa@HskuQR5j0CbjAHoc>Nd}af=ZN* z^q7*rN=1K>;zw@BKF6dRL9r;Ag?(^Zz1&!_=U*cfqy{-0k)dSxTlrv&(r7V#j>2|H z=5vPyWvL)i(8$I{EwX%V z>I7d1NEM}w&PDfr;=DyWQ#8-efcCCH6QF}M>WeoG0x0LZxMxkc zg`{gn@(-7og&ja9}v^a_Jd>*Sf&nzZGvkb{`0edf6s=K~}FzoNWrZK+0BW&H9) zr`e;tb0foD&CiANqKsHc8ui-_Fn($*0|S$H#Rn9{A3K{{AX}7l5+2}KQ+HRZ;$C|~ zDda=fPi{y(KJnVs;v3iWuR1=hxsCSbD5#(dgY)u_T|`Tuws(H_LkpVmyhZ_84$9s* zb!ht6CR+>IgHSxceulc*il0h9LmPH%wHrPymX8nMw3Ft`Y%kR}Uoq?yw$eQHP*>|s z<<3`pf}0?_^b3-Cwj83Ll1cKgObY!p{sfUiZfOsza@))>d&_MscJ>1IkkVxt&S-#o z=~TJPftH4FcSq5*it~iY0i?}X#(#KZOuKoik9kL4_zM*V{bH;Bwhj+Gjvc8O+i0UQ zEPeRShe*F2$7i)-F(=wAQtiZ%$)W=Bol?0aok?OtZwA+bwtPbPf5U`TEmfNp3b;Iw zOr(WoZG)Y2S4?3caY8eGQ;v(x`D{ZQ6L#)t2VKB6LWvo}%o{VUZxGR?BH@y2SJ{Y5 zo5wHh{d52^K!b;EX75o0Oq?>DCv+MgT(vy}!#&BhPONRHSnrMI)1(0z_8*_>>fqKE zq52_U@l0u3(b)#2w9fc21I`Ey+Oz|knUZN)4u}LLz>R&QgyRz!Y_#7MlhUT%=pf&EcH|m z%Vv+@^QP$&qJZboBWYtI01~_dUzfEi>(_qK!=v>^I@jz*KrdHnqR|Q8=(&INnY@oQ z&LVH6xO@M?*t}k*d?9v|epOMk6eKvJ^@!rt8N4hm;gPIA|6=(jXkz`tSu@Vhz=O_Vi8^ew7f4pAhpk%SJ7Ja9P}seJvq53J zx`cZyKrlvO!le6>;k&dlhQzBM**P0pLK@4B|Ja75RK)6;4nuUk)pgwN=2f1D9o zl(txw%p7kW1^%Agc%{~h_}v)wRy|?-QG;s2!UPatHd)vi<>O<=%h4#3v>2&b2FzrSYZjO5ww z1Xo*ABS7}mW}EnQjFPf69Q`PnJQKU9Y-U7W;aoTFJEVbM7VsW+f+jyp{YRq2QSO}5YHzOR#shr*#x z6AzjA9B+Il>26_*OnlJ=?T6?HKfinS_Z3z7D=uPvweqD-=+D&<$p5L)A^W!?t*Z6E z#OyjiKL>7rEl|Wc{A)!dgqljKN-9}fP700um)Na|7`jwH#is-mdNeGw+AQGyxIaW3 zl|%@xm<-`;%>Z?N5a;Z7f%pw!d(Ik%-bwwzz^vE(yK`yP}fx>WS7REd&?xWP{Y&iw%7~QACzd# zPa_Y*l_#R$BE%mB0EM@APCvr%9h|xID|x)|oj=NAUH;`VV6-P`*$slr?d6O9|I@T) zU`G#yM2ltWMtuK2U zmhy0T+>ALFjQypn!;s+{!Ye<>Ba-j3h@c?KH!r(D9EP>1+{D1NHYfPsY?JrF+}yKl zTQ{HZo0})cs|R?)Aj91o*!s`?>g$wQg8*0N1{mJU7qHgXgMg2ul!{%&$p_EVjsnmz zNNy(V-|WJqoC@Qn+%>DO4gs+gdg#L5CCq~y7dt$ZtZE23UJ1HHupC(hc_bMI5>+Gx z)FF;P#W;0-;ol-RI=H_@!Dv`$Lm?5tkrk0oK{JF8Klc1hW-33+Dz|F$6n9?+N$)?% zHWs*-w6>&v8VGMSouKt}yq}bsUM9G>H%JSb3jg-!A@49%;t&n|M+-ys03Qac;_dwr z*WOfG+Q>?a!g+sJrh;^v5+lf{aGZ5Um^^-4L?=?&@+-=Td91oS>mG%5Dj6g|n>Nxk9wj+ zN;>g89YSMSg>#T+{JrVkQzz^`rIENPwqe<)a69(1FtHhe&P|^sXzL12TiF{c5M(HG zR~3Oj&*+w_t{SUuC+t52jnBI&HNYqa5DHc%jbuAS$39R4E}8>C&P==tysdUZfim-~b5-C`GCi=@3DB5tN7&QKTLSL}~yJQM@_# z{dB**^KH-Ud7jz-wVt(S)}H-aUFUA}m_}6)z!x4hp@@nIp~XBCFs}u2t1V;N-ir)q zoT6cuL}J$=@%i1z?)_I18ayU@fA8<+ZANv&L^%p!T@y zQ25m?odhbDA>1ujQ)rb9;Nnjw#K$>lNn7*!2#G9mr**z@rOEH$b}w-$b7v-E9Hy#*D{wtS}V8H%hZk=%BwSAkk#bLKv^ldH+6D zXwbV%{#lB65JU*A!}C#EDeFCbG|r0-GmQOUi7QIAPbrkWB6RO@v!)S;^UttI$S2wG z0E;7GFmc3R>v+>JmRePdf5spS3CSc)N6JjV8%*sy~!GSl>0SUJ4@tweT57w8Y?dgj;J*n503 z$zN|&7IxV3hGmJz@fAMCLoJZG4fpBgupw90-aOFe%JB#<^Dc0I^H4p5w$zH=+Cht( z@N}I1uL(-cdiPwIJ0Hgd$I_qqsG#lgrYf!nRm$V|SBI;TA89)kkzo}HkR}cJ@h)BW zQWyj;^V*ia-amX1dfp(+v2(9L&#XzE4c5wBSqdESaNT}?v$a3^=X!RFV4o70 zedYF7;-%~`9gQ*I{IH|whoogOf{iPLO6ttOnz`+tpZ1>$=IV1hd#+xBtKV6bb^~^_plD>`bbdMPQ)yQ^yt&@cL_C@(? zw{O-)u5Ydr?I3F~xFOOue<6(@PwvpHsOGlrQ2{2AQn+rsG}vnH6%~L>*6XTY%ZL15Y#jRoP;%grOUDn>oK?)tI?hhwwioZ zx|5IzxjHfHt&2co>F;pN>bXSnwv?242I56$M9qrGam?KgZXa0lK3M}KHhZK6!W0Pa zB*WVSCMQ6uDJfA#lR_vq6FhXMN+}E8_%dp*%);-tM^W)N`krd92&IeVwGQaTkf}>N z*dwY4p0VYfIe&b`?>PXK<~Tarhc*bjNNWWu#F**3@#K-XtuWYLqwF*T#2{nT?^S*g zW7aXA?>oNCIU$h1{EeCjf2bH?EmdBC`({9cJOyd%jHwxBsG83e@3?ckMda)%SV2Zx z&||X6_3!qTOYA5iO-M+XhUVvNsVAs=&7(6bP!{jk|K0zeuZc`DLA_AKfpuzHcFv-5 z5(zOtAA1No z7a7$@0i8rFb?2Mue^_$-nlLyH{Yw}pK}<{M@6D2Jq9r^x)?@ucKB%>F;j6JC!5W0$ zjMnoPHXO2h#NG5V4!o3Kja#oPsvKJ^Wv94Khj8cLVY>BjJd_{lpBweKlS9cA@uL`H z^rTRaNUipp*%+jgK%dlK#f4LdKYrwP4eU5xeeYNGlbzUWi1x_e75h=N!FXD zfQt~u(Evr#ETbb{S?y&#Ri~FKe5bT#LQDm9E&(n|$v3~CY$0GxlUD%dk}A1^)Fj`Y z(=@K}nG`25=^%)UPft;$*_4o6V4q96tp{_B@`O5KoBzjpg_B>LD=>#yZSFuy38>&4OEnsfp>VdbXPKUI%6$vTqsFd;wUw z>)k~X#{OV{p*-GDRO#w{2tt+{1xuj%!$w&1`?U$@bo^@chGlx-JwU5+j7$})Oh&KN zO6QUu+>OYc(?=+SjxQ?2k%<1tKwv6e+f+Vv9SF&x%VK?%qA-4x*Fpj%CGs26!a ztu>j(2iqpqjKsRt$6ODn@+;Gr`qBm-zELBDFId3rj`%9>VXQ#P?Z!;U%^CUL%wJ6~ ze~Fu)Rx~bgK8jQcu3}}9WpowI65-xdxYqriuEjGfj9pltM&tCC)H4FnCA>&ojN0l*gB|lLpxjL@$YKvZB#ubXG$`hN9l}?0Wr%@t9q`iLN z_QU%X69FQ$pbF5~;%;KA86syb>V`!1l)e^rZwUvvyBYi?@l3|_>EbVi4gHY#_%S%e zKzU1n!7M+t+eUl_<6da@751tv0Hbbn-E=FSd0Il4?cWr-es%aJZoNPlaOa`n69_-n zZFXQ)WGgpdQ5=F8jB4A5BHT2iisN_93O*CHs*GY@ClKY8-TmUI7?)VOcVgkX!z_F! z$0oTjdx5;+S6)>;)DVbum&QI=W}Nmok6!SBHhAzl$WkjI5sFzQy-42pCiF)`bX^55 z6Ej0VC!+dna!btxjC!B3QR+&9OPqHSL+t)LVJZ_`yNz9VL&}bF=ArTGJSmtK8n29& z&~m2IIXPyZJFfT*|MHQzb1={^RwcUZQ-!GKJ-hKr$8TwWqur@I!kn1{IXEaV94Xx) zpI+!wbq`;YGNWk9iQ;nGG)!LYQ3WTEx zo=}P$Y7)5I%BvB-6-K~?3obPkUAMPMHlh1qHux}dYt}c|JO>$>e%Lx`R~Jb`{G-K0 zn=Uj+Hsr^pYDFB~Eirc~zR(hXmP-oS52!0P?l?HmMKo6$`6-I3zxvm=QNZf+zm`*p zkkP`u*9hy#xPGUMh})rc3p3jBDy2!~aUS8?ul&(Kd?LwfR2FKOxmK}MOd^@Hy?66b zUY_Cm6qtb_{Ujc*KUqG5-+~|xGj=~bL`vSZ`gO0asq~+o+)AVUfW33%UV#0N-yK3P z)moWDQ%XUPE~lTUG2!1lOFOnZiKzaG zDg^^?OiFg5Eptf;dlSSHa;z_XCo|uf)K|UXYh!#eS)`ua{WLO`>1IHe0^u()@Qz>j zR1q%I&8Tj#=4$f?Q@&Tu|LXM~IUvRO%KoI#1CMVKguQ&vz+tcAs2v>f&FUhdY94*j zcu$0KrJNq*%obK>Mx|y#z zO2rFooP^vgn2xTfimFra*xK$IsGXUN~xmT^OO$&h+#4UktVkb|rS6+WTUH-5{61GJkD;zOmYt8fTM8z`3wJP!` zY32dm%S<;|Q8hVGz!TP2*05dLt0xud__}IA<7AAtqZ(b$wbo6JQBnHxsA8o(O3EgY z-Lu^p`=cN^EcIBh;h%|W`=I&st$Ht)O9SgSj?w9$@woz0PM45C;pa1bmv${W`%z2j z*jT4m62mNWF?G*&PjA5V&lr5(Rhqc4AFgPbYlPITW$}AJU(S0UEZQZ4uAEC zy+P;m$6x+NK415Hn_aAb;~nHQZS6tCv*K;xD?^i=Go0#cGK0_p@qn6opw;8wN$$JE z-HdRK*o(OatLi&{_;&?6=UVb4S{}m~W(dEhRs@%YS%>aZO*7ia4lv)JwQjk_(ag*) z$L@EVMzB6;)WqNkdgczUGv)N;Ic-AxEQ?WsGweE!O^f$u>(^dosvXrMlq2;<>dIYn z<5%#1$bLH?smkvfaXIMhSG+IeIqhLT5lFaY-Xd;2aMsbAV~ps=Va`6#Q^`ce^hqo* zx`_WTMd<%p1BGC9RZ8~Kl}cj$k6Ltc@<$3V^Z$F4C{xka0Y24XJOB5N#j{n^zh`tl m{#iga|F@jz|8}6FGNs1(L%AYTZpr<71!7=+@3r2er2haR-Ijv@ literal 0 HcmV?d00001 diff --git a/doc/user/project/clusters/index.md b/doc/user/project/clusters/index.md index 7d9e771f570..27b4b49c207 100644 --- a/doc/user/project/clusters/index.md +++ b/doc/user/project/clusters/index.md @@ -88,3 +88,12 @@ To remove the Cluster integration from your project, simply click on the and [add a cluster](#adding-a-cluster) again. [permissions]: ../../permissions.md + +## Installing applications + +GitLab provides a one-click install for +[Helm Tiller](https://docs.helm.sh/) and +[Ingress](https://kubernetes.io/docs/concepts/services-networking/ingress/) +which will be added directly to your configured cluster. + +![Cluster application settings](img/cluster-applications.png) diff --git a/spec/javascripts/clusters/clusters_bundle_spec.js b/spec/javascripts/clusters/clusters_bundle_spec.js new file mode 100644 index 00000000000..26d230ce414 --- /dev/null +++ b/spec/javascripts/clusters/clusters_bundle_spec.js @@ -0,0 +1,235 @@ +import Clusters from '~/clusters/clusters_bundle'; +import { + APPLICATION_INSTALLABLE, + APPLICATION_INSTALLING, + APPLICATION_INSTALLED, + REQUEST_LOADING, + REQUEST_SUCCESS, + REQUEST_FAILURE, +} from '~/clusters/constants'; +import getSetTimeoutPromise from '../helpers/set_timeout_promise_helper'; + +describe('Clusters', () => { + let cluster; + preloadFixtures('clusters/show_cluster.html.raw'); + + beforeEach(() => { + loadFixtures('clusters/show_cluster.html.raw'); + cluster = new Clusters(); + }); + + afterEach(() => { + cluster.destroy(); + }); + + describe('toggle', () => { + it('should update the button and the input field on click', () => { + cluster.toggleButton.click(); + + expect( + cluster.toggleButton.classList, + ).not.toContain('checked'); + + expect( + cluster.toggleInput.getAttribute('value'), + ).toEqual('false'); + }); + }); + + describe('checkForNewInstalls', () => { + const INITIAL_APP_MAP = { + helm: { status: null, title: 'Helm Tiller' }, + ingress: { status: null, title: 'Ingress' }, + runner: { status: null, title: 'GitLab Runner' }, + }; + + 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_INSTALLABLE, title: 'Helm Tiller' }, + }); + + expect(document.querySelector('.js-cluster-application-notice.hidden')).toBeDefined(); + }); + + it('shows an alert when something gets newly installed', () => { + cluster.checkForNewInstalls({ + ...INITIAL_APP_MAP, + helm: { status: APPLICATION_INSTALLING, title: 'Helm Tiller' }, + }, { + ...INITIAL_APP_MAP, + helm: { status: APPLICATION_INSTALLED, title: 'Helm Tiller' }, + }); + + expect(document.querySelector('.js-cluster-application-notice:not(.hidden)')).toBeDefined(); + expect(document.querySelector('.js-cluster-application-notice').textContent.trim()).toEqual('Helm Tiller was successfully installed on your cluster'); + }); + + it('shows an alert when multiple things gets newly installed', () => { + cluster.checkForNewInstalls({ + ...INITIAL_APP_MAP, + helm: { status: APPLICATION_INSTALLING, title: 'Helm Tiller' }, + ingress: { status: APPLICATION_INSTALLABLE, title: 'Ingress' }, + }, { + ...INITIAL_APP_MAP, + helm: { status: APPLICATION_INSTALLED, title: 'Helm Tiller' }, + ingress: { status: APPLICATION_INSTALLED, title: 'Ingress' }, + }); + + expect(document.querySelector('.js-cluster-application-notice:not(.hidden)')).toBeDefined(); + expect(document.querySelector('.js-cluster-application-notice').textContent.trim()).toEqual('Helm Tiller, Ingress was successfully installed on your cluster'); + }); + + it('hides existing alert when we call again and nothing is newly installed', () => { + const installedState = { + ...INITIAL_APP_MAP, + helm: { status: APPLICATION_INSTALLED, title: 'Helm Tiller' }, + }; + + // Show the banner + cluster.checkForNewInstalls({ + ...INITIAL_APP_MAP, + helm: { status: APPLICATION_INSTALLING, title: 'Helm Tiller' }, + }, installedState); + + expect(document.querySelector('.js-cluster-application-notice:not(.hidden)')).toBeDefined(); + + // Banner should go back hidden + cluster.checkForNewInstalls(installedState, installedState); + + expect(document.querySelector('.js-cluster-application-notice.hidden')).toBeDefined(); + }); + }); + + describe('updateContainer', () => { + describe('when creating cluster', () => { + it('should show the creating container', () => { + cluster.updateContainer('creating'); + + expect( + cluster.creatingContainer.classList.contains('hidden'), + ).toBeFalsy(); + expect( + cluster.successContainer.classList.contains('hidden'), + ).toBeTruthy(); + expect( + cluster.errorContainer.classList.contains('hidden'), + ).toBeTruthy(); + }); + }); + + describe('when cluster is created', () => { + it('should show the success container', () => { + cluster.updateContainer('created'); + + expect( + cluster.creatingContainer.classList.contains('hidden'), + ).toBeTruthy(); + expect( + cluster.successContainer.classList.contains('hidden'), + ).toBeFalsy(); + expect( + cluster.errorContainer.classList.contains('hidden'), + ).toBeTruthy(); + }); + }); + + describe('when cluster has error', () => { + it('should show the error container', () => { + cluster.updateContainer('errored', 'this is an error'); + + expect( + cluster.creatingContainer.classList.contains('hidden'), + ).toBeTruthy(); + expect( + cluster.successContainer.classList.contains('hidden'), + ).toBeTruthy(); + expect( + cluster.errorContainer.classList.contains('hidden'), + ).toBeFalsy(); + + expect( + cluster.errorReasonContainer.textContent, + ).toContain('this is an error'); + }); + }); + }); + + describe('installApplication', () => { + it('tries to install helm', (done) => { + spyOn(cluster.service, 'installApplication').and.returnValue(Promise.resolve()); + expect(cluster.store.state.applications.helm.requestStatus).toEqual(null); + + cluster.installApplication('helm'); + + expect(cluster.store.state.applications.helm.requestStatus).toEqual(REQUEST_LOADING); + expect(cluster.store.state.applications.helm.requestReason).toEqual(null); + expect(cluster.service.installApplication).toHaveBeenCalledWith('helm'); + + getSetTimeoutPromise() + .then(() => { + expect(cluster.store.state.applications.helm.requestStatus).toEqual(REQUEST_SUCCESS); + expect(cluster.store.state.applications.helm.requestReason).toEqual(null); + }) + .then(done) + .catch(done.fail); + }); + + it('tries to install ingress', (done) => { + spyOn(cluster.service, 'installApplication').and.returnValue(Promise.resolve()); + expect(cluster.store.state.applications.ingress.requestStatus).toEqual(null); + + cluster.installApplication('ingress'); + + expect(cluster.store.state.applications.ingress.requestStatus).toEqual(REQUEST_LOADING); + expect(cluster.store.state.applications.ingress.requestReason).toEqual(null); + expect(cluster.service.installApplication).toHaveBeenCalledWith('ingress'); + + getSetTimeoutPromise() + .then(() => { + expect(cluster.store.state.applications.ingress.requestStatus).toEqual(REQUEST_SUCCESS); + expect(cluster.store.state.applications.ingress.requestReason).toEqual(null); + }) + .then(done) + .catch(done.fail); + }); + + it('tries to install runner', (done) => { + spyOn(cluster.service, 'installApplication').and.returnValue(Promise.resolve()); + expect(cluster.store.state.applications.runner.requestStatus).toEqual(null); + + cluster.installApplication('runner'); + + expect(cluster.store.state.applications.runner.requestStatus).toEqual(REQUEST_LOADING); + expect(cluster.store.state.applications.runner.requestReason).toEqual(null); + expect(cluster.service.installApplication).toHaveBeenCalledWith('runner'); + + getSetTimeoutPromise() + .then(() => { + expect(cluster.store.state.applications.runner.requestStatus).toEqual(REQUEST_SUCCESS); + expect(cluster.store.state.applications.runner.requestReason).toEqual(null); + }) + .then(done) + .catch(done.fail); + }); + + it('sets error request status when the request fails', (done) => { + spyOn(cluster.service, 'installApplication').and.returnValue(Promise.reject(new Error('STUBBED ERROR'))); + expect(cluster.store.state.applications.helm.requestStatus).toEqual(null); + + cluster.installApplication('helm'); + + expect(cluster.store.state.applications.helm.requestStatus).toEqual(REQUEST_LOADING); + expect(cluster.store.state.applications.helm.requestReason).toEqual(null); + expect(cluster.service.installApplication).toHaveBeenCalled(); + + getSetTimeoutPromise() + .then(() => { + expect(cluster.store.state.applications.helm.requestStatus).toEqual(REQUEST_FAILURE); + expect(cluster.store.state.applications.helm.requestReason).toBeDefined(); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/clusters/components/application_row_spec.js b/spec/javascripts/clusters/components/application_row_spec.js new file mode 100644 index 00000000000..ba38ed6f180 --- /dev/null +++ b/spec/javascripts/clusters/components/application_row_spec.js @@ -0,0 +1,213 @@ +import Vue from 'vue'; +import eventHub from '~/clusters/event_hub'; +import { + APPLICATION_INSTALLABLE, + APPLICATION_INSTALLING, + APPLICATION_INSTALLED, + APPLICATION_ERROR, + REQUEST_LOADING, + REQUEST_SUCCESS, + REQUEST_FAILURE, +} 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'; + +describe('Application Row', () => { + let vm; + let ApplicationRow; + + beforeEach(() => { + ApplicationRow = Vue.extend(applicationRow); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('Title', () => { + it('shows title', () => { + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + titleLink: null, + }); + const title = vm.$el.querySelector('.js-cluster-application-title'); + + expect(title.tagName).toEqual('SPAN'); + expect(title.textContent.trim()).toEqual(DEFAULT_APPLICATION_STATE.title); + }); + + it('shows title link', () => { + expect(DEFAULT_APPLICATION_STATE.titleLink).toBeDefined(); + + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + }); + const title = vm.$el.querySelector('.js-cluster-application-title'); + + expect(title.tagName).toEqual('A'); + expect(title.textContent.trim()).toEqual(DEFAULT_APPLICATION_STATE.title); + }); + }); + + describe('Install button', () => { + it('has indeterminate state on page load', () => { + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + status: null, + }); + + expect(vm.installButtonLabel).toBeUndefined(); + }); + + it('has enabled "Install" when `status=installable`', () => { + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + status: APPLICATION_INSTALLABLE, + }); + + expect(vm.installButtonLabel).toEqual('Install'); + expect(vm.installButtonLoading).toEqual(false); + expect(vm.installButtonDisabled).toEqual(false); + }); + + it('has loading "Installing" when `status=installing`', () => { + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + status: APPLICATION_INSTALLING, + }); + + expect(vm.installButtonLabel).toEqual('Installing'); + expect(vm.installButtonLoading).toEqual(true); + expect(vm.installButtonDisabled).toEqual(true); + }); + + it('has disabled "Installed" when `status=installed`', () => { + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + status: APPLICATION_INSTALLED, + }); + + expect(vm.installButtonLabel).toEqual('Installed'); + expect(vm.installButtonLoading).toEqual(false); + expect(vm.installButtonDisabled).toEqual(true); + }); + + it('has disabled "Install" when `status=error`', () => { + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + status: APPLICATION_ERROR, + }); + + expect(vm.installButtonLabel).toEqual('Install'); + expect(vm.installButtonLoading).toEqual(false); + expect(vm.installButtonDisabled).toEqual(true); + }); + + it('has loading "Install" when `requestStatus=loading`', () => { + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + status: APPLICATION_INSTALLABLE, + requestStatus: REQUEST_LOADING, + }); + + expect(vm.installButtonLabel).toEqual('Install'); + expect(vm.installButtonLoading).toEqual(true); + expect(vm.installButtonDisabled).toEqual(true); + }); + + it('has disabled "Install" when `requestStatus=success`', () => { + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + status: APPLICATION_INSTALLABLE, + requestStatus: REQUEST_SUCCESS, + }); + + expect(vm.installButtonLabel).toEqual('Install'); + expect(vm.installButtonLoading).toEqual(false); + expect(vm.installButtonDisabled).toEqual(true); + }); + + it('has enabled "Install" when `requestStatus=error` (so you can try installing again)', () => { + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + status: APPLICATION_INSTALLABLE, + requestStatus: REQUEST_FAILURE, + }); + + expect(vm.installButtonLabel).toEqual('Install'); + expect(vm.installButtonLoading).toEqual(false); + expect(vm.installButtonDisabled).toEqual(false); + }); + + it('clicking install button emits event', () => { + spyOn(eventHub, '$emit'); + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + status: APPLICATION_INSTALLABLE, + }); + const installButton = vm.$el.querySelector('.js-cluster-application-install-button'); + + installButton.click(); + + expect(eventHub.$emit).toHaveBeenCalledWith('installApplication', DEFAULT_APPLICATION_STATE.id); + }); + + it('clicking disabled install button emits nothing', () => { + spyOn(eventHub, '$emit'); + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + status: APPLICATION_INSTALLING, + }); + const installButton = vm.$el.querySelector('.js-cluster-application-install-button'); + + expect(vm.installButtonDisabled).toEqual(true); + + installButton.click(); + + expect(eventHub.$emit).not.toHaveBeenCalled(); + }); + }); + + describe('Error block', () => { + it('does not show error block when there is no error', () => { + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + status: null, + requestStatus: null, + }); + const generalErrorMessage = vm.$el.querySelector('.js-cluster-application-general-error-message'); + + expect(generalErrorMessage).toBeNull(); + }); + + it('shows status reason when `status=error`', () => { + const statusReason = 'We broke it 0.0'; + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + status: APPLICATION_ERROR, + statusReason, + }); + const generalErrorMessage = vm.$el.querySelector('.js-cluster-application-general-error-message'); + const statusErrorMessage = vm.$el.querySelector('.js-cluster-application-status-error-message'); + + expect(generalErrorMessage.textContent.trim()).toEqual(`Something went wrong while installing ${DEFAULT_APPLICATION_STATE.title}`); + expect(statusErrorMessage.textContent.trim()).toEqual(statusReason); + }); + + it('shows request reason when `requestStatus=error`', () => { + const requestReason = 'We broke thre request 0.0'; + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + status: APPLICATION_INSTALLABLE, + requestStatus: REQUEST_FAILURE, + requestReason, + }); + const generalErrorMessage = vm.$el.querySelector('.js-cluster-application-general-error-message'); + const requestErrorMessage = vm.$el.querySelector('.js-cluster-application-request-error-message'); + + expect(generalErrorMessage.textContent.trim()).toEqual(`Something went wrong while installing ${DEFAULT_APPLICATION_STATE.title}`); + expect(requestErrorMessage.textContent.trim()).toEqual(requestReason); + }); + }); +}); diff --git a/spec/javascripts/clusters/components/applications_spec.js b/spec/javascripts/clusters/components/applications_spec.js new file mode 100644 index 00000000000..5f59a00dc65 --- /dev/null +++ b/spec/javascripts/clusters/components/applications_spec.js @@ -0,0 +1,42 @@ +import Vue from 'vue'; +import applications from '~/clusters/components/applications.vue'; +import mountComponent from '../../helpers/vue_mount_component_helper'; + +describe('Applications', () => { + let vm; + let Applications; + + beforeEach(() => { + Applications = Vue.extend(applications); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('', () => { + beforeEach(() => { + vm = mountComponent(Applications, { + applications: { + helm: { title: 'Helm Tiller' }, + ingress: { title: 'Ingress' }, + runner: { title: 'GitLab Runner' }, + }, + }); + }); + + it('renders a row for Helm Tiller', () => { + expect(vm.$el.querySelector('.js-cluster-application-row-helm')).toBeDefined(); + }); + + /* * / + it('renders a row for Ingress', () => { + expect(vm.$el.querySelector('.js-cluster-application-row-ingress')).toBeDefined(); + }); + + it('renders a row for GitLab Runner', () => { + expect(vm.$el.querySelector('.js-cluster-application-row-runner')).toBeDefined(); + }); + /* */ + }); +}); diff --git a/spec/javascripts/clusters/services/mock_data.js b/spec/javascripts/clusters/services/mock_data.js new file mode 100644 index 00000000000..af6b6a73819 --- /dev/null +++ b/spec/javascripts/clusters/services/mock_data.js @@ -0,0 +1,50 @@ +import { + APPLICATION_INSTALLABLE, + APPLICATION_INSTALLING, + APPLICATION_ERROR, +} from '~/clusters/constants'; + +const CLUSTERS_MOCK_DATA = { + GET: { + '/gitlab-org/gitlab-shell/clusters/1/status.json': { + data: { + status: 'errored', + status_reason: 'Failed to request to CloudPlatform.', + applications: [{ + name: 'helm', + status: APPLICATION_INSTALLABLE, + status_reason: null, + }, { + name: 'ingress', + status: APPLICATION_ERROR, + status_reason: 'Cannot connect', + }, { + name: 'runner', + status: APPLICATION_INSTALLING, + status_reason: null, + }], + }, + }, + }, + POST: { + '/gitlab-org/gitlab-shell/clusters/1/applications/helm': { }, + '/gitlab-org/gitlab-shell/clusters/1/applications/ingress': { }, + '/gitlab-org/gitlab-shell/clusters/1/applications/runner': { }, + }, +}; + +const DEFAULT_APPLICATION_STATE = { + id: 'some-app', + title: 'My App', + titleLink: 'https://about.gitlab.com/', + description: 'Some description about this interesting application!', + status: null, + statusReason: null, + requestStatus: null, + requestReason: null, +}; + +export { + CLUSTERS_MOCK_DATA, + DEFAULT_APPLICATION_STATE, +}; diff --git a/spec/javascripts/clusters/stores/clusters_store_spec.js b/spec/javascripts/clusters/stores/clusters_store_spec.js new file mode 100644 index 00000000000..9f9d63434f7 --- /dev/null +++ b/spec/javascripts/clusters/stores/clusters_store_spec.js @@ -0,0 +1,86 @@ +import ClustersStore from '~/clusters/stores/clusters_store'; +import { APPLICATION_INSTALLING } from '~/clusters/constants'; +import { CLUSTERS_MOCK_DATA } from '../services/mock_data'; + +describe('Clusters Store', () => { + let store; + + beforeEach(() => { + store = new ClustersStore(); + }); + + describe('updateStatus', () => { + it('should store new status', () => { + expect(store.state.status).toEqual(null); + + const newStatus = 'errored'; + store.updateStatus(newStatus); + + expect(store.state.status).toEqual(newStatus); + }); + }); + + describe('updateStatusReason', () => { + it('should store new reason', () => { + expect(store.state.statusReason).toEqual(null); + + const newReason = 'Something went wrong!'; + store.updateStatusReason(newReason); + + expect(store.state.statusReason).toEqual(newReason); + }); + }); + + describe('updateAppProperty', () => { + it('should store new request status', () => { + expect(store.state.applications.helm.requestStatus).toEqual(null); + + const newStatus = APPLICATION_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); + + const newReason = 'We broke it.'; + store.updateAppProperty('helm', 'requestReason', newReason); + + expect(store.state.applications.helm.requestReason).toEqual(newReason); + }); + }); + + describe('updateStateFromServer', () => { + it('should store new polling data from server', () => { + const mockResponseData = CLUSTERS_MOCK_DATA.GET['/gitlab-org/gitlab-shell/clusters/1/status.json'].data; + store.updateStateFromServer(mockResponseData); + + expect(store.state).toEqual({ + helpPath: null, + status: mockResponseData.status, + statusReason: mockResponseData.status_reason, + applications: { + helm: { + status: mockResponseData.applications[0].status, + statusReason: mockResponseData.applications[0].status_reason, + requestStatus: null, + requestReason: null, + }, + ingress: { + status: mockResponseData.applications[1].status, + statusReason: mockResponseData.applications[1].status_reason, + requestStatus: null, + requestReason: null, + }, + runner: { + status: mockResponseData.applications[2].status, + statusReason: mockResponseData.applications[2].status_reason, + requestStatus: null, + requestReason: null, + }, + }, + }); + }); + }); +}); diff --git a/spec/javascripts/clusters_spec.js b/spec/javascripts/clusters_spec.js deleted file mode 100644 index eb1cd6eb804..00000000000 --- a/spec/javascripts/clusters_spec.js +++ /dev/null @@ -1,79 +0,0 @@ -import Clusters from '~/clusters'; - -describe('Clusters', () => { - let cluster; - preloadFixtures('clusters/show_cluster.html.raw'); - - beforeEach(() => { - loadFixtures('clusters/show_cluster.html.raw'); - cluster = new Clusters(); - }); - - describe('toggle', () => { - it('should update the button and the input field on click', () => { - cluster.toggleButton.click(); - - expect( - cluster.toggleButton.classList, - ).not.toContain('checked'); - - expect( - cluster.toggleInput.getAttribute('value'), - ).toEqual('false'); - }); - }); - - describe('updateContainer', () => { - describe('when creating cluster', () => { - it('should show the creating container', () => { - cluster.updateContainer('creating'); - - expect( - cluster.creatingContainer.classList.contains('hidden'), - ).toBeFalsy(); - expect( - cluster.successContainer.classList.contains('hidden'), - ).toBeTruthy(); - expect( - cluster.errorContainer.classList.contains('hidden'), - ).toBeTruthy(); - }); - }); - - describe('when cluster is created', () => { - it('should show the success container', () => { - cluster.updateContainer('created'); - - expect( - cluster.creatingContainer.classList.contains('hidden'), - ).toBeTruthy(); - expect( - cluster.successContainer.classList.contains('hidden'), - ).toBeFalsy(); - expect( - cluster.errorContainer.classList.contains('hidden'), - ).toBeTruthy(); - }); - }); - - describe('when cluster has error', () => { - it('should show the error container', () => { - cluster.updateContainer('errored', 'this is an error'); - - expect( - cluster.creatingContainer.classList.contains('hidden'), - ).toBeTruthy(); - expect( - cluster.successContainer.classList.contains('hidden'), - ).toBeTruthy(); - expect( - cluster.errorContainer.classList.contains('hidden'), - ).toBeFalsy(); - - expect( - cluster.errorReasonContainer.textContent, - ).toContain('this is an error'); - }); - }); - }); -}); From 80b0834ae96480202678d8ca1e19c0ee4abf9001 Mon Sep 17 00:00:00 2001 From: Alessio Caiazza Date: Mon, 6 Nov 2017 10:23:15 +0100 Subject: [PATCH 02/17] Add Clusters::Appplications::CheckInstallationProgressService tests --- .../check_installation_progress_service.rb | 4 +- spec/factories/clusters/applications/helm.rb | 5 ++ ...heck_installation_progress_service_spec.rb | 75 +++++++++++++++++++ 3 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 spec/services/clusters/applications/check_installation_progress_service_spec.rb diff --git a/app/services/clusters/applications/check_installation_progress_service.rb b/app/services/clusters/applications/check_installation_progress_service.rb index cf96c128c2e..81306a2ff5b 100644 --- a/app/services/clusters/applications/check_installation_progress_service.rb +++ b/app/services/clusters/applications/check_installation_progress_service.rb @@ -27,13 +27,13 @@ module Clusters end def on_failed - app.make_errored!(log || 'Installation silently failed') + app.make_errored!(installation_errors || 'Installation silently failed') finalize_installation end def check_timeout if Time.now.utc - app.updated_at.to_time.utc > ClusterWaitForAppInstallationWorker::TIMEOUT - app.make_errored!('App installation timeouted') + app.make_errored!('Installation timeouted') else ClusterWaitForAppInstallationWorker.perform_in( ClusterWaitForAppInstallationWorker::INTERVAL, app.name, app.id) diff --git a/spec/factories/clusters/applications/helm.rb b/spec/factories/clusters/applications/helm.rb index 968a6a1a007..fd956097115 100644 --- a/spec/factories/clusters/applications/helm.rb +++ b/spec/factories/clusters/applications/helm.rb @@ -31,5 +31,10 @@ FactoryGirl.define do status(-1) status_reason 'something went wrong' end + + trait :timeouted do + installing + updated_at ClusterWaitForAppInstallationWorker::TIMEOUT.ago + end end end diff --git a/spec/services/clusters/applications/check_installation_progress_service_spec.rb b/spec/services/clusters/applications/check_installation_progress_service_spec.rb new file mode 100644 index 00000000000..c64c3a0c94c --- /dev/null +++ b/spec/services/clusters/applications/check_installation_progress_service_spec.rb @@ -0,0 +1,75 @@ +require 'spec_helper' + +describe Clusters::Applications::CheckInstallationProgressService do + RESCHEDULE_PHASES = Gitlab::Kubernetes::Pod::PHASES - [Gitlab::Kubernetes::Pod::SUCCEEDED, Gitlab::Kubernetes::Pod::FAILED].freeze + + def mock_helm_api(phase, errors: nil) + expect(service).to receive(:installation_phase).once.and_return(phase) + expect(service).to receive(:installation_errors).once.and_return(errors) if errors.present? + end + + shared_examples 'not yet completed phase' do |phase| + context "when the installation POD phase is #{phase}" do + before do + mock_helm_api(phase) + end + + context 'when not timeouted' do + it 'reschedule a new check' do + expect(ClusterWaitForAppInstallationWorker).to receive(:perform_in).once + + service.execute + + expect(application).to be_installing + expect(application.status_reason).to be_nil + end + end + + context 'when timeouted' do + let(:application) { create(:applications_helm, :timeouted) } + + it 'make the application errored' do + expect(ClusterWaitForAppInstallationWorker).not_to receive(:perform_in) + + service.execute + + expect(application).to be_errored + expect(application.status_reason).to match(/\btimeouted\b/) + end + end + end + end + + describe '#execute' do + let(:application) { create(:applications_helm, :installing) } + let(:service) { described_class.new(application) } + + context 'when installation POD succeeded' do + it 'make the application installed' do + mock_helm_api(Gitlab::Kubernetes::Pod::SUCCEEDED) + expect(service).to receive(:finalize_installation).once + + service.execute + + expect(application).to be_installed + expect(application.status_reason).to be_nil + end + end + + context 'when installation POD failed' do + let(:error_message) { 'test installation failed' } + + it 'make the application errored' do + mock_helm_api(Gitlab::Kubernetes::Pod::FAILED, errors: error_message) + expect(service).to receive(:finalize_installation).once + + service.execute + + expect(application).to be_errored + expect(application.status_reason).to eq(error_message) + end + end + + RESCHEDULE_PHASES.each { |phase| it_behaves_like 'not yet completed phase', phase } + end +end From fe0292cfa7266ad5f936e103cdf005a00da4c233 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Mon, 6 Nov 2017 10:50:10 +0100 Subject: [PATCH 03/17] Remove not used ingress and runner --- app/assets/javascripts/clusters/clusters_bundle.js | 4 ---- app/assets/javascripts/clusters/services/clusters_service.js | 2 -- app/views/projects/clusters/show.html.haml | 4 +--- 3 files changed, 1 insertion(+), 9 deletions(-) diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index 053f11cc3c4..9a436b3e22d 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -28,8 +28,6 @@ export default class Clusters { const { statusPath, installHelmPath, - installIngressPath, - installRunnerPath, clusterStatus, clusterStatusReason, helpPath, @@ -42,8 +40,6 @@ export default class Clusters { this.service = new ClustersService({ endpoint: statusPath, installHelmEndpoint: installHelmPath, - installIngresEndpoint: installIngressPath, - installRunnerEndpoint: installRunnerPath, }); this.toggle = this.toggle.bind(this); diff --git a/app/assets/javascripts/clusters/services/clusters_service.js b/app/assets/javascripts/clusters/services/clusters_service.js index 0ac8e68187d..a7bb0961c7e 100644 --- a/app/assets/javascripts/clusters/services/clusters_service.js +++ b/app/assets/javascripts/clusters/services/clusters_service.js @@ -8,8 +8,6 @@ export default class ClusterService { this.options = options; this.appInstallEndpointMap = { helm: this.options.installHelmEndpoint, - ingress: this.options.installIngressEndpoint, - runner: this.options.installRunnerEndpoint, }; } diff --git a/app/views/projects/clusters/show.html.haml b/app/views/projects/clusters/show.html.haml index 799b07046e5..f116c4f7dba 100644 --- a/app/views/projects/clusters/show.html.haml +++ b/app/views/projects/clusters/show.html.haml @@ -6,9 +6,7 @@ - status_path = status_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster.id, format: :json) if can?(current_user, :admin_cluster, @cluster) .edit-cluster-form.js-edit-cluster-form{ data: { status_path: status_path, - install_helm_path: 'TODO', - install_ingress_path: 'TODO', - install_runner_path: 'TODO', + install_helm_path: install_applications_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster, :helm), toggle_status: @cluster.enabled? ? 'true': 'false', cluster_status: @cluster.status_name, cluster_status_reason: @cluster.status_reason, From 001de85e7c6f86423aca0d245fdc83c57b374630 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Mon, 6 Nov 2017 11:03:58 +0100 Subject: [PATCH 04/17] Return empty applications if not Kubernetes [ci skip] --- app/models/clusters/cluster.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index 7d0be3d3739..5b9bd6e548b 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -58,6 +58,8 @@ module Clusters end def applications + return [] unless kubernetes? + [ application_helm || build_application_helm ] From 61501a07cb9c1fff5a30662b3e3815976f2777cb Mon Sep 17 00:00:00 2001 From: Alessio Caiazza Date: Mon, 6 Nov 2017 15:43:02 +0100 Subject: [PATCH 05/17] Add Clusters::Applications services tests --- .../clusters/concerns/application_status.rb | 2 +- .../check_installation_progress_service.rb | 11 +--- .../finalize_installation_service.rb | 8 +-- .../clusters/applications/install_service.rb | 9 +-- .../schedule_installation_service.rb | 13 ++--- ...heck_installation_progress_service_spec.rb | 57 +++++++++++-------- .../finalize_installation_service_spec.rb | 32 +++++++++++ .../applications/install_service_spec.rb | 54 ++++++++++++++++++ .../schedule_installation_service_spec.rb | 55 ++++++++++++++++++ 9 files changed, 184 insertions(+), 57 deletions(-) create mode 100644 spec/services/clusters/applications/finalize_installation_service_spec.rb create mode 100644 spec/services/clusters/applications/install_service_spec.rb create mode 100644 spec/services/clusters/applications/schedule_installation_service_spec.rb diff --git a/app/models/clusters/concerns/application_status.rb b/app/models/clusters/concerns/application_status.rb index 7bb68d75224..c5711fd0b58 100644 --- a/app/models/clusters/concerns/application_status.rb +++ b/app/models/clusters/concerns/application_status.rb @@ -24,7 +24,7 @@ module Clusters end event :make_scheduled do - transition any - [:scheduled] => :scheduled + transition %i(installable errored) => :scheduled end before_transition any => [:scheduled] do |app_status, _| diff --git a/app/services/clusters/applications/check_installation_progress_service.rb b/app/services/clusters/applications/check_installation_progress_service.rb index 81306a2ff5b..1bd5dae0584 100644 --- a/app/services/clusters/applications/check_installation_progress_service.rb +++ b/app/services/clusters/applications/check_installation_progress_service.rb @@ -6,7 +6,7 @@ module Clusters case installation_phase when Gitlab::Kubernetes::Pod::SUCCEEDED - on_succeeded + finalize_installation when Gitlab::Kubernetes::Pod::FAILED on_failed else @@ -18,14 +18,6 @@ module Clusters private - def on_succeeded - if app.make_installed - finalize_installation - else - app.make_errored!("Failed to update app record; #{app.errors}") - end - end - def on_failed app.make_errored!(installation_errors || 'Installation silently failed') finalize_installation @@ -34,6 +26,7 @@ module Clusters def check_timeout if Time.now.utc - app.updated_at.to_time.utc > ClusterWaitForAppInstallationWorker::TIMEOUT app.make_errored!('Installation timeouted') + finalize_installation else ClusterWaitForAppInstallationWorker.perform_in( ClusterWaitForAppInstallationWorker::INTERVAL, app.name, app.id) diff --git a/app/services/clusters/applications/finalize_installation_service.rb b/app/services/clusters/applications/finalize_installation_service.rb index 339d671c091..292c789b67b 100644 --- a/app/services/clusters/applications/finalize_installation_service.rb +++ b/app/services/clusters/applications/finalize_installation_service.rb @@ -4,13 +4,7 @@ module Clusters def execute helm_api.delete_installation_pod!(app) - app.make_errored!('Installation aborted') if aborted? - end - - private - - def aborted? - app.installing? || app.scheduled? + app.make_installed! if app.installing? end end end diff --git a/app/services/clusters/applications/install_service.rb b/app/services/clusters/applications/install_service.rb index 5ed0968a98a..4eba19a474e 100644 --- a/app/services/clusters/applications/install_service.rb +++ b/app/services/clusters/applications/install_service.rb @@ -5,14 +5,11 @@ module Clusters return unless app.scheduled? begin + app.make_installing! helm_api.install(app) - if app.make_installing - ClusterWaitForAppInstallationWorker.perform_in( - ClusterWaitForAppInstallationWorker::INTERVAL, app.name, app.id) - else - app.make_errored!("Failed to update app record; #{app.errors}") - end + ClusterWaitForAppInstallationWorker.perform_in( + ClusterWaitForAppInstallationWorker::INTERVAL, app.name, app.id) rescue KubeException => ke app.make_errored!("Kubernetes error: #{ke.message}") rescue StandardError diff --git a/app/services/clusters/applications/schedule_installation_service.rb b/app/services/clusters/applications/schedule_installation_service.rb index 17b3a09948d..eb8caa68ef7 100644 --- a/app/services/clusters/applications/schedule_installation_service.rb +++ b/app/services/clusters/applications/schedule_installation_service.rb @@ -2,15 +2,10 @@ module Clusters module Applications class ScheduleInstallationService < ::BaseService def execute - application = application_class.find_or_create_by!(cluster: cluster) - - application.make_scheduled! - ClusterInstallAppWorker.perform_async(application.name, application.id) - true - rescue ActiveRecord::RecordInvalid - false - rescue StateMachines::InvalidTransition - false + application_class.find_or_create_by!(cluster: cluster).try do |application| + application.make_scheduled! + ClusterInstallAppWorker.perform_async(application.name, application.id) + end end private diff --git a/spec/services/clusters/applications/check_installation_progress_service_spec.rb b/spec/services/clusters/applications/check_installation_progress_service_spec.rb index c64c3a0c94c..fe04fac9613 100644 --- a/spec/services/clusters/applications/check_installation_progress_service_spec.rb +++ b/spec/services/clusters/applications/check_installation_progress_service_spec.rb @@ -3,20 +3,27 @@ require 'spec_helper' describe Clusters::Applications::CheckInstallationProgressService do RESCHEDULE_PHASES = Gitlab::Kubernetes::Pod::PHASES - [Gitlab::Kubernetes::Pod::SUCCEEDED, Gitlab::Kubernetes::Pod::FAILED].freeze - def mock_helm_api(phase, errors: nil) - expect(service).to receive(:installation_phase).once.and_return(phase) - expect(service).to receive(:installation_errors).once.and_return(errors) if errors.present? + let(:application) { create(:applications_helm, :installing) } + let(:service) { described_class.new(application) } + let(:phase) { Gitlab::Kubernetes::Pod::UNKNOWN } + let(:errors) { nil } + + shared_examples 'a terminated installation' do + it 'finalize the installation' do + expect(service).to receive(:finalize_installation).once + + service.execute + end end - shared_examples 'not yet completed phase' do |phase| - context "when the installation POD phase is #{phase}" do - before do - mock_helm_api(phase) - end + shared_examples 'a not yet terminated installation' do |a_phase| + let(:phase) { a_phase } + context "when phase is #{a_phase}" do context 'when not timeouted' do it 'reschedule a new check' do expect(ClusterWaitForAppInstallationWorker).to receive(:perform_in).once + expect(service).not_to receive(:finalize_installation) service.execute @@ -28,6 +35,8 @@ describe Clusters::Applications::CheckInstallationProgressService do context 'when timeouted' do let(:application) { create(:applications_helm, :timeouted) } + it_behaves_like 'a terminated installation' + it 'make the application errored' do expect(ClusterWaitForAppInstallationWorker).not_to receive(:perform_in) @@ -40,36 +49,34 @@ describe Clusters::Applications::CheckInstallationProgressService do end end + before do + expect(service).to receive(:installation_phase).once.and_return(phase) + + allow(service).to receive(:installation_errors).and_return(errors) + allow(service).to receive(:finalize_installation).and_return(nil) + end + describe '#execute' do - let(:application) { create(:applications_helm, :installing) } - let(:service) { described_class.new(application) } - context 'when installation POD succeeded' do - it 'make the application installed' do - mock_helm_api(Gitlab::Kubernetes::Pod::SUCCEEDED) - expect(service).to receive(:finalize_installation).once + let(:phase) { Gitlab::Kubernetes::Pod::SUCCEEDED } - service.execute - - expect(application).to be_installed - expect(application.status_reason).to be_nil - end + it_behaves_like 'a terminated installation' end context 'when installation POD failed' do - let(:error_message) { 'test installation failed' } + let(:phase) { Gitlab::Kubernetes::Pod::FAILED } + let(:errors) { 'test installation failed' } + + it_behaves_like 'a terminated installation' it 'make the application errored' do - mock_helm_api(Gitlab::Kubernetes::Pod::FAILED, errors: error_message) - expect(service).to receive(:finalize_installation).once - service.execute expect(application).to be_errored - expect(application.status_reason).to eq(error_message) + expect(application.status_reason).to eq(errors) end end - RESCHEDULE_PHASES.each { |phase| it_behaves_like 'not yet completed phase', phase } + RESCHEDULE_PHASES.each { |phase| it_behaves_like 'a not yet terminated installation', phase } end end diff --git a/spec/services/clusters/applications/finalize_installation_service_spec.rb b/spec/services/clusters/applications/finalize_installation_service_spec.rb new file mode 100644 index 00000000000..08b7a80dfcd --- /dev/null +++ b/spec/services/clusters/applications/finalize_installation_service_spec.rb @@ -0,0 +1,32 @@ +require 'spec_helper' + +describe Clusters::Applications::FinalizeInstallationService do + describe '#execute' do + let(:application) { create(:applications_helm, :installing) } + let(:service) { described_class.new(application) } + + before do + expect_any_instance_of(Gitlab::Kubernetes::Helm).to receive(:delete_installation_pod!).with(application) + end + + context 'when installation POD succeeded' do + it 'make the application installed' do + service.execute + + expect(application).to be_installed + expect(application.status_reason).to be_nil + end + end + + context 'when installation POD failed' do + let(:application) { create(:applications_helm, :errored) } + + it 'make the application errored' do + service.execute + + expect(application).to be_errored + expect(application.status_reason).not_to be_nil + end + end + end +end diff --git a/spec/services/clusters/applications/install_service_spec.rb b/spec/services/clusters/applications/install_service_spec.rb new file mode 100644 index 00000000000..a646dac1cae --- /dev/null +++ b/spec/services/clusters/applications/install_service_spec.rb @@ -0,0 +1,54 @@ +require 'spec_helper' + +describe Clusters::Applications::InstallService do + describe '#execute' do + let(:application) { create(:applications_helm, :scheduled) } + let(:service) { described_class.new(application) } + + context 'when there are no errors' do + before do + expect_any_instance_of(Gitlab::Kubernetes::Helm).to receive(:install).with(application) + allow(ClusterWaitForAppInstallationWorker).to receive(:perform_in).and_return(nil) + end + + it 'make the application installing' do + service.execute + + expect(application).to be_installing + end + + it 'schedule async installation status check' do + expect(ClusterWaitForAppInstallationWorker).to receive(:perform_in).once + + service.execute + end + end + + context 'when k8s cluster communication fails' do + before do + error = KubeException.new(500, 'system failure', nil) + expect_any_instance_of(Gitlab::Kubernetes::Helm).to receive(:install).with(application).and_raise(error) + end + + it 'make the application errored' do + service.execute + + expect(application).to be_errored + expect(application.status_reason).to match(/kubernetes error:/i) + end + end + + context 'when application cannot be persisted' do + let(:application) { build(:applications_helm, :scheduled) } + + it 'make the application errored' do + expect(application).to receive(:make_installing!).once.and_raise(ActiveRecord::RecordInvalid) + expect_any_instance_of(Gitlab::Kubernetes::Helm).not_to receive(:install) + + service.execute + + expect(application).to be_errored + end + end + end +end diff --git a/spec/services/clusters/applications/schedule_installation_service_spec.rb b/spec/services/clusters/applications/schedule_installation_service_spec.rb new file mode 100644 index 00000000000..6ba587a41db --- /dev/null +++ b/spec/services/clusters/applications/schedule_installation_service_spec.rb @@ -0,0 +1,55 @@ +require 'spec_helper' + +describe Clusters::Applications::ScheduleInstallationService do + def count_scheduled + application_class&.with_status(:scheduled)&.count || 0 + end + + shared_examples 'a failing service' do + it 'raise an exception' do + expect(ClusterInstallAppWorker).not_to receive(:perform_async) + count_before = count_scheduled + + expect { service.execute }.to raise_error(StandardError) + expect(count_scheduled).to eq(count_before) + end + end + + describe '#execute' do + let(:application_class) { Clusters::Applications::Helm } + let(:cluster) { create(:cluster, :project, :provided_by_gcp) } + let(:project) { cluster.project } + let(:service) { described_class.new(project, nil, cluster: cluster, application_class: application_class) } + + it 'creates a new application' do + expect { service.execute }.to change { application_class.count }.by(1) + end + + it 'make the application scheduled' do + expect(ClusterInstallAppWorker).to receive(:perform_async).with(application_class.application_name, kind_of(Numeric)).once + + expect { service.execute }.to change { application_class.with_status(:scheduled).count }.by(1) + end + + context 'when installation is already in progress' do + let(:application) { create(:applications_helm, :installing) } + let(:cluster) { application.cluster } + + it_behaves_like 'a failing service' + end + + context 'when application_class is nil' do + let(:application_class) { nil } + + it_behaves_like 'a failing service' + end + + context 'when application cannot be persisted' do + before do + expect_any_instance_of(application_class).to receive(:make_scheduled!).once.and_raise(ActiveRecord::RecordInvalid) + end + + it_behaves_like 'a failing service' + end + end +end From f3a3566edc8fe24337b9df163f4699785061bb38 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Mon, 6 Nov 2017 15:48:44 +0100 Subject: [PATCH 06/17] Add support for not_installable/scheduled and to not show created banner --- app/assets/javascripts/clusters/clusters_bundle.js | 5 ++++- .../javascripts/clusters/components/application_row.vue | 7 +++++-- app/assets/javascripts/clusters/constants.js | 2 ++ app/models/clusters/applications/helm.rb | 6 ++++++ app/models/clusters/cluster.rb | 5 +++-- app/models/clusters/concerns/application_status.rb | 1 + 6 files changed, 21 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index 9a436b3e22d..4d4c90460d2 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -132,9 +132,12 @@ export default class Clusters { handleSuccess(data) { const prevApplicationMap = Object.assign({}, this.store.state.applications); + const prevStatus = this.store.state.status; this.store.updateStateFromServer(data.data); this.checkForNewInstalls(prevApplicationMap, this.store.state.applications); - this.updateContainer(this.store.state.status, this.store.state.statusReason); + if (prevStatus.length == 0 || prevStatus !== this.store.state.status) { + this.updateContainer(this.store.state.status, this.store.state.statusReason); + } } toggle() { diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue index f8d53fcc4b7..9c5ff39534f 100644 --- a/app/assets/javascripts/clusters/components/application_row.vue +++ b/app/assets/javascripts/clusters/components/application_row.vue @@ -3,6 +3,8 @@ import { s__ } from '../../locale'; import eventHub from '../event_hub'; import loadingButton from '../../vue_shared/components/loading_button.vue'; import { + APPLICATION_NOT_INSTALLABLE, + APPLICATION_SCHEDULED, APPLICATION_INSTALLABLE, APPLICATION_INSTALLING, APPLICATION_INSTALLED, @@ -59,6 +61,7 @@ export default { }, installButtonLoading() { return !this.status || + this.status === APPLICATION_SCHEDULED || this.status === APPLICATION_INSTALLING || this.requestStatus === REQUEST_LOADING; }, @@ -72,9 +75,9 @@ export default { }, installButtonLabel() { let label; - if (this.status === APPLICATION_INSTALLABLE || this.status === APPLICATION_ERROR) { + if (this.status === APPLICATION_INSTALLABLE || this.status === APPLICATION_ERROR || this.status === APPLICATION_NOT_INSTALLABLE) { label = s__('ClusterIntegration|Install'); - } else if (this.status === APPLICATION_INSTALLING) { + } else if (this.status === APPLICATION_SCHEDULED || this.status === APPLICATION_INSTALLING) { label = s__('ClusterIntegration|Installing'); } else if (this.status === APPLICATION_INSTALLED) { label = s__('ClusterIntegration|Installed'); diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js index 3f202435716..f1894b173b9 100644 --- a/app/assets/javascripts/clusters/constants.js +++ b/app/assets/javascripts/clusters/constants.js @@ -1,5 +1,7 @@ // These need to match what is returned from the server +export const APPLICATION_NOT_INSTALLABLE = 'not_installable'; export const APPLICATION_INSTALLABLE = 'installable'; +export const APPLICATION_SCHEDULED = 'scheduled'; export const APPLICATION_INSTALLING = 'installing'; export const APPLICATION_INSTALLED = 'installed'; export const APPLICATION_ERROR = 'error'; diff --git a/app/models/clusters/applications/helm.rb b/app/models/clusters/applications/helm.rb index 42626a50175..9bc5c026645 100644 --- a/app/models/clusters/applications/helm.rb +++ b/app/models/clusters/applications/helm.rb @@ -11,10 +11,16 @@ module Clusters validates :cluster, presence: true + after_initialize :set_initial_status + def self.application_name self.to_s.demodulize.underscore end + def set_initial_status + self.status = 0 unless cluster.platform_kubernetes_active? + end + def name self.class.application_name end diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index 5b9bd6e548b..68759ebb6df 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -37,6 +37,9 @@ module Clusters delegate :on_creation?, to: :provider, allow_nil: true delegate :update_kubernetes_integration!, to: :platform, allow_nil: true + delegate :active?, to: :platform_kubernetes, prefix: true, allow_nil: true + delegate :installed?, to: :application_helm, prefix: true, allow_nil: true + enum platform_type: { kubernetes: 1 } @@ -58,8 +61,6 @@ module Clusters end def applications - return [] unless kubernetes? - [ application_helm || build_application_helm ] diff --git a/app/models/clusters/concerns/application_status.rb b/app/models/clusters/concerns/application_status.rb index 7bb68d75224..7592dd55689 100644 --- a/app/models/clusters/concerns/application_status.rb +++ b/app/models/clusters/concerns/application_status.rb @@ -5,6 +5,7 @@ module Clusters included do state_machine :status, initial: :installable do + state :not_installable, value: -2 state :errored, value: -1 state :installable, value: 0 state :scheduled, value: 1 From 895b6e5d80397fdd6cb5e1727a410a08f8a5b332 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Mon, 6 Nov 2017 15:50:55 +0100 Subject: [PATCH 07/17] Add active? to Platforms::Kubernetes --- app/models/clusters/platforms/kubernetes.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb index 74f7c9442db..6dc1ee810d3 100644 --- a/app/models/clusters/platforms/kubernetes.rb +++ b/app/models/clusters/platforms/kubernetes.rb @@ -74,6 +74,10 @@ module Clusters ) end + def active? + manages_kubernetes_service? + end + private def enforce_namespace_to_lower_case From b893a858808f1e2aefca4e3f665d633d31fa5937 Mon Sep 17 00:00:00 2001 From: Alessio Caiazza Date: Mon, 6 Nov 2017 16:04:58 +0100 Subject: [PATCH 08/17] db/schema.rb cleanup --- db/schema.rb | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/db/schema.rb b/db/schema.rb index 548f4711339..507024c20de 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -474,8 +474,6 @@ ActiveRecord::Schema.define(version: 20171031100710) do t.string "encrypted_password_iv" t.text "encrypted_token" t.string "encrypted_token_iv" - t.datetime_with_timezone "created_at", null: false - t.datetime_with_timezone "updated_at", null: false end add_index "cluster_platforms_kubernetes", ["cluster_id"], name: "index_cluster_platforms_kubernetes_on_cluster_id", unique: true, using: :btree @@ -499,14 +497,11 @@ ActiveRecord::Schema.define(version: 20171031100710) do t.text "status_reason" t.string "gcp_project_id", null: false t.string "zone", null: false - t.integer "num_nodes", null: false t.string "machine_type" t.string "operation_id" t.string "endpoint" t.text "encrypted_access_token" t.string "encrypted_access_token_iv" - t.datetime_with_timezone "created_at", null: false - t.datetime_with_timezone "updated_at", null: false end add_index "cluster_providers_gcp", ["cluster_id"], name: "index_cluster_providers_gcp_on_cluster_id", unique: true, using: :btree @@ -519,12 +514,11 @@ ActiveRecord::Schema.define(version: 20171031100710) do t.datetime_with_timezone "updated_at", null: false t.boolean "enabled", default: true t.string "name", null: false - t.integer "provider_type" - t.integer "platform_type" - t.datetime_with_timezone "created_at", null: false - t.datetime_with_timezone "updated_at", null: false end + add_index "clusters", ["enabled"], name: "index_clusters_on_enabled", using: :btree + add_index "clusters", ["user_id"], name: "index_clusters_on_user_id", using: :btree + create_table "clusters_applications_helm", force: :cascade do |t| t.integer "cluster_id", null: false t.datetime_with_timezone "created_at", null: false From 2802b5bb52b6ba28e6eeb1813f3fd3a79d2c03c4 Mon Sep 17 00:00:00 2001 From: Alessio Caiazza Date: Mon, 6 Nov 2017 17:02:28 +0100 Subject: [PATCH 09/17] Add ClusterApplicationEntity tests --- ...ntity.rb => cluster_application_entity.rb} | 2 +- app/serializers/cluster_entity.rb | 2 +- spec/fixtures/api/schemas/cluster_status.json | 26 +++++++--------- .../cluster_application_entity_spec.rb | 30 +++++++++++++++++++ spec/serializers/cluster_entity_spec.rb | 13 ++++++-- spec/serializers/cluster_serializer_spec.rb | 2 +- 6 files changed, 55 insertions(+), 20 deletions(-) rename app/serializers/{cluster_app_entity.rb => cluster_application_entity.rb} (62%) create mode 100644 spec/serializers/cluster_application_entity_spec.rb diff --git a/app/serializers/cluster_app_entity.rb b/app/serializers/cluster_application_entity.rb similarity index 62% rename from app/serializers/cluster_app_entity.rb rename to app/serializers/cluster_application_entity.rb index 7da2d4921a2..3f9a275ad08 100644 --- a/app/serializers/cluster_app_entity.rb +++ b/app/serializers/cluster_application_entity.rb @@ -1,4 +1,4 @@ -class ClusterAppEntity < Grape::Entity +class ClusterApplicationEntity < Grape::Entity expose :name expose :status_name, as: :status expose :status_reason diff --git a/app/serializers/cluster_entity.rb b/app/serializers/cluster_entity.rb index e775c68eb6b..7e5b0997878 100644 --- a/app/serializers/cluster_entity.rb +++ b/app/serializers/cluster_entity.rb @@ -3,5 +3,5 @@ class ClusterEntity < Grape::Entity expose :status_name, as: :status expose :status_reason - expose :applications, using: ClusterAppEntity + expose :applications, using: ClusterApplicationEntity end diff --git a/spec/fixtures/api/schemas/cluster_status.json b/spec/fixtures/api/schemas/cluster_status.json index 451ea50f0f9..489d563be2b 100644 --- a/spec/fixtures/api/schemas/cluster_status.json +++ b/spec/fixtures/api/schemas/cluster_status.json @@ -1,42 +1,38 @@ { "type": "object", "required" : [ - "status" + "status", + "applications" ], "properties" : { "status": { "type": "string" }, "status_reason": { "type": ["string", "null"] }, - "applications": { "$ref": "#/definitions/applications" } + "applications": { + "type": "array", + "items": { "$ref": "#/definitions/application_status" } + } }, "additionalProperties": false, "definitions": { - "applications": { - "type": "object", - "additionalProperties": false, - "properties" : { - "helm": { "$ref": "#/definitions/app_status" }, - "runner": { "$ref": "#/definitions/app_status" }, - "ingress": { "$ref": "#/definitions/app_status" }, - "prometheus": { "$ref": "#/definitions/app_status" } - } - }, - "app_status": { + "application_status": { "type": "object", "additionalProperties": false, "properties" : { + "name": { "type": "string" }, "status": { "type": { "enum": [ "installable", + "scheduled", "installing", "installed", - "error" + "errored" ] } }, "status_reason": { "type": ["string", "null"] } }, - "required" : [ "status" ] + "required" : [ "name", "status" ] } } } diff --git a/spec/serializers/cluster_application_entity_spec.rb b/spec/serializers/cluster_application_entity_spec.rb new file mode 100644 index 00000000000..61cebcefa28 --- /dev/null +++ b/spec/serializers/cluster_application_entity_spec.rb @@ -0,0 +1,30 @@ +require 'spec_helper' + +describe ClusterApplicationEntity do + describe '#as_json' do + let(:application) { build(:applications_helm) } + subject { described_class.new(application).as_json } + + it 'has name' do + expect(subject[:name]).to eq(application.name) + end + + it 'has status' do + expect(subject[:status]).to eq(:installable) + end + + it 'has no status_reason' do + expect(subject[:status_reason]).to be_nil + end + + context 'when application is errored' do + let(:application) { build(:applications_helm, :errored) } + + it 'has corresponded data' do + expect(subject[:status]).to eq(:errored) + expect(subject[:status_reason]).not_to be_nil + expect(subject[:status_reason]).to eq(application.status_reason) + end + end + end +end diff --git a/spec/serializers/cluster_entity_spec.rb b/spec/serializers/cluster_entity_spec.rb index abfc3731fb2..c58ee1a1ea6 100644 --- a/spec/serializers/cluster_entity_spec.rb +++ b/spec/serializers/cluster_entity_spec.rb @@ -35,8 +35,17 @@ describe ClusterEntity do end end - it 'contains applications' do - expect(subject[:applications]).to eq({}) + context 'when no application has been installed' do + let(:cluster) { create(:cluster) } + subject { described_class.new(cluster).as_json[:applications]} + + it 'contains helm as installable' do + expect(subject).not_to be_empty + + helm = subject[0] + expect(helm[:name]).to eq('helm') + expect(helm[:status]).to eq(:installable) + end end end end diff --git a/spec/serializers/cluster_serializer_spec.rb b/spec/serializers/cluster_serializer_spec.rb index 04d8728303c..a6dd2309663 100644 --- a/spec/serializers/cluster_serializer_spec.rb +++ b/spec/serializers/cluster_serializer_spec.rb @@ -9,7 +9,7 @@ describe ClusterSerializer do let(:provider) { create(:provider_gcp, :errored) } it 'serializes only status' do - expect(subject.keys).to contain_exactly(:status, :status_reason) + expect(subject.keys).to contain_exactly(:status, :status_reason, :applications) end end From f4fb0340094508106113c0c7c22c865fa7c73f7f Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 6 Nov 2017 10:07:19 -0600 Subject: [PATCH 10/17] Add FE tests for not_installable/scheduled and cluster banner rules --- .../javascripts/clusters/clusters_bundle.js | 48 +++++++++++-------- .../clusters/components/application_row.vue | 6 ++- app/assets/javascripts/clusters/constants.js | 2 +- .../clusters/services/clusters_service.js | 2 + .../clusters/clusters_bundle_spec.js | 48 +++++++++++++++++-- .../components/application_row_spec.js | 42 ++++++++++++---- .../clusters/stores/clusters_store_spec.js | 3 ++ 7 files changed, 117 insertions(+), 34 deletions(-) diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index 4d4c90460d2..c486208175f 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -28,6 +28,8 @@ export default class Clusters { const { statusPath, installHelmPath, + installIngressPath, + installRunnerPath, clusterStatus, clusterStatusReason, helpPath, @@ -40,6 +42,8 @@ export default class Clusters { this.service = new ClustersService({ endpoint: statusPath, installHelmEndpoint: installHelmPath, + installIngresEndpoint: installIngressPath, + installRunnerEndpoint: installRunnerPath, }); this.toggle = this.toggle.bind(this); @@ -57,7 +61,7 @@ export default class Clusters { this.initApplications(); if (this.store.state.status !== 'created') { - this.updateContainer(this.store.state.status, this.store.state.statusReason); + this.updateContainer(null, this.store.state.status, this.store.state.statusReason); } this.addListeners(); @@ -131,13 +135,13 @@ export default class Clusters { } handleSuccess(data) { - const prevApplicationMap = Object.assign({}, this.store.state.applications); const prevStatus = this.store.state.status; + const prevApplicationMap = Object.assign({}, this.store.state.applications); + this.store.updateStateFromServer(data.data); + this.checkForNewInstalls(prevApplicationMap, this.store.state.applications); - if (prevStatus.length == 0 || prevStatus !== this.store.state.status) { - this.updateContainer(this.store.state.status, this.store.state.statusReason); - } + this.updateContainer(prevStatus, this.store.state.status, this.store.state.statusReason); } toggle() { @@ -168,22 +172,26 @@ export default class Clusters { } } - updateContainer(status, error) { + updateContainer(prevStatus, status, error) { this.hideAll(); - switch (status) { - case 'created': - this.successContainer.classList.remove('hidden'); - break; - case 'errored': - this.errorContainer.classList.remove('hidden'); - this.errorReasonContainer.textContent = error; - break; - case 'scheduled': - case 'creating': - this.creatingContainer.classList.remove('hidden'); - break; - default: - this.hideAll(); + + // We poll all the time but only want the `created` banner to show when newly created + if (this.store.state.status !== 'created' || prevStatus !== this.store.state.status) { + switch (status) { + case 'created': + this.successContainer.classList.remove('hidden'); + break; + case 'errored': + this.errorContainer.classList.remove('hidden'); + this.errorReasonContainer.textContent = error; + break; + case 'scheduled': + case 'creating': + this.creatingContainer.classList.remove('hidden'); + break; + default: + this.hideAll(); + } } } diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue index 9c5ff39534f..3dc658d0f1f 100644 --- a/app/assets/javascripts/clusters/components/application_row.vue +++ b/app/assets/javascripts/clusters/components/application_row.vue @@ -75,7 +75,11 @@ export default { }, installButtonLabel() { let label; - if (this.status === APPLICATION_INSTALLABLE || this.status === APPLICATION_ERROR || this.status === APPLICATION_NOT_INSTALLABLE) { + if ( + this.status === APPLICATION_NOT_INSTALLABLE || + this.status === APPLICATION_INSTALLABLE || + this.status === APPLICATION_ERROR + ) { label = s__('ClusterIntegration|Install'); } else if (this.status === APPLICATION_SCHEDULED || this.status === APPLICATION_INSTALLING) { label = s__('ClusterIntegration|Installing'); diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js index f1894b173b9..93223aefff8 100644 --- a/app/assets/javascripts/clusters/constants.js +++ b/app/assets/javascripts/clusters/constants.js @@ -4,7 +4,7 @@ export const APPLICATION_INSTALLABLE = 'installable'; export const APPLICATION_SCHEDULED = 'scheduled'; export const APPLICATION_INSTALLING = 'installing'; export const APPLICATION_INSTALLED = 'installed'; -export const APPLICATION_ERROR = 'error'; +export const APPLICATION_ERROR = 'errored'; // These are only used client-side export const REQUEST_LOADING = 'request-loading'; diff --git a/app/assets/javascripts/clusters/services/clusters_service.js b/app/assets/javascripts/clusters/services/clusters_service.js index a7bb0961c7e..0ac8e68187d 100644 --- a/app/assets/javascripts/clusters/services/clusters_service.js +++ b/app/assets/javascripts/clusters/services/clusters_service.js @@ -8,6 +8,8 @@ export default class ClusterService { this.options = options; this.appInstallEndpointMap = { helm: this.options.installHelmEndpoint, + ingress: this.options.installIngressEndpoint, + runner: this.options.installRunnerEndpoint, }; } diff --git a/spec/javascripts/clusters/clusters_bundle_spec.js b/spec/javascripts/clusters/clusters_bundle_spec.js index 26d230ce414..86e9cb22be8 100644 --- a/spec/javascripts/clusters/clusters_bundle_spec.js +++ b/spec/javascripts/clusters/clusters_bundle_spec.js @@ -104,7 +104,21 @@ describe('Clusters', () => { describe('updateContainer', () => { describe('when creating cluster', () => { it('should show the creating container', () => { - cluster.updateContainer('creating'); + cluster.updateContainer(null, 'creating'); + + expect( + cluster.creatingContainer.classList.contains('hidden'), + ).toBeFalsy(); + expect( + cluster.successContainer.classList.contains('hidden'), + ).toBeTruthy(); + expect( + cluster.errorContainer.classList.contains('hidden'), + ).toBeTruthy(); + }); + + it('should continue to show `creating` banner with subsequent updates of the same status', () => { + cluster.updateContainer('creating', 'creating'); expect( cluster.creatingContainer.classList.contains('hidden'), @@ -120,7 +134,7 @@ describe('Clusters', () => { describe('when cluster is created', () => { it('should show the success container', () => { - cluster.updateContainer('created'); + cluster.updateContainer(null, 'created'); expect( cluster.creatingContainer.classList.contains('hidden'), @@ -132,11 +146,25 @@ describe('Clusters', () => { cluster.errorContainer.classList.contains('hidden'), ).toBeTruthy(); }); + + it('should not show a banner when status is already `created`', () => { + cluster.updateContainer('created', 'created'); + + expect( + cluster.creatingContainer.classList.contains('hidden'), + ).toBeTruthy(); + expect( + cluster.successContainer.classList.contains('hidden'), + ).toBeTruthy(); + expect( + cluster.errorContainer.classList.contains('hidden'), + ).toBeTruthy(); + }); }); describe('when cluster has error', () => { it('should show the error container', () => { - cluster.updateContainer('errored', 'this is an error'); + cluster.updateContainer(null, 'errored', 'this is an error'); expect( cluster.creatingContainer.classList.contains('hidden'), @@ -152,6 +180,20 @@ describe('Clusters', () => { cluster.errorReasonContainer.textContent, ).toContain('this is an error'); }); + + it('should show `error` banner when previously `creating`', () => { + cluster.updateContainer('creating', 'errored'); + + expect( + cluster.creatingContainer.classList.contains('hidden'), + ).toBeTruthy(); + expect( + cluster.successContainer.classList.contains('hidden'), + ).toBeTruthy(); + expect( + cluster.errorContainer.classList.contains('hidden'), + ).toBeFalsy(); + }); }); }); diff --git a/spec/javascripts/clusters/components/application_row_spec.js b/spec/javascripts/clusters/components/application_row_spec.js index ba38ed6f180..392cebc5e35 100644 --- a/spec/javascripts/clusters/components/application_row_spec.js +++ b/spec/javascripts/clusters/components/application_row_spec.js @@ -1,6 +1,8 @@ import Vue from 'vue'; import eventHub from '~/clusters/event_hub'; import { + APPLICATION_NOT_INSTALLABLE, + APPLICATION_SCHEDULED, APPLICATION_INSTALLABLE, APPLICATION_INSTALLING, APPLICATION_INSTALLED, @@ -60,7 +62,18 @@ describe('Application Row', () => { expect(vm.installButtonLabel).toBeUndefined(); }); - it('has enabled "Install" when `status=installable`', () => { + it('has disabled "Install" when APPLICATION_NOT_INSTALLABLE', () => { + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + status: APPLICATION_NOT_INSTALLABLE, + }); + + expect(vm.installButtonLabel).toEqual('Install'); + expect(vm.installButtonLoading).toEqual(false); + expect(vm.installButtonDisabled).toEqual(true); + }); + + it('has enabled "Install" when APPLICATION_INSTALLABLE', () => { vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, status: APPLICATION_INSTALLABLE, @@ -71,7 +84,18 @@ describe('Application Row', () => { expect(vm.installButtonDisabled).toEqual(false); }); - it('has loading "Installing" when `status=installing`', () => { + it('has loading "Installing" when APPLICATION_SCHEDULED', () => { + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + status: APPLICATION_SCHEDULED, + }); + + expect(vm.installButtonLabel).toEqual('Installing'); + expect(vm.installButtonLoading).toEqual(true); + expect(vm.installButtonDisabled).toEqual(true); + }); + + it('has loading "Installing" when APPLICATION_INSTALLING', () => { vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, status: APPLICATION_INSTALLING, @@ -82,7 +106,7 @@ describe('Application Row', () => { expect(vm.installButtonDisabled).toEqual(true); }); - it('has disabled "Installed" when `status=installed`', () => { + it('has disabled "Installed" when APPLICATION_INSTALLED', () => { vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, status: APPLICATION_INSTALLED, @@ -93,7 +117,7 @@ describe('Application Row', () => { expect(vm.installButtonDisabled).toEqual(true); }); - it('has disabled "Install" when `status=error`', () => { + it('has disabled "Install" when APPLICATION_ERROR', () => { vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, status: APPLICATION_ERROR, @@ -104,7 +128,7 @@ describe('Application Row', () => { expect(vm.installButtonDisabled).toEqual(true); }); - it('has loading "Install" when `requestStatus=loading`', () => { + it('has loading "Install" when REQUEST_LOADING', () => { vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, status: APPLICATION_INSTALLABLE, @@ -116,7 +140,7 @@ describe('Application Row', () => { expect(vm.installButtonDisabled).toEqual(true); }); - it('has disabled "Install" when `requestStatus=success`', () => { + it('has disabled "Install" when REQUEST_SUCCESS', () => { vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, status: APPLICATION_INSTALLABLE, @@ -128,7 +152,7 @@ describe('Application Row', () => { expect(vm.installButtonDisabled).toEqual(true); }); - it('has enabled "Install" when `requestStatus=error` (so you can try installing again)', () => { + it('has enabled "Install" when REQUEST_FAILURE (so you can try installing again)', () => { vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, status: APPLICATION_INSTALLABLE, @@ -181,7 +205,7 @@ describe('Application Row', () => { expect(generalErrorMessage).toBeNull(); }); - it('shows status reason when `status=error`', () => { + it('shows status reason when APPLICATION_ERROR', () => { const statusReason = 'We broke it 0.0'; vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, @@ -195,7 +219,7 @@ describe('Application Row', () => { expect(statusErrorMessage.textContent.trim()).toEqual(statusReason); }); - it('shows request reason when `requestStatus=error`', () => { + it('shows request reason when REQUEST_FAILURE', () => { const requestReason = 'We broke thre request 0.0'; vm = mountComponent(ApplicationRow, { ...DEFAULT_APPLICATION_STATE, diff --git a/spec/javascripts/clusters/stores/clusters_store_spec.js b/spec/javascripts/clusters/stores/clusters_store_spec.js index 9f9d63434f7..cb8b3d38e2e 100644 --- a/spec/javascripts/clusters/stores/clusters_store_spec.js +++ b/spec/javascripts/clusters/stores/clusters_store_spec.js @@ -62,18 +62,21 @@ describe('Clusters Store', () => { statusReason: mockResponseData.status_reason, applications: { helm: { + title: 'Helm Tiller', status: mockResponseData.applications[0].status, statusReason: mockResponseData.applications[0].status_reason, requestStatus: null, requestReason: null, }, ingress: { + title: 'Ingress', status: mockResponseData.applications[1].status, statusReason: mockResponseData.applications[1].status_reason, requestStatus: null, requestReason: null, }, runner: { + title: 'GitLab Runner', status: mockResponseData.applications[2].status, statusReason: mockResponseData.applications[2].status_reason, requestStatus: null, From 2b4fccb7205101480bae2668e681cb3b58fface5 Mon Sep 17 00:00:00 2001 From: Alessio Caiazza Date: Mon, 6 Nov 2017 18:06:02 +0100 Subject: [PATCH 11/17] Add Helm import/export --- lib/gitlab/import_export/import_export.yml | 3 ++- lib/gitlab/import_export/relation_factory.rb | 2 +- spec/lib/gitlab/import_export/all_models.yml | 8 ++++++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/lib/gitlab/import_export/import_export.yml b/lib/gitlab/import_export/import_export.yml index 561779182bc..06b1035fec6 100644 --- a/lib/gitlab/import_export/import_export.yml +++ b/lib/gitlab/import_export/import_export.yml @@ -54,7 +54,8 @@ project_tree: - :auto_devops - :triggers - :pipeline_schedules - - :cluster + - clusters: + - :application_helm - :services - :hooks - protected_branches: diff --git a/lib/gitlab/import_export/relation_factory.rb b/lib/gitlab/import_export/relation_factory.rb index a790dcfe8a6..679be1b21fa 100644 --- a/lib/gitlab/import_export/relation_factory.rb +++ b/lib/gitlab/import_export/relation_factory.rb @@ -8,8 +8,8 @@ module Gitlab triggers: 'Ci::Trigger', pipeline_schedules: 'Ci::PipelineSchedule', builds: 'Ci::Build', - cluster: 'Clusters::Cluster', clusters: 'Clusters::Cluster', + application_helm: 'Clusters::Applications::Helm', hooks: 'ProjectHook', merge_access_levels: 'ProtectedBranch::MergeAccessLevel', push_access_levels: 'ProtectedBranch::PushAccessLevel', diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 96efdd0949b..6eb266a7b94 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -147,7 +147,8 @@ deploy_keys: - user - deploy_keys_projects - projects -cluster: +clusters: +- application_helm - cluster_projects - projects - user @@ -160,6 +161,8 @@ provider_gcp: - cluster platform_kubernetes: - cluster +application_helm: +- cluster services: - project - service_hook @@ -191,6 +194,7 @@ project: - tags - chat_services - cluster +- clusters - cluster_project - creator - group @@ -299,4 +303,4 @@ push_event_payload: - event issue_assignees: - issue -- assignee \ No newline at end of file +- assignee From f676f388153fc9b5da0a76b2d5c2af9d8ffe832c Mon Sep 17 00:00:00 2001 From: Alessio Caiazza Date: Mon, 6 Nov 2017 18:21:10 +0100 Subject: [PATCH 12/17] Add more tests to Projects::Clusters::ApplicationsController --- .../projects/clusters/applications_controller.rb | 14 ++++++-------- .../clusters/applications_controller_spec.rb | 13 +++++++++++++ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/app/controllers/projects/clusters/applications_controller.rb b/app/controllers/projects/clusters/applications_controller.rb index 4b9d54a8537..90c7fa62216 100644 --- a/app/controllers/projects/clusters/applications_controller.rb +++ b/app/controllers/projects/clusters/applications_controller.rb @@ -5,14 +5,12 @@ class Projects::Clusters::ApplicationsController < Projects::ApplicationControll before_action :authorize_create_cluster!, only: [:create] def create - scheduled = Clusters::Applications::ScheduleInstallationService.new(project, current_user, - application_class: @application_class, - cluster: @cluster).execute - if scheduled - head :no_content - else - head :bad_request - end + Clusters::Applications::ScheduleInstallationService.new(project, current_user, + application_class: @application_class, + cluster: @cluster).execute + head :no_content + rescue StandardError + head :bad_request end private diff --git a/spec/controllers/projects/clusters/applications_controller_spec.rb b/spec/controllers/projects/clusters/applications_controller_spec.rb index b8464b713c4..3213a797756 100644 --- a/spec/controllers/projects/clusters/applications_controller_spec.rb +++ b/spec/controllers/projects/clusters/applications_controller_spec.rb @@ -49,6 +49,19 @@ describe Projects::Clusters::ApplicationsController do expect(response).to have_http_status(:not_found) end end + + context 'when application is already installing' do + before do + other = current_application.new(cluster: cluster) + other.make_installing! + end + + it 'returns 400' do + go + + expect(response).to have_http_status(:bad_request) + end + end end describe 'security' do From 02f99feec25491c07777682b20f74f3b0b2b074d Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 6 Nov 2017 12:07:21 -0600 Subject: [PATCH 13/17] I18n general app error and update attributes from review --- .../clusters/components/application_row.vue | 26 +++++++++++++------ .../clusters/components/applications.vue | 4 +-- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue index 3dc658d0f1f..aee4e86ddac 100644 --- a/app/assets/javascripts/clusters/components/application_row.vue +++ b/app/assets/javascripts/clusters/components/application_row.vue @@ -1,5 +1,5 @@