summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorKamil Trzciński <ayufan@ayufan.eu>2017-11-07 18:36:19 +0000
committerKamil Trzciński <ayufan@ayufan.eu>2017-11-07 18:36:19 +0000
commitd9a1dc3ddd3f491fb3ed922c0b85819e9fa95fea (patch)
treea0fd2f8c2790584acbc1bbef39780cf7a1e834a4
parentb16c2ef7d5c4e7696b51c7e410381b316be128a6 (diff)
parent18ac8acb5b97c2a395b8c453a29d0fcfd176b9c9 (diff)
downloadgitlab-ce-d9a1dc3ddd3f491fb3ed922c0b85819e9fa95fea.tar.gz
Merge branch '38464-k8s-apps' into 'master'
Add support for k8s cluster applications - Helm tiller installation See merge request gitlab-org/gitlab-ce!14908
-rw-r--r--app/assets/javascripts/clusters.js123
-rw-r--r--app/assets/javascripts/clusters/clusters_bundle.js221
-rw-r--r--app/assets/javascripts/clusters/components/application_row.vue185
-rw-r--r--app/assets/javascripts/clusters/components/applications.vue105
-rw-r--r--app/assets/javascripts/clusters/constants.js12
-rw-r--r--app/assets/javascripts/clusters/event_hub.js3
-rw-r--r--app/assets/javascripts/clusters/services/clusters_service.js24
-rw-r--r--app/assets/javascripts/clusters/stores/clusters_store.js68
-rw-r--r--app/assets/javascripts/dispatcher.js9
-rw-r--r--app/assets/javascripts/vue_shared/components/loading_button.vue7
-rw-r--r--app/assets/stylesheets/framework/buttons.scss1
-rw-r--r--app/assets/stylesheets/pages/clusters.scss5
-rw-r--r--app/controllers/projects/clusters/applications_controller.rb25
-rw-r--r--app/models/clusters/applications/helm.rb35
-rw-r--r--app/models/clusters/cluster.rb28
-rw-r--r--app/models/clusters/concerns/application_status.rb43
-rw-r--r--app/models/clusters/platforms/kubernetes.rb8
-rw-r--r--app/models/project.rb1
-rw-r--r--app/models/project_services/kubernetes_service.rb4
-rw-r--r--app/serializers/cluster_application_entity.rb5
-rw-r--r--app/serializers/cluster_entity.rb1
-rw-r--r--app/serializers/cluster_serializer.rb2
-rw-r--r--app/services/clusters/applications/base_helm_service.rb29
-rw-r--r--app/services/clusters/applications/check_installation_progress_service.rb65
-rw-r--r--app/services/clusters/applications/install_service.rb21
-rw-r--r--app/services/clusters/applications/schedule_installation_service.rb22
-rw-r--r--app/views/projects/clusters/show.html.haml16
-rw-r--r--app/workers/cluster_install_app_worker.rb11
-rw-r--r--app/workers/cluster_wait_for_app_installation_worker.rb14
-rw-r--r--app/workers/concerns/cluster_applications.rb9
-rw-r--r--changelogs/unreleased/36629-35958-add-cluster-application-section.yml6
-rw-r--r--config/routes/project.rb4
-rw-r--r--db/migrate/20171031100710_create_clusters_kubernetes_helm_apps.rb18
-rw-r--r--db/schema.rb10
-rw-r--r--doc/user/project/clusters/img/cluster-applications.pngbin0 -> 39115 bytes
-rw-r--r--doc/user/project/clusters/index.md9
-rw-r--r--lib/gitlab/import_export/import_export.yml1
-rw-r--r--lib/gitlab/import_export/relation_factory.rb2
-rw-r--r--lib/gitlab/kubernetes/helm.rb96
-rw-r--r--lib/gitlab/kubernetes/namespace.rb29
-rw-r--r--lib/gitlab/kubernetes/pod.rb12
-rw-r--r--spec/controllers/projects/clusters/applications_controller_spec.rb85
-rw-r--r--spec/factories/clusters/applications/helm.rb35
-rw-r--r--spec/features/projects/clusters_spec.rb42
-rw-r--r--spec/fixtures/api/schemas/cluster_status.json33
-rw-r--r--spec/javascripts/clusters/clusters_bundle_spec.js257
-rw-r--r--spec/javascripts/clusters/components/application_row_spec.js237
-rw-r--r--spec/javascripts/clusters/components/applications_spec.js42
-rw-r--r--spec/javascripts/clusters/services/mock_data.js50
-rw-r--r--spec/javascripts/clusters/stores/clusters_store_spec.js89
-rw-r--r--spec/javascripts/clusters_spec.js79
-rw-r--r--spec/lib/gitlab/import_export/all_models.yml16
-rw-r--r--spec/lib/gitlab/kubernetes/helm_spec.rb100
-rw-r--r--spec/lib/gitlab/kubernetes/namespace_spec.rb66
-rw-r--r--spec/models/clusters/applications/helm_spec.rb102
-rw-r--r--spec/models/project_services/kubernetes_service_spec.rb2
-rw-r--r--spec/serializers/cluster_application_entity_spec.rb30
-rw-r--r--spec/serializers/cluster_entity_spec.rb17
-rw-r--r--spec/serializers/cluster_serializer_spec.rb4
-rw-r--r--spec/services/clusters/applications/check_installation_progress_service_spec.rb91
-rw-r--r--spec/services/clusters/applications/install_service_spec.rb60
-rw-r--r--spec/services/clusters/applications/schedule_installation_service_spec.rb55
-rw-r--r--spec/support/matchers/access_matchers_for_controller.rb2
63 files changed, 2546 insertions, 237 deletions
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..5f421ea58ba
--- /dev/null
+++ b/app/assets/javascripts/clusters/clusters_bundle.js
@@ -0,0 +1,221 @@
+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(null, 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 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);
+ this.updateContainer(prevStatus, 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) {
+ const text = sprintf(s__('ClusterIntegration|%{appList} was successfully installed on your cluster'), {
+ appList: appTitles.join(', '),
+ });
+ Flash(text, 'notice', this.successApplicationContainer);
+ }
+ }
+
+ updateContainer(prevStatus, status, error) {
+ 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();
+ }
+ }
+ }
+
+ 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..872abf03ef1
--- /dev/null
+++ b/app/assets/javascripts/clusters/components/application_row.vue
@@ -0,0 +1,185 @@
+<script>
+import { s__, sprintf } 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,
+ APPLICATION_ERROR,
+ REQUEST_LOADING,
+ REQUEST_SUCCESS,
+ REQUEST_FAILURE,
+} from '../constants';
+
+export default {
+ props: {
+ id: {
+ type: String,
+ required: true,
+ },
+ title: {
+ type: String,
+ required: true,
+ },
+ titleLink: {
+ type: String,
+ required: false,
+ },
+ description: {
+ type: String,
+ required: true,
+ },
+ status: {
+ type: String,
+ required: false,
+ },
+ statusReason: {
+ type: String,
+ required: false,
+ },
+ requestStatus: {
+ type: String,
+ required: false,
+ },
+ requestReason: {
+ type: String,
+ required: false,
+ },
+ },
+ components: {
+ loadingButton,
+ },
+ computed: {
+ rowJsClass() {
+ return `js-cluster-application-row-${this.id}`;
+ },
+ installButtonLoading() {
+ return !this.status ||
+ this.status === APPLICATION_SCHEDULED ||
+ this.status === APPLICATION_INSTALLING ||
+ this.requestStatus === REQUEST_LOADING;
+ },
+ installButtonDisabled() {
+ // Avoid the potential for the real-time data to say APPLICATION_INSTALLABLE but
+ // we already made a request to install and are just waiting for the real-time
+ // to sync up.
+ return (this.status !== APPLICATION_INSTALLABLE && this.status !== APPLICATION_ERROR) ||
+ this.requestStatus === REQUEST_LOADING ||
+ this.requestStatus === REQUEST_SUCCESS;
+ },
+ installButtonLabel() {
+ let label;
+ 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');
+ } else if (this.status === APPLICATION_INSTALLED) {
+ label = s__('ClusterIntegration|Installed');
+ }
+
+ return label;
+ },
+ hasError() {
+ return this.status === APPLICATION_ERROR || this.requestStatus === REQUEST_FAILURE;
+ },
+ generalErrorDescription() {
+ return sprintf(
+ s__('ClusterIntegration|Something went wrong while installing %{title}'), {
+ title: this.title,
+ },
+ );
+ },
+ },
+ methods: {
+ installClicked() {
+ eventHub.$emit('installApplication', this.id);
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="gl-responsive-table-row gl-responsive-table-row-col-span"
+ :class="rowJsClass"
+ >
+ <div
+ class="gl-responsive-table-row-layout"
+ role="row"
+ >
+ <a
+ v-if="titleLink"
+ :href="titleLink"
+ target="blank"
+ rel="noopener noreferrer"
+ role="gridcell"
+ class="table-section section-15 section-align-top js-cluster-application-title"
+ >
+ {{ title }}
+ </a>
+ <span
+ v-else
+ class="table-section section-15 section-align-top js-cluster-application-title"
+ >
+ {{ title }}
+ </span>
+ <div
+ class="table-section section-wrap"
+ role="gridcell"
+ >
+ <div v-html="description"></div>
+ </div>
+ <div
+ class="table-section table-button-footer section-15 section-align-top"
+ role="gridcell"
+ >
+ <div class="btn-group table-action-buttons">
+ <loading-button
+ class="js-cluster-application-install-button"
+ :loading="installButtonLoading"
+ :disabled="installButtonDisabled"
+ :label="installButtonLabel"
+ @click="installClicked"
+ />
+ </div>
+ </div>
+ </div>
+ <div
+ v-if="hasError"
+ class="gl-responsive-table-row-layout"
+ role="row"
+ >
+ <div
+ class="alert alert-danger alert-block append-bottom-0 table-section section-100"
+ role="gridcell"
+ >
+ <div>
+ <p class="js-cluster-application-general-error-message">
+ {{ generalErrorDescription }}
+ </p>
+ <ul v-if="statusReason || requestReason">
+ <li
+ v-if="statusReason"
+ class="js-cluster-application-status-error-message"
+ >
+ {{ statusReason }}
+ </li>
+ <li
+ v-if="requestReason"
+ class="js-cluster-application-request-error-message"
+ >
+ {{ requestReason }}
+ </li>
+ </ul>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue
new file mode 100644
index 00000000000..28da4cbe89d
--- /dev/null
+++ b/app/assets/javascripts/clusters/components/applications.vue
@@ -0,0 +1,105 @@
+<script>
+import _ from 'underscore';
+import { s__, sprintf } from '../../locale';
+import applicationRow from './application_row.vue';
+
+export default {
+ props: {
+ applications: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ helpPath: {
+ type: String,
+ required: false,
+ },
+ },
+ components: {
+ applicationRow,
+ },
+ computed: {
+ generalApplicationDescription() {
+ return sprintf(
+ _.escape(s__('ClusterIntegration|Install applications on your cluster. Read more about %{helpLink}')), {
+ helpLink: `<a href="${this.helpPath}">
+ ${_.escape(s__('ClusterIntegration|installing applications'))}
+ </a>`,
+ },
+ false,
+ );
+ },
+ helmTillerDescription() {
+ return _.escape(s__(
+ `ClusterIntegration|Helm streamlines installing and managing Kubernets applications.
+ Tiller runs inside of your Kubernetes Cluster, and manages
+ releases of your charts.`,
+ ));
+ },
+ ingressDescription() {
+ const descriptionParagraph = _.escape(s__(
+ `ClusterIntegration|Ingress gives you a way to route requests to services based on the
+ request host or path, centralizing a number of services into a single entrypoint.`,
+ ));
+
+ const extraCostParagraph = sprintf(
+ _.escape(s__('ClusterIntegration|%{boldNotice} This will add some extra resources like a load balancer, which incur additional costs. See %{pricingLink}')), {
+ boldNotice: `<strong>${_.escape(s__('ClusterIntegration|Note:'))}</strong>`,
+ pricingLink: `<a href="https://cloud.google.com/compute/pricing#lb" target="_blank" rel="noopener noreferrer">
+ ${_.escape(s__('ClusterIntegration|GKE pricing'))}
+ </a>`,
+ },
+ false,
+ );
+
+ return `
+ <p>
+ ${descriptionParagraph}
+ </p>
+ <p class="append-bottom-0">
+ ${extraCostParagraph}
+ </p>
+ `;
+ },
+ gitlabRunnerDescription() {
+ return _.escape(s__(
+ `ClusterIntegration|GitLab Runner is the open source project that is used to run your jobs
+ and send the results back to GitLab.`,
+ ));
+ },
+ },
+};
+</script>
+
+<template>
+ <section class="settings no-animate expanded">
+ <div class="settings-header">
+ <h4>
+ {{ s__('ClusterIntegration|Applications') }}
+ </h4>
+ <p
+ class="append-bottom-0"
+ v-html="generalApplicationDescription"
+ >
+ </p>
+ </div>
+
+ <div class="settings-content">
+ <div class="append-bottom-20">
+ <application-row
+ id="helm"
+ :title="applications.helm.title"
+ title-link="https://docs.helm.sh/"
+ :description="helmTillerDescription"
+ :status="applications.helm.status"
+ :status-reason="applications.helm.statusReason"
+ :request-status="applications.helm.requestStatus"
+ :request-reason="applications.helm.requestReason"
+ />
+ <!-- NOTE: Don't forget to update `clusters.scss` min-height for this block and uncomment `application_spec` tests -->
+ <!-- Add Ingress row, all other plumbing is complete -->
+ <!-- Add GitLab Runner row, all other plumbing is complete -->
+ </div>
+ </div>
+ </section>
+</template>
diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js
new file mode 100644
index 00000000000..93223aefff8
--- /dev/null
+++ b/app/assets/javascripts/clusters/constants.js
@@ -0,0 +1,12 @@
+// 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 = 'errored';
+
+// 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"
>
<transition name="fade">
<loading-icon
diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss
index c4a95afc4d2..b2f26cf7159 100644
--- a/app/assets/stylesheets/framework/buttons.scss
+++ b/app/assets/stylesheets/framework/buttons.scss
@@ -294,6 +294,7 @@
.btn-align-content {
display: flex;
+ justify-content: center;
align-items: center;
}
diff --git a/app/assets/stylesheets/pages/clusters.scss b/app/assets/stylesheets/pages/clusters.scss
index 5c91579c69c..b9bfae9356c 100644
--- a/app/assets/stylesheets/pages/clusters.scss
+++ b/app/assets/stylesheets/pages/clusters.scss
@@ -3,3 +3,8 @@
background-color: $white-light;
}
}
+
+.cluster-applications-table {
+ // Wait for the Vue to kick-in and render the applications block
+ min-height: 179px;
+}
diff --git a/app/controllers/projects/clusters/applications_controller.rb b/app/controllers/projects/clusters/applications_controller.rb
new file mode 100644
index 00000000000..90c7fa62216
--- /dev/null
+++ b/app/controllers/projects/clusters/applications_controller.rb
@@ -0,0 +1,25 @@
+class Projects::Clusters::ApplicationsController < Projects::ApplicationController
+ before_action :cluster
+ before_action :application_class, only: [:create]
+ before_action :authorize_read_cluster!
+ before_action :authorize_create_cluster!, only: [:create]
+
+ def create
+ Clusters::Applications::ScheduleInstallationService.new(project, current_user,
+ application_class: @application_class,
+ cluster: @cluster).execute
+ head :no_content
+ rescue StandardError
+ head :bad_request
+ end
+
+ private
+
+ def cluster
+ @cluster ||= project.clusters.find(params[:id]) || render_404
+ end
+
+ def application_class
+ @application_class ||= Clusters::Cluster::APPLICATIONS[params[:application]] || render_404
+ end
+end
diff --git a/app/models/clusters/applications/helm.rb b/app/models/clusters/applications/helm.rb
new file mode 100644
index 00000000000..c7949d11ef8
--- /dev/null
+++ b/app/models/clusters/applications/helm.rb
@@ -0,0 +1,35 @@
+module Clusters
+ module Applications
+ class Helm < ActiveRecord::Base
+ self.table_name = 'clusters_applications_helm'
+
+ include ::Clusters::Concerns::ApplicationStatus
+
+ belongs_to :cluster, class_name: 'Clusters::Cluster', foreign_key: :cluster_id
+
+ default_value_for :version, Gitlab::Kubernetes::Helm::HELM_VERSION
+
+ validates :cluster, presence: true
+
+ after_initialize :set_initial_status
+
+ def self.application_name
+ self.to_s.demodulize.underscore
+ end
+
+ def set_initial_status
+ return unless not_installable?
+
+ self.status = 'installable' if cluster&.platform_kubernetes_active?
+ end
+
+ def name
+ self.class.application_name
+ end
+
+ def install_command
+ Gitlab::Kubernetes::Helm::InstallCommand.new(name, true)
+ end
+ end
+ end
+end
diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb
index 955dba51745..68759ebb6df 100644
--- a/app/models/clusters/cluster.rb
+++ b/app/models/clusters/cluster.rb
@@ -4,6 +4,10 @@ module Clusters
self.table_name = 'clusters'
+ APPLICATIONS = {
+ Applications::Helm.application_name => Applications::Helm
+ }.freeze
+
belongs_to :user
has_many :cluster_projects, class_name: 'Clusters::Project'
@@ -15,6 +19,8 @@ module Clusters
# We have to ":destroy" it today to ensure that we clean also the Kubernetes Integration
has_one :platform_kubernetes, class_name: 'Clusters::Platforms::Kubernetes', autosave: true, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_one :application_helm, class_name: 'Clusters::Applications::Helm'
+
accepts_nested_attributes_for :provider_gcp, update_only: true
accepts_nested_attributes_for :platform_kubernetes, update_only: true
@@ -28,10 +34,12 @@ module Clusters
delegate :status, to: :provider, allow_nil: true
delegate :status_reason, to: :provider, allow_nil: true
- delegate :status_name, to: :provider, allow_nil: true
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
}
@@ -44,6 +52,20 @@ module Clusters
scope :enabled, -> { where(enabled: true) }
scope :disabled, -> { where(enabled: false) }
+ def status_name
+ if provider
+ provider.status_name
+ else
+ :created
+ end
+ end
+
+ def applications
+ [
+ application_helm || build_application_helm
+ ]
+ end
+
def provider
return provider_gcp if gcp?
end
@@ -59,6 +81,10 @@ module Clusters
end
alias_method :project, :first_project
+ def kubeclient
+ platform_kubernetes.kubeclient if kubernetes?
+ end
+
private
def restrict_modification
diff --git a/app/models/clusters/concerns/application_status.rb b/app/models/clusters/concerns/application_status.rb
new file mode 100644
index 00000000000..7b7c8eac773
--- /dev/null
+++ b/app/models/clusters/concerns/application_status.rb
@@ -0,0 +1,43 @@
+module Clusters
+ module Concerns
+ module ApplicationStatus
+ extend ActiveSupport::Concern
+
+ included do
+ state_machine :status, initial: :not_installable do
+ state :not_installable, value: -2
+ state :errored, value: -1
+ state :installable, value: 0
+ state :scheduled, value: 1
+ state :installing, value: 2
+ state :installed, value: 3
+
+ event :make_scheduled do
+ transition [:installable, :errored] => :scheduled
+ end
+
+ event :make_installing do
+ transition [:scheduled] => :installing
+ end
+
+ event :make_installed do
+ transition [:installing] => :installed
+ end
+
+ event :make_errored do
+ transition any => :errored
+ end
+
+ before_transition any => [:scheduled] do |app_status, _|
+ app_status.status_reason = nil
+ end
+
+ before_transition any => [:errored] do |app_status, transition|
+ status_reason = transition.args.first
+ app_status.status_reason = status_reason if status_reason
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb
index b11701797c2..6dc1ee810d3 100644
--- a/app/models/clusters/platforms/kubernetes.rb
+++ b/app/models/clusters/platforms/kubernetes.rb
@@ -55,6 +55,10 @@ module Clusters
self.class.namespace_for_project(project) if project
end
+ def kubeclient
+ @kubeclient ||= kubernetes_service.kubeclient if manages_kubernetes_service?
+ end
+
def update_kubernetes_integration!
raise 'Kubernetes service already configured' unless manages_kubernetes_service?
@@ -70,6 +74,10 @@ module Clusters
)
end
+ def active?
+ manages_kubernetes_service?
+ end
+
private
def enforce_namespace_to_lower_case
diff --git a/app/models/project.rb b/app/models/project.rb
index 379bab27d17..53df29dab02 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -189,6 +189,7 @@ class Project < ActiveRecord::Base
has_one :cluster_project, class_name: 'Clusters::Project'
has_one :cluster, through: :cluster_project, class_name: 'Clusters::Cluster'
+ has_many :clusters, through: :cluster_project, class_name: 'Clusters::Cluster'
# Container repositories need to remove data from the container registry,
# which is not managed by the DB. Hence we're still using dependent: :destroy
diff --git a/app/models/project_services/kubernetes_service.rb b/app/models/project_services/kubernetes_service.rb
index 5c0b3338a62..5080acffb3c 100644
--- a/app/models/project_services/kubernetes_service.rb
+++ b/app/models/project_services/kubernetes_service.rb
@@ -136,6 +136,10 @@ class KubernetesService < DeploymentService
{ pods: read_pods }
end
+ def kubeclient
+ @kubeclient ||= build_kubeclient!
+ end
+
TEMPLATE_PLACEHOLDER = 'Kubernetes namespace'.freeze
private
diff --git a/app/serializers/cluster_application_entity.rb b/app/serializers/cluster_application_entity.rb
new file mode 100644
index 00000000000..3f9a275ad08
--- /dev/null
+++ b/app/serializers/cluster_application_entity.rb
@@ -0,0 +1,5 @@
+class ClusterApplicationEntity < Grape::Entity
+ expose :name
+ expose :status_name, as: :status
+ expose :status_reason
+end
diff --git a/app/serializers/cluster_entity.rb b/app/serializers/cluster_entity.rb
index 08a113c4d8a..7e5b0997878 100644
--- a/app/serializers/cluster_entity.rb
+++ b/app/serializers/cluster_entity.rb
@@ -3,4 +3,5 @@ class ClusterEntity < Grape::Entity
expose :status_name, as: :status
expose :status_reason
+ expose :applications, using: ClusterApplicationEntity
end
diff --git a/app/serializers/cluster_serializer.rb b/app/serializers/cluster_serializer.rb
index 2c87202a105..2e13c1501e7 100644
--- a/app/serializers/cluster_serializer.rb
+++ b/app/serializers/cluster_serializer.rb
@@ -2,6 +2,6 @@ class ClusterSerializer < BaseSerializer
entity ClusterEntity
def represent_status(resource)
- represent(resource, { only: [:status, :status_reason] })
+ represent(resource, { only: [:status, :status_reason, :applications] })
end
end
diff --git a/app/services/clusters/applications/base_helm_service.rb b/app/services/clusters/applications/base_helm_service.rb
new file mode 100644
index 00000000000..9a4ce31cb39
--- /dev/null
+++ b/app/services/clusters/applications/base_helm_service.rb
@@ -0,0 +1,29 @@
+module Clusters
+ module Applications
+ class BaseHelmService
+ attr_accessor :app
+
+ def initialize(app)
+ @app = app
+ end
+
+ protected
+
+ def cluster
+ app.cluster
+ end
+
+ def kubeclient
+ cluster.kubeclient
+ end
+
+ def helm_api
+ @helm_api ||= Gitlab::Kubernetes::Helm.new(kubeclient)
+ end
+
+ def install_command
+ @install_command ||= app.install_command
+ end
+ end
+ end
+end
diff --git a/app/services/clusters/applications/check_installation_progress_service.rb b/app/services/clusters/applications/check_installation_progress_service.rb
new file mode 100644
index 00000000000..bde090eaeec
--- /dev/null
+++ b/app/services/clusters/applications/check_installation_progress_service.rb
@@ -0,0 +1,65 @@
+module Clusters
+ module Applications
+ class CheckInstallationProgressService < BaseHelmService
+ def execute
+ return unless app.installing?
+
+ case installation_phase
+ when Gitlab::Kubernetes::Pod::SUCCEEDED
+ on_success
+ when Gitlab::Kubernetes::Pod::FAILED
+ on_failed
+ else
+ check_timeout
+ end
+ rescue KubeException => ke
+ app.make_errored!("Kubernetes error: #{ke.message}") unless app.errored?
+ end
+
+ private
+
+ def on_success
+ app.make_installed!
+ ensure
+ remove_installation_pod
+ end
+
+ def on_failed
+ app.make_errored!(installation_errors || 'Installation silently failed')
+ ensure
+ remove_installation_pod
+ end
+
+ def check_timeout
+ if timeouted?
+ begin
+ app.make_errored!('Installation timeouted')
+ ensure
+ remove_installation_pod
+ end
+ else
+ ClusterWaitForAppInstallationWorker.perform_in(
+ ClusterWaitForAppInstallationWorker::INTERVAL, app.name, app.id)
+ end
+ end
+
+ def timeouted?
+ Time.now.utc - app.updated_at.to_time.utc > ClusterWaitForAppInstallationWorker::TIMEOUT
+ end
+
+ def remove_installation_pod
+ helm_api.delete_installation_pod!(install_command.pod_name)
+ rescue
+ # no-op
+ end
+
+ def installation_phase
+ helm_api.installation_status(install_command.pod_name)
+ end
+
+ def installation_errors
+ helm_api.installation_log(install_command.pod_name)
+ end
+ end
+ end
+end
diff --git a/app/services/clusters/applications/install_service.rb b/app/services/clusters/applications/install_service.rb
new file mode 100644
index 00000000000..8ceeec687cd
--- /dev/null
+++ b/app/services/clusters/applications/install_service.rb
@@ -0,0 +1,21 @@
+module Clusters
+ module Applications
+ class InstallService < BaseHelmService
+ def execute
+ return unless app.scheduled?
+
+ begin
+ app.make_installing!
+ helm_api.install(install_command)
+
+ ClusterWaitForAppInstallationWorker.perform_in(
+ ClusterWaitForAppInstallationWorker::INTERVAL, app.name, app.id)
+ rescue KubeException => ke
+ app.make_errored!("Kubernetes error: #{ke.message}")
+ rescue StandardError
+ app.make_errored!("Can't start installation process")
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/clusters/applications/schedule_installation_service.rb b/app/services/clusters/applications/schedule_installation_service.rb
new file mode 100644
index 00000000000..eb8caa68ef7
--- /dev/null
+++ b/app/services/clusters/applications/schedule_installation_service.rb
@@ -0,0 +1,22 @@
+module Clusters
+ module Applications
+ class ScheduleInstallationService < ::BaseService
+ def execute
+ application_class.find_or_create_by!(cluster: cluster).try do |application|
+ application.make_scheduled!
+ ClusterInstallAppWorker.perform_async(application.name, application.id)
+ end
+ end
+
+ private
+
+ def application_class
+ params[:application_class]
+ end
+
+ def cluster
+ params[:cluster]
+ end
+ end
+ end
+end
diff --git a/app/views/projects/clusters/show.html.haml b/app/views/projects/clusters/show.html.haml
index ebb9383ca12..be6784058ae 100644
--- a/app/views/projects/clusters/show.html.haml
+++ b/app/views/projects/clusters/show.html.haml
@@ -4,11 +4,17 @@
- expanded = Rails.env.test?
-- status_path = status_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster.id, format: :json) if can?(current_user, :admin_cluster, @cluster) && @cluster.on_creation?
+- 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: 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 } }
+ cluster_status_reason: @cluster.status_reason,
+ help_path: help_page_path('user/project/clusters/index.md', anchor: 'installing-applications') } }
+
+
+ .js-cluster-application-notice
+ .flash-container
%section.settings.no-animate.expanded
%h4= s_('ClusterIntegration|Enable cluster integration')
@@ -49,7 +55,9 @@
.form-group
= field.submit _('Save'), class: 'btn btn-success'
- %section.settings.no-animate#js-cluster-details{ class: ('expanded' if expanded) }
+ .cluster-applications-table#js-cluster-applications
+
+ %section.settings#js-cluster-details
.settings-header
%h4= s_('ClusterIntegration|Cluster details')
%button.btn.js-settings-toggle
@@ -59,7 +67,7 @@
.settings-content
.form_group.append-bottom-20
- %label.append-bottom-10{ for: 'cluter-name' }
+ %label.append-bottom-10{ for: 'cluster-name' }
= s_('ClusterIntegration|Cluster name')
.input-group
%input.form-control.cluster-name{ value: @cluster.name, disabled: true }
diff --git a/app/workers/cluster_install_app_worker.rb b/app/workers/cluster_install_app_worker.rb
new file mode 100644
index 00000000000..899aed904e4
--- /dev/null
+++ b/app/workers/cluster_install_app_worker.rb
@@ -0,0 +1,11 @@
+class ClusterInstallAppWorker
+ include Sidekiq::Worker
+ include ClusterQueue
+ include ClusterApplications
+
+ def perform(app_name, app_id)
+ find_application(app_name, app_id) do |app|
+ Clusters::Applications::InstallService.new(app).execute
+ end
+ end
+end
diff --git a/app/workers/cluster_wait_for_app_installation_worker.rb b/app/workers/cluster_wait_for_app_installation_worker.rb
new file mode 100644
index 00000000000..4bb8c293e5d
--- /dev/null
+++ b/app/workers/cluster_wait_for_app_installation_worker.rb
@@ -0,0 +1,14 @@
+class ClusterWaitForAppInstallationWorker
+ include Sidekiq::Worker
+ include ClusterQueue
+ include ClusterApplications
+
+ INTERVAL = 10.seconds
+ TIMEOUT = 20.minutes
+
+ def perform(app_name, app_id)
+ find_application(app_name, app_id) do |app|
+ Clusters::Applications::CheckInstallationProgressService.new(app).execute
+ end
+ end
+end
diff --git a/app/workers/concerns/cluster_applications.rb b/app/workers/concerns/cluster_applications.rb
new file mode 100644
index 00000000000..24ecaa0b52f
--- /dev/null
+++ b/app/workers/concerns/cluster_applications.rb
@@ -0,0 +1,9 @@
+module ClusterApplications
+ extend ActiveSupport::Concern
+
+ included do
+ def find_application(app_name, id, &blk)
+ Clusters::Cluster::APPLICATIONS[app_name].find(id).try(&blk)
+ end
+ end
+end
diff --git a/changelogs/unreleased/36629-35958-add-cluster-application-section.yml b/changelogs/unreleased/36629-35958-add-cluster-application-section.yml
new file mode 100644
index 00000000000..0afa53e8642
--- /dev/null
+++ b/changelogs/unreleased/36629-35958-add-cluster-application-section.yml
@@ -0,0 +1,6 @@
+---
+title: Add applications section to GKE clusters page to easily install Helm Tiller,
+ Ingress
+merge_request:
+author:
+type: added
diff --git a/config/routes/project.rb b/config/routes/project.rb
index c52dd542c90..bdafaba3ab3 100644
--- a/config/routes/project.rb
+++ b/config/routes/project.rb
@@ -191,6 +191,10 @@ constraints(ProjectUrlConstrainer.new) do
member do
get :status, format: :json
+
+ scope :applications do
+ post '/:application', to: 'clusters/applications#create', as: :install_applications
+ end
end
end
diff --git a/db/migrate/20171031100710_create_clusters_kubernetes_helm_apps.rb b/db/migrate/20171031100710_create_clusters_kubernetes_helm_apps.rb
new file mode 100644
index 00000000000..a2ce37127ea
--- /dev/null
+++ b/db/migrate/20171031100710_create_clusters_kubernetes_helm_apps.rb
@@ -0,0 +1,18 @@
+class CreateClustersKubernetesHelmApps < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ create_table :clusters_applications_helm do |t|
+ t.references :cluster, null: false, unique: true, foreign_key: { on_delete: :cascade }
+
+ t.datetime_with_timezone :created_at, null: false
+ t.datetime_with_timezone :updated_at, null: false
+
+ t.integer :status, null: false
+ t.string :version, null: false
+ t.text :status_reason
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index c4c43235f02..82e71a4dfe9 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -519,6 +519,15 @@ ActiveRecord::Schema.define(version: 20171101134435) do
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
+ t.datetime_with_timezone "updated_at", null: false
+ t.integer "status", null: false
+ t.string "version", null: false
+ t.text "status_reason"
+ end
+
create_table "container_repositories", force: :cascade do |t|
t.integer "project_id", null: false
t.string "name", null: false
@@ -1893,6 +1902,7 @@ ActiveRecord::Schema.define(version: 20171101134435) do
add_foreign_key "cluster_projects", "projects", on_delete: :cascade
add_foreign_key "cluster_providers_gcp", "clusters", on_delete: :cascade
add_foreign_key "clusters", "users", on_delete: :nullify
+ add_foreign_key "clusters_applications_helm", "clusters", on_delete: :cascade
add_foreign_key "container_repositories", "projects"
add_foreign_key "deploy_keys_projects", "projects", name: "fk_58a901ca7e", on_delete: :cascade
add_foreign_key "deployments", "projects", name: "fk_b9a3851b82", on_delete: :cascade
diff --git a/doc/user/project/clusters/img/cluster-applications.png b/doc/user/project/clusters/img/cluster-applications.png
new file mode 100644
index 00000000000..7c82d19297e
--- /dev/null
+++ b/doc/user/project/clusters/img/cluster-applications.png
Binary files differ
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/lib/gitlab/import_export/import_export.yml b/lib/gitlab/import_export/import_export.yml
index 239dca4d43f..263599831bf 100644
--- a/lib/gitlab/import_export/import_export.yml
+++ b/lib/gitlab/import_export/import_export.yml
@@ -54,7 +54,6 @@ project_tree:
- :auto_devops
- :triggers
- :pipeline_schedules
- - :cluster
- :services
- :hooks
- protected_branches:
diff --git a/lib/gitlab/import_export/relation_factory.rb b/lib/gitlab/import_export/relation_factory.rb
index 0d745e2b21f..2b34ceb5831 100644
--- a/lib/gitlab/import_export/relation_factory.rb
+++ b/lib/gitlab/import_export/relation_factory.rb
@@ -8,8 +8,6 @@ module Gitlab
triggers: 'Ci::Trigger',
pipeline_schedules: 'Ci::PipelineSchedule',
builds: 'Ci::Build',
- cluster: 'Clusters::Cluster',
- clusters: 'Clusters::Cluster',
hooks: 'ProjectHook',
merge_access_levels: 'ProtectedBranch::MergeAccessLevel',
push_access_levels: 'ProtectedBranch::PushAccessLevel',
diff --git a/lib/gitlab/kubernetes/helm.rb b/lib/gitlab/kubernetes/helm.rb
new file mode 100644
index 00000000000..7a50f07f3c5
--- /dev/null
+++ b/lib/gitlab/kubernetes/helm.rb
@@ -0,0 +1,96 @@
+module Gitlab
+ module Kubernetes
+ class Helm
+ HELM_VERSION = '2.7.0'.freeze
+ NAMESPACE = 'gitlab-managed-apps'.freeze
+ INSTALL_DEPS = <<-EOS.freeze
+ set -eo pipefail
+ apk add -U ca-certificates openssl >/dev/null
+ wget -q -O - https://kubernetes-helm.storage.googleapis.com/helm-v${HELM_VERSION}-linux-amd64.tar.gz | tar zxC /tmp >/dev/null
+ mv /tmp/linux-amd64/helm /usr/bin/
+ EOS
+
+ InstallCommand = Struct.new(:name, :install_helm, :chart) do
+ def pod_name
+ "install-#{name}"
+ end
+ end
+
+ def initialize(kubeclient)
+ @kubeclient = kubeclient
+ @namespace = Namespace.new(NAMESPACE, kubeclient)
+ end
+
+ def install(command)
+ @namespace.ensure_exists!
+ @kubeclient.create_pod(pod_resource(command))
+ end
+
+ ##
+ # Returns Pod phase
+ #
+ # https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#pod-phase
+ #
+ # values: "Pending", "Running", "Succeeded", "Failed", "Unknown"
+ #
+ def installation_status(pod_name)
+ @kubeclient.get_pod(pod_name, @namespace.name).status.phase
+ end
+
+ def installation_log(pod_name)
+ @kubeclient.get_pod_log(pod_name, @namespace.name).body
+ end
+
+ def delete_installation_pod!(pod_name)
+ @kubeclient.delete_pod(pod_name, @namespace.name)
+ end
+
+ private
+
+ def pod_resource(command)
+ labels = { 'gitlab.org/action': 'install', 'gitlab.org/application': command.name }
+ metadata = { name: command.pod_name, namespace: @namespace.name, labels: labels }
+ container = {
+ name: 'helm',
+ image: 'alpine:3.6',
+ env: generate_pod_env(command),
+ command: %w(/bin/sh),
+ args: %w(-c $(COMMAND_SCRIPT))
+ }
+ spec = { containers: [container], restartPolicy: 'Never' }
+
+ ::Kubeclient::Resource.new(metadata: metadata, spec: spec)
+ end
+
+ def generate_pod_env(command)
+ {
+ HELM_VERSION: HELM_VERSION,
+ TILLER_NAMESPACE: @namespace.name,
+ COMMAND_SCRIPT: generate_script(command)
+ }.map { |key, value| { name: key, value: value } }
+ end
+
+ def generate_script(command)
+ [
+ INSTALL_DEPS,
+ helm_init_command(command),
+ helm_install_command(command)
+ ].join("\n")
+ end
+
+ def helm_init_command(command)
+ if command.install_helm
+ 'helm init >/dev/null'
+ else
+ 'helm init --client-only >/dev/null'
+ end
+ end
+
+ def helm_install_command(command)
+ return if command.chart.nil?
+
+ "helm install #{command.chart} --name #{command.name} --namespace #{@namespace.name} >/dev/null"
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/kubernetes/namespace.rb b/lib/gitlab/kubernetes/namespace.rb
new file mode 100644
index 00000000000..c8479fbc0e8
--- /dev/null
+++ b/lib/gitlab/kubernetes/namespace.rb
@@ -0,0 +1,29 @@
+module Gitlab
+ module Kubernetes
+ class Namespace
+ attr_accessor :name
+
+ def initialize(name, client)
+ @name = name
+ @client = client
+ end
+
+ def exists?
+ @client.get_namespace(name)
+ rescue ::KubeException => ke
+ raise ke unless ke.error_code == 404
+ false
+ end
+
+ def create!
+ resource = ::Kubeclient::Resource.new(metadata: { name: name })
+
+ @client.create_namespace(resource)
+ end
+
+ def ensure_exists!
+ exists? || create!
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/kubernetes/pod.rb b/lib/gitlab/kubernetes/pod.rb
new file mode 100644
index 00000000000..f3842cdf762
--- /dev/null
+++ b/lib/gitlab/kubernetes/pod.rb
@@ -0,0 +1,12 @@
+module Gitlab
+ module Kubernetes
+ module Pod
+ PENDING = 'Pending'.freeze
+ RUNNING = 'Running'.freeze
+ SUCCEEDED = 'Succeeded'.freeze
+ FAILED = 'Failed'.freeze
+ UNKNOWN = 'Unknown'.freeze
+ PHASES = [PENDING, RUNNING, SUCCEEDED, FAILED, UNKNOWN].freeze
+ end
+ end
+end
diff --git a/spec/controllers/projects/clusters/applications_controller_spec.rb b/spec/controllers/projects/clusters/applications_controller_spec.rb
new file mode 100644
index 00000000000..8b460646059
--- /dev/null
+++ b/spec/controllers/projects/clusters/applications_controller_spec.rb
@@ -0,0 +1,85 @@
+require 'spec_helper'
+
+describe Projects::Clusters::ApplicationsController do
+ include AccessMatchersForController
+
+ def current_application
+ Clusters::Cluster::APPLICATIONS[application]
+ end
+
+ describe 'POST create' do
+ let(:cluster) { create(:cluster, :project, :provided_by_gcp) }
+ let(:project) { cluster.project }
+ let(:application) { 'helm' }
+ let(:params) { { application: application, id: cluster.id } }
+
+ describe 'functionality' do
+ let(:user) { create(:user) }
+
+ before do
+ project.add_master(user)
+ sign_in(user)
+ end
+
+ it 'schedule an application installation' do
+ expect(ClusterInstallAppWorker).to receive(:perform_async).with(application, anything).once
+
+ expect { go }.to change { current_application.count }
+ expect(response).to have_http_status(:no_content)
+ expect(cluster.application_helm).to be_scheduled
+ end
+
+ context 'when cluster do not exists' do
+ before do
+ cluster.destroy!
+ end
+
+ it 'return 404' do
+ expect { go }.not_to change { current_application.count }
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+
+ context 'when application is unknown' do
+ let(:application) { 'unkwnown-app' }
+
+ it 'return 404' do
+ go
+
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+
+ context 'when application is already installing' do
+ before do
+ create(:cluster_applications_helm, :installing, cluster: cluster)
+ end
+
+ it 'returns 400' do
+ go
+
+ expect(response).to have_http_status(:bad_request)
+ end
+ end
+ end
+
+ describe 'security' do
+ before do
+ allow(ClusterInstallAppWorker).to receive(:perform_async)
+ end
+
+ it { expect { go }.to be_allowed_for(:admin) }
+ it { expect { go }.to be_allowed_for(:owner).of(project) }
+ it { expect { go }.to be_allowed_for(:master).of(project) }
+ it { expect { go }.to be_denied_for(:developer).of(project) }
+ it { expect { go }.to be_denied_for(:reporter).of(project) }
+ it { expect { go }.to be_denied_for(:guest).of(project) }
+ it { expect { go }.to be_denied_for(:user) }
+ it { expect { go }.to be_denied_for(:external) }
+ end
+
+ def go
+ post :create, params.merge(namespace_id: project.namespace, project_id: project)
+ end
+ end
+end
diff --git a/spec/factories/clusters/applications/helm.rb b/spec/factories/clusters/applications/helm.rb
new file mode 100644
index 00000000000..fab37195113
--- /dev/null
+++ b/spec/factories/clusters/applications/helm.rb
@@ -0,0 +1,35 @@
+FactoryGirl.define do
+ factory :cluster_applications_helm, class: Clusters::Applications::Helm do
+ cluster factory: %i(cluster provided_by_gcp)
+
+ trait :not_installable do
+ status(-2)
+ end
+
+ trait :installable do
+ status 0
+ end
+
+ trait :scheduled do
+ status 1
+ end
+
+ trait :installing do
+ status 2
+ end
+
+ trait :installed do
+ status 3
+ end
+
+ trait :errored 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/features/projects/clusters_spec.rb b/spec/features/projects/clusters_spec.rb
index 368aad860e7..03d866f8bc1 100644
--- a/spec/features/projects/clusters_spec.rb
+++ b/spec/features/projects/clusters_spec.rb
@@ -50,10 +50,22 @@ feature 'Clusters', :js do
it 'user sees a cluster details page and creation status' do
expect(page).to have_content('Cluster is being created on Google Container Engine...')
+ # Application Installation buttons
+ expect(page.find(:css, '.js-cluster-application-install-button')['disabled']).to eq('true')
+ expect(page.find(:css, '.js-cluster-application-install-button').text).to eq('Install')
+
Clusters::Cluster.last.provider.make_created!
expect(page).to have_content('Cluster was successfully created on Google Container Engine')
end
+
+ it 'user sees a error if something worng during creation' do
+ expect(page).to have_content('Cluster is being created on Google Container Engine...')
+
+ Clusters::Cluster.last.provider.make_errored!('Something wrong!')
+
+ expect(page).to have_content('Something wrong!')
+ end
end
context 'when user filled form with invalid parameters' do
@@ -78,6 +90,36 @@ feature 'Clusters', :js do
it 'user sees an cluster details page' do
expect(page).to have_button('Save')
expect(page.find(:css, '.cluster-name').value).to eq(cluster.name)
+
+ # Application Installation buttons
+ expect(page.find(:css, '.js-cluster-application-install-button')['disabled']).to be_nil
+ expect(page.find(:css, '.js-cluster-application-install-button')).to have_content('Install')
+ end
+
+ context 'when user installs application: tiller' do
+ before do
+ allow(ClusterInstallAppWorker).to receive(:perform_async).and_return(nil)
+
+ page.find(:css, '.js-cluster-application-install-button').click
+ end
+
+ it 'user sees status transition' do
+ # FE sends request and gets the responce, then the buttons is "Install"
+ expect(page.find(:css, '.js-cluster-application-install-button')['disabled']).to eq('true')
+ expect(page.find(:css, '.js-cluster-application-install-button')).to have_content('Install')
+
+ Clusters::Cluster.last.application_helm.make_installing!
+
+ # FE starts pooling and update the buttons to "Installing"
+ expect(page.find(:css, '.js-cluster-application-install-button')['disabled']).to eq('true')
+ expect(page.find(:css, '.js-cluster-application-install-button')).to have_content('Installing')
+
+ Clusters::Cluster.last.application_helm.make_installed!
+
+ expect(page.find(:css, '.js-cluster-application-install-button')['disabled']).to eq('true')
+ expect(page.find(:css, '.js-cluster-application-install-button')).to have_content('Installed')
+ expect(page).to have_content('Helm Tiller was successfully installed on your cluster')
+ end
end
context 'when user disables the cluster' do
diff --git a/spec/fixtures/api/schemas/cluster_status.json b/spec/fixtures/api/schemas/cluster_status.json
index 1f255a17881..489d563be2b 100644
--- a/spec/fixtures/api/schemas/cluster_status.json
+++ b/spec/fixtures/api/schemas/cluster_status.json
@@ -1,11 +1,38 @@
{
"type": "object",
"required" : [
- "status"
+ "status",
+ "applications"
],
"properties" : {
"status": { "type": "string" },
- "status_reason": { "type": ["string", "null"] }
+ "status_reason": { "type": ["string", "null"] },
+ "applications": {
+ "type": "array",
+ "items": { "$ref": "#/definitions/application_status" }
+ }
},
- "additionalProperties": false
+ "additionalProperties": false,
+ "definitions": {
+ "application_status": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties" : {
+ "name": { "type": "string" },
+ "status": {
+ "type": {
+ "enum": [
+ "installable",
+ "scheduled",
+ "installing",
+ "installed",
+ "errored"
+ ]
+ }
+ },
+ "status_reason": { "type": ["string", "null"] }
+ },
+ "required" : [ "name", "status" ]
+ }
+ }
}
diff --git a/spec/javascripts/clusters/clusters_bundle_spec.js b/spec/javascripts/clusters/clusters_bundle_spec.js
new file mode 100644
index 00000000000..027e8001053
--- /dev/null
+++ b/spec/javascripts/clusters/clusters_bundle_spec.js
@@ -0,0 +1,257 @@
+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 .flash-text')).toBeNull();
+ });
+
+ 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 .flash-text')).toBeDefined();
+ expect(document.querySelector('.js-cluster-application-notice .flash-text').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 .flash-text')).toBeDefined();
+ expect(document.querySelector('.js-cluster-application-notice .flash-text').textContent.trim()).toEqual('Helm Tiller, Ingress was successfully installed on your cluster');
+ });
+ });
+
+ describe('updateContainer', () => {
+ describe('when creating cluster', () => {
+ it('should show the creating container', () => {
+ 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'),
+ ).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(null, 'created');
+
+ expect(
+ cluster.creatingContainer.classList.contains('hidden'),
+ ).toBeTruthy();
+ expect(
+ cluster.successContainer.classList.contains('hidden'),
+ ).toBeFalsy();
+ expect(
+ 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(null, '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');
+ });
+
+ 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();
+ });
+ });
+ });
+
+ 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..e671c18e1a5
--- /dev/null
+++ b/spec/javascripts/clusters/components/application_row_spec.js
@@ -0,0 +1,237 @@
+import Vue from 'vue';
+import eventHub from '~/clusters/event_hub';
+import {
+ APPLICATION_NOT_INSTALLABLE,
+ APPLICATION_SCHEDULED,
+ 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 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,
+ });
+
+ expect(vm.installButtonLabel).toEqual('Install');
+ expect(vm.installButtonLoading).toEqual(false);
+ expect(vm.installButtonDisabled).toEqual(false);
+ });
+
+ 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,
+ });
+
+ expect(vm.installButtonLabel).toEqual('Installing');
+ expect(vm.installButtonLoading).toEqual(true);
+ expect(vm.installButtonDisabled).toEqual(true);
+ });
+
+ it('has disabled "Installed" when APPLICATION_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 enabled "Install" when APPLICATION_ERROR', () => {
+ vm = mountComponent(ApplicationRow, {
+ ...DEFAULT_APPLICATION_STATE,
+ status: APPLICATION_ERROR,
+ });
+
+ expect(vm.installButtonLabel).toEqual('Install');
+ expect(vm.installButtonLoading).toEqual(false);
+ expect(vm.installButtonDisabled).toEqual(false);
+ });
+
+ it('has loading "Install" when REQUEST_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 REQUEST_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 REQUEST_FAILURE (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 APPLICATION_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 REQUEST_FAILURE', () => {
+ 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..cb8b3d38e2e
--- /dev/null
+++ b/spec/javascripts/clusters/stores/clusters_store_spec.js
@@ -0,0 +1,89 @@
+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: {
+ 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,
+ 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');
- });
- });
- });
-});
diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml
index 50786ac6324..bf1e97654e5 100644
--- a/spec/lib/gitlab/import_export/all_models.yml
+++ b/spec/lib/gitlab/import_export/all_models.yml
@@ -147,19 +147,6 @@ deploy_keys:
- user
- deploy_keys_projects
- projects
-cluster:
-- cluster_projects
-- projects
-- user
-- provider_gcp
-- platform_kubernetes
-cluster_projects:
-- projects
-- clusters
-provider_gcp:
-- cluster
-platform_kubernetes:
-- cluster
services:
- project
- service_hook
@@ -191,6 +178,7 @@ project:
- tags
- chat_services
- cluster
+- clusters
- cluster_project
- creator
- group
@@ -300,4 +288,4 @@ push_event_payload:
- event
issue_assignees:
- issue
-- assignee \ No newline at end of file
+- assignee
diff --git a/spec/lib/gitlab/kubernetes/helm_spec.rb b/spec/lib/gitlab/kubernetes/helm_spec.rb
new file mode 100644
index 00000000000..15f99b0401f
--- /dev/null
+++ b/spec/lib/gitlab/kubernetes/helm_spec.rb
@@ -0,0 +1,100 @@
+require 'spec_helper'
+
+describe Gitlab::Kubernetes::Helm do
+ let(:client) { double('kubernetes client') }
+ let(:helm) { described_class.new(client) }
+ let(:namespace) { Gitlab::Kubernetes::Namespace.new(described_class::NAMESPACE, client) }
+ let(:install_helm) { true }
+ let(:chart) { 'stable/a_chart' }
+ let(:application_name) { 'app_name' }
+ let(:command) { Gitlab::Kubernetes::Helm::InstallCommand.new(application_name, install_helm, chart) }
+ subject { helm }
+
+ before do
+ allow(Gitlab::Kubernetes::Namespace).to receive(:new).with(described_class::NAMESPACE, client).and_return(namespace)
+ end
+
+ describe '#initialize' do
+ it 'creates a namespace object' do
+ expect(Gitlab::Kubernetes::Namespace).to receive(:new).with(described_class::NAMESPACE, client)
+
+ subject
+ end
+ end
+
+ describe '#install' do
+ before do
+ allow(client).to receive(:create_pod).and_return(nil)
+ allow(namespace).to receive(:ensure_exists!).once
+ end
+
+ it 'ensures the namespace exists before creating the POD' do
+ expect(namespace).to receive(:ensure_exists!).once.ordered
+ expect(client).to receive(:create_pod).once.ordered
+
+ subject.install(command)
+ end
+ end
+
+ describe '#installation_status' do
+ let(:phase) { Gitlab::Kubernetes::Pod::RUNNING }
+ let(:pod) { Kubeclient::Resource.new(status: { phase: phase }) } # partial representation
+
+ it 'fetches POD phase from kubernetes cluster' do
+ expect(client).to receive(:get_pod).with(command.pod_name, described_class::NAMESPACE).once.and_return(pod)
+
+ expect(subject.installation_status(command.pod_name)).to eq(phase)
+ end
+ end
+
+ describe '#installation_log' do
+ let(:log) { 'some output' }
+ let(:response) { RestClient::Response.new(log) }
+
+ it 'fetches POD phase from kubernetes cluster' do
+ expect(client).to receive(:get_pod_log).with(command.pod_name, described_class::NAMESPACE).once.and_return(response)
+
+ expect(subject.installation_log(command.pod_name)).to eq(log)
+ end
+ end
+
+ describe '#delete_installation_pod!' do
+ it 'deletes the POD from kubernetes cluster' do
+ expect(client).to receive(:delete_pod).with(command.pod_name, described_class::NAMESPACE).once
+
+ subject.delete_installation_pod!(command.pod_name)
+ end
+ end
+
+ describe '#helm_init_command' do
+ subject { helm.send(:helm_init_command, command) }
+
+ context 'when command.install_helm is true' do
+ let(:install_helm) { true }
+
+ it { is_expected.to eq('helm init >/dev/null') }
+ end
+
+ context 'when command.install_helm is false' do
+ let(:install_helm) { false }
+
+ it { is_expected.to eq('helm init --client-only >/dev/null') }
+ end
+ end
+
+ describe '#helm_install_command' do
+ subject { helm.send(:helm_install_command, command) }
+
+ context 'when command.chart is nil' do
+ let(:chart) { nil }
+
+ it { is_expected.to be_nil }
+ end
+
+ context 'when command.chart is set' do
+ let(:chart) { 'stable/a_chart' }
+
+ it { is_expected.to eq("helm install #{chart} --name #{application_name} --namespace #{namespace.name} >/dev/null")}
+ end
+ end
+end
diff --git a/spec/lib/gitlab/kubernetes/namespace_spec.rb b/spec/lib/gitlab/kubernetes/namespace_spec.rb
new file mode 100644
index 00000000000..b3c987f9344
--- /dev/null
+++ b/spec/lib/gitlab/kubernetes/namespace_spec.rb
@@ -0,0 +1,66 @@
+require 'spec_helper'
+
+describe Gitlab::Kubernetes::Namespace do
+ let(:name) { 'a_namespace' }
+ let(:client) { double('kubernetes client') }
+ subject { described_class.new(name, client) }
+
+ it { expect(subject.name).to eq(name) }
+
+ describe '#exists?' do
+ context 'when namespace do not exits' do
+ let(:exception) { ::KubeException.new(404, "namespace #{name} not found", nil) }
+
+ it 'returns false' do
+ expect(client).to receive(:get_namespace).with(name).once.and_raise(exception)
+
+ expect(subject.exists?).to be_falsey
+ end
+ end
+
+ context 'when namespace exits' do
+ let(:namespace) { ::Kubeclient::Resource.new(kind: 'Namespace', metadata: { name: name }) } # partial representation
+
+ it 'returns true' do
+ expect(client).to receive(:get_namespace).with(name).once.and_return(namespace)
+
+ expect(subject.exists?).to be_truthy
+ end
+ end
+
+ context 'when cluster cannot be reached' do
+ let(:exception) { Errno::ECONNREFUSED.new }
+
+ it 'raises exception' do
+ expect(client).to receive(:get_namespace).with(name).once.and_raise(exception)
+
+ expect { subject.exists? }.to raise_error(exception)
+ end
+ end
+ end
+
+ describe '#create!' do
+ it 'creates a namespace' do
+ matcher = have_attributes(metadata: have_attributes(name: name))
+ expect(client).to receive(:create_namespace).with(matcher).once
+
+ expect { subject.create! }.not_to raise_error
+ end
+ end
+
+ describe '#ensure_exists!' do
+ it 'checks for existing namespace before creating' do
+ expect(subject).to receive(:exists?).once.ordered.and_return(false)
+ expect(subject).to receive(:create!).once.ordered
+
+ subject.ensure_exists!
+ end
+
+ it 'do not re-create an existing namespace' do
+ expect(subject).to receive(:exists?).once.and_return(true)
+ expect(subject).not_to receive(:create!)
+
+ subject.ensure_exists!
+ end
+ end
+end
diff --git a/spec/models/clusters/applications/helm_spec.rb b/spec/models/clusters/applications/helm_spec.rb
new file mode 100644
index 00000000000..f8855079842
--- /dev/null
+++ b/spec/models/clusters/applications/helm_spec.rb
@@ -0,0 +1,102 @@
+require 'rails_helper'
+
+describe Clusters::Applications::Helm do
+ it { is_expected.to belong_to(:cluster) }
+ it { is_expected.to validate_presence_of(:cluster) }
+
+ describe '#name' do
+ it 'is .application_name' do
+ expect(subject.name).to eq(described_class.application_name)
+ end
+
+ it 'is recorded in Clusters::Cluster::APPLICATIONS' do
+ expect(Clusters::Cluster::APPLICATIONS[subject.name]).to eq(described_class)
+ end
+ end
+
+ describe '#version' do
+ it 'defaults to Gitlab::Kubernetes::Helm::HELM_VERSION' do
+ expect(subject.version).to eq(Gitlab::Kubernetes::Helm::HELM_VERSION)
+ end
+ end
+
+ describe '#status' do
+ let(:cluster) { create(:cluster) }
+
+ subject { described_class.new(cluster: cluster) }
+
+ it 'defaults to :not_installable' do
+ expect(subject.status_name).to be(:not_installable)
+ end
+
+ context 'when platform kubernetes is defined' do
+ let(:cluster) { create(:cluster, :provided_by_gcp) }
+
+ it 'defaults to :installable' do
+ expect(subject.status_name).to be(:installable)
+ end
+ end
+ end
+
+ describe '#install_command' do
+ it 'has all the needed information' do
+ expect(subject.install_command).to have_attributes(name: subject.name, install_helm: true, chart: nil)
+ end
+ end
+
+ describe 'status state machine' do
+ describe '#make_installing' do
+ subject { create(:cluster_applications_helm, :scheduled) }
+
+ it 'is installing' do
+ subject.make_installing!
+
+ expect(subject).to be_installing
+ end
+ end
+
+ describe '#make_installed' do
+ subject { create(:cluster_applications_helm, :installing) }
+
+ it 'is installed' do
+ subject.make_installed
+
+ expect(subject).to be_installed
+ end
+ end
+
+ describe '#make_errored' do
+ subject { create(:cluster_applications_helm, :installing) }
+ let(:reason) { 'some errors' }
+
+ it 'is errored' do
+ subject.make_errored(reason)
+
+ expect(subject).to be_errored
+ expect(subject.status_reason).to eq(reason)
+ end
+ end
+
+ describe '#make_scheduled' do
+ subject { create(:cluster_applications_helm, :installable) }
+
+ it 'is scheduled' do
+ subject.make_scheduled
+
+ expect(subject).to be_scheduled
+ end
+
+ describe 'when was errored' do
+ subject { create(:cluster_applications_helm, :errored) }
+
+ it 'clears #status_reason' do
+ expect(subject.status_reason).not_to be_nil
+
+ subject.make_scheduled!
+
+ expect(subject.status_reason).to be_nil
+ end
+ end
+ end
+ end
+end
diff --git a/spec/models/project_services/kubernetes_service_spec.rb b/spec/models/project_services/kubernetes_service_spec.rb
index 7617e1f89b1..1c629155e1e 100644
--- a/spec/models/project_services/kubernetes_service_spec.rb
+++ b/spec/models/project_services/kubernetes_service_spec.rb
@@ -7,7 +7,7 @@ describe KubernetesService, :use_clean_rails_memory_store_caching do
let(:project) { build_stubbed(:kubernetes_project) }
let(:service) { project.kubernetes_service }
- describe "Associations" do
+ describe 'Associations' do
it { is_expected.to belong_to :project }
end
diff --git a/spec/serializers/cluster_application_entity_spec.rb b/spec/serializers/cluster_application_entity_spec.rb
new file mode 100644
index 00000000000..87c7b2ad36e
--- /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(:cluster_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(:not_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(:cluster_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 72f02131211..d6a43fd0f00 100644
--- a/spec/serializers/cluster_entity_spec.rb
+++ b/spec/serializers/cluster_entity_spec.rb
@@ -29,10 +29,23 @@ describe ClusterEntity do
context 'when provider type is user' do
let(:cluster) { create(:cluster, provider_type: :user) }
- it 'has nil' do
- expect(subject[:status]).to be_nil
+ it 'has corresponded data' do
+ expect(subject[:status]).to eq(:created)
expect(subject[:status_reason]).to be_nil
end
end
+
+ context 'when no application has been installed' do
+ let(:cluster) { create(:cluster) }
+ subject { described_class.new(cluster).as_json[:applications]}
+
+ it 'contains helm as not_installable' do
+ expect(subject).not_to be_empty
+
+ helm = subject[0]
+ expect(helm[:name]).to eq('helm')
+ expect(helm[:status]).to eq(:not_installable)
+ end
+ end
end
end
diff --git a/spec/serializers/cluster_serializer_spec.rb b/spec/serializers/cluster_serializer_spec.rb
index ff7d1789149..5e9f7a45891 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(:cluster_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
@@ -17,7 +17,7 @@ describe ClusterSerializer do
let(:cluster) { create(:cluster, provider_type: :user) }
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
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..75fc05d36e9
--- /dev/null
+++ b/spec/services/clusters/applications/check_installation_progress_service_spec.rb
@@ -0,0 +1,91 @@
+require 'spec_helper'
+
+describe Clusters::Applications::CheckInstallationProgressService do
+ RESCHEDULE_PHASES = Gitlab::Kubernetes::Pod::PHASES - [Gitlab::Kubernetes::Pod::SUCCEEDED, Gitlab::Kubernetes::Pod::FAILED].freeze
+
+ let(:application) { create(:cluster_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 'removes the installation POD' do
+ expect(service).to receive(:remove_installation_pod).once
+
+ service.execute
+ end
+ 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(:remove_installation_pod)
+
+ service.execute
+
+ expect(application).to be_installing
+ expect(application.status_reason).to be_nil
+ end
+ end
+
+ context 'when timeouted' do
+ let(:application) { create(:cluster_applications_helm, :timeouted) }
+
+ it_behaves_like 'a terminated installation'
+
+ 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
+
+ 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(:remove_installation_pod).and_return(nil)
+ end
+
+ describe '#execute' do
+ context 'when installation POD succeeded' do
+ let(:phase) { Gitlab::Kubernetes::Pod::SUCCEEDED }
+
+ it_behaves_like 'a terminated installation'
+
+ it 'make the application installed' do
+ expect(ClusterWaitForAppInstallationWorker).not_to receive(:perform_in)
+
+ service.execute
+
+ expect(application).to be_installed
+ expect(application.status_reason).to be_nil
+ end
+ end
+
+ context 'when installation POD failed' do
+ let(:phase) { Gitlab::Kubernetes::Pod::FAILED }
+ let(:errors) { 'test installation failed' }
+
+ it_behaves_like 'a terminated installation'
+
+ it 'make the application errored' do
+ service.execute
+
+ expect(application).to be_errored
+ expect(application.status_reason).to eq(errors)
+ end
+ end
+
+ RESCHEDULE_PHASES.each { |phase| it_behaves_like 'a not yet terminated installation', phase }
+ 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..054a49ffedf
--- /dev/null
+++ b/spec/services/clusters/applications/install_service_spec.rb
@@ -0,0 +1,60 @@
+require 'spec_helper'
+
+describe Clusters::Applications::InstallService do
+ describe '#execute' do
+ let(:application) { create(:cluster_applications_helm, :scheduled) }
+ let(:service) { described_class.new(application) }
+ let(:helm_client) { instance_double(Gitlab::Kubernetes::Helm) }
+
+ before do
+ allow(service).to receive(:helm_api).and_return(helm_client)
+ end
+
+ context 'when there are no errors' do
+ before do
+ expect(helm_client).to receive(:install).with(application.install_command)
+ allow(ClusterWaitForAppInstallationWorker).to receive(:perform_in).and_return(nil)
+ end
+
+ it 'make the application installing' do
+ expect(application.cluster).not_to be_nil
+ 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(helm_client).to receive(:install).with(application.install_command).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(:cluster_applications_helm, :scheduled) }
+
+ it 'make the application errored' do
+ expect(application).to receive(:make_installing!).once.and_raise(ActiveRecord::RecordInvalid)
+ expect(helm_client).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..cf95361c935
--- /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(:cluster_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
diff --git a/spec/support/matchers/access_matchers_for_controller.rb b/spec/support/matchers/access_matchers_for_controller.rb
index bb6b7c63ee9..cdb62a5deee 100644
--- a/spec/support/matchers/access_matchers_for_controller.rb
+++ b/spec/support/matchers/access_matchers_for_controller.rb
@@ -5,7 +5,7 @@ module AccessMatchersForController
extend RSpec::Matchers::DSL
include Warden::Test::Helpers
- EXPECTED_STATUS_CODE_ALLOWED = [200, 201, 302].freeze
+ EXPECTED_STATUS_CODE_ALLOWED = [200, 201, 204, 302].freeze
EXPECTED_STATUS_CODE_DENIED = [401, 404].freeze
def emulate_user(role, membership = nil)