summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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.vue114
-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/framework/wells.scss31
-rw-r--r--app/assets/stylesheets/pages/clusters.scss5
-rw-r--r--app/controllers/projects/clusters/applications_controller.rb25
-rw-r--r--app/controllers/projects/commit_controller.rb12
-rw-r--r--app/helpers/commits_helper.rb26
-rw-r--r--app/models/clusters/applications/helm.rb35
-rw-r--r--app/models/clusters/applications/ingress.rb44
-rw-r--r--app/models/clusters/cluster.rb31
-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.haml17
-rw-r--r--app/views/projects/commit/_commit_box.html.haml2
-rw-r--r--app/views/projects/commit/_limit_exceeded_message.html.haml8
-rw-r--r--app/views/projects/commit/branches.html.haml26
-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--changelogs/unreleased/37824-many-branches-lock-server.yml6
-rw-r--r--changelogs/unreleased/add-ingress-to-cluster-applications.yml5
-rw-r--r--changelogs/unreleased/add-typescript.yml5
-rw-r--r--changelogs/unreleased/feature-hashed-storage-repo-import.yml5
-rw-r--r--config/karma.config.js9
-rw-r--r--config/routes/project.rb4
-rw-r--r--config/webpack.config.js6
-rw-r--r--db/migrate/20171031100710_create_clusters_kubernetes_helm_apps.rb18
-rw-r--r--db/migrate/20171106101200_create_clusters_kubernetes_ingress_apps.rb21
-rw-r--r--db/schema.rb23
-rw-r--r--doc/development/ux_guide/components.md33
-rw-r--r--doc/development/ux_guide/img/modals-general-confimation-dialog.pngbin0 -> 51205 bytes
-rw-r--r--doc/development/ux_guide/img/modals-layout-for-modals.pngbin0 -> 68203 bytes
-rw-r--r--doc/development/ux_guide/img/modals-special-confimation-dialog.pngbin0 -> 89978 bytes
-rw-r--r--doc/development/ux_guide/img/modals-three-buttons.pngbin0 -> 54927 bytes
-rw-r--r--doc/raketasks/import.md47
-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--doc/user/project/container_registry.md3
-rw-r--r--lib/gitlab/bare_repository_import/importer.rb101
-rw-r--r--lib/gitlab/bare_repository_import/repository.rb42
-rw-r--r--lib/gitlab/bare_repository_importer.rb97
-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--lib/tasks/gitlab/import.rake14
-rw-r--r--package.json2
-rw-r--r--spec/controllers/projects/clusters/applications_controller_spec.rb85
-rw-r--r--spec/controllers/projects/commit_controller_spec.rb26
-rw-r--r--spec/factories/clusters/applications/helm.rb35
-rw-r--r--spec/factories/clusters/applications/ingress.rb35
-rw-r--r--spec/features/projects/clusters_spec.rb86
-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/bare_repository_import/importer_spec.rb168
-rw-r--r--spec/lib/gitlab/bare_repository_import/repository_spec.rb51
-rw-r--r--spec/lib/gitlab/bare_repository_importer_spec.rb100
-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/clusters/applications/ingress_spec.rb108
-rw-r--r--spec/models/clusters/cluster_spec.rb21
-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/features/discussion_comments_shared_example.rb2
-rw-r--r--spec/support/matchers/access_matchers_for_controller.rb2
-rw-r--r--spec/views/projects/commit/branches.html.haml_spec.rb109
-rw-r--r--tsconfig.json8
-rw-r--r--vendor/gitignore/Composer.gitignore2
-rw-r--r--vendor/gitignore/Global/Windows.gitignore2
-rw-r--r--vendor/gitignore/Terraform.gitignore15
-rw-r--r--vendor/gitignore/VisualStudio.gitignore6
-rw-r--r--vendor/gitlab-ci-yml/Auto-DevOps.gitlab-ci.yml4
-rw-r--r--vendor/gitlab-ci-yml/OpenShift.gitlab-ci.yml6
-rw-r--r--vendor/gitlab-ci-yml/Rust.gitlab-ci.yml6
-rw-r--r--vendor/gitlab-ci-yml/dotNET.gitlab-ci.yml86
-rw-r--r--yarn.lock195
108 files changed, 3684 insertions, 664 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..dc443475952
--- /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,
+ installIngressEndpoint: 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..e5ae439d26e
--- /dev/null
+++ b/app/assets/javascripts/clusters/components/applications.vue
@@ -0,0 +1,114 @@
+<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"
+ />
+ <application-row
+ id="ingress"
+ :title="applications.ingress.title"
+ title-link="https://kubernetes.io/docs/concepts/services-networking/ingress/"
+ :description="ingressDescription"
+ :status="applications.ingress.status"
+ :status-reason="applications.ingress.statusReason"
+ :request-status="applications.ingress.requestStatus"
+ :request-reason="applications.ingress.requestReason"
+ />
+ <!-- NOTE: Don't forget to update `clusters.scss` min-height for this block and uncomment `application_spec` tests -->
+ <!-- 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/framework/wells.scss b/app/assets/stylesheets/framework/wells.scss
index 5f9756bf58a..68824ff8418 100644
--- a/app/assets/stylesheets/framework/wells.scss
+++ b/app/assets/stylesheets/framework/wells.scss
@@ -52,6 +52,37 @@
.label.label-gray {
background-color: $well-expand-item;
}
+
+ .branches {
+ display: inline;
+ }
+
+ .branch-link {
+ margin-bottom: 2px;
+ }
+
+ .limit-box {
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ background-color: $red-100;
+ border-radius: $border-radius-default;
+ text-align: center;
+
+ &:hover {
+ background-color: $red-200;
+ }
+
+ .limit-icon {
+ margin: 0 8px;
+ }
+
+ .limit-message {
+ line-height: 16px;
+ margin-right: 8px;
+ font-size: 12px;
+ }
+ }
}
.light-well {
diff --git a/app/assets/stylesheets/pages/clusters.scss b/app/assets/stylesheets/pages/clusters.scss
index 5c91579c69c..e5b9e1f2de6 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: 302px;
+}
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/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb
index a62f05db7db..494d412b532 100644
--- a/app/controllers/projects/commit_controller.rb
+++ b/app/controllers/projects/commit_controller.rb
@@ -16,6 +16,8 @@ class Projects::CommitController < Projects::ApplicationController
before_action :define_note_vars, only: [:show, :diff_for_path]
before_action :authorize_edit_tree!, only: [:revert, :cherry_pick]
+ BRANCH_SEARCH_LIMIT = 1000
+
def show
apply_diff_view_cookie!
@@ -56,8 +58,14 @@ class Projects::CommitController < Projects::ApplicationController
end
def branches
- @branches = @project.repository.branch_names_contains(commit.id)
- @tags = @project.repository.tag_names_contains(commit.id)
+ # branch_names_contains/tag_names_contains can take a long time when there are thousands of
+ # branches/tags - each `git branch --contains xxx` request can consume a cpu core.
+ # so only do the query when there are a manageable number of branches/tags
+ @branches_limit_exceeded = @project.repository.branch_count > BRANCH_SEARCH_LIMIT
+ @branches = @branches_limit_exceeded ? [] : @project.repository.branch_names_contains(commit.id)
+
+ @tags_limit_exceeded = @project.repository.tag_count > BRANCH_SEARCH_LIMIT
+ @tags = @tags_limit_exceeded ? [] : @project.repository.tag_names_contains(commit.id)
render layout: false
end
diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb
index ef22cafc2e2..f9a666fa1e6 100644
--- a/app/helpers/commits_helper.rb
+++ b/app/helpers/commits_helper.rb
@@ -60,23 +60,33 @@ module CommitsHelper
branches.include?(project.default_branch) ? branches.delete(project.default_branch) : branches.pop
end
+ # Returns a link formatted as a commit branch link
+ def commit_branch_link(url, text)
+ link_to(url, class: 'label label-gray ref-name branch-link') do
+ icon('code-fork') + " #{text}"
+ end
+ end
+
# Returns the sorted alphabetically links to branches, separated by a comma
def commit_branches_links(project, branches)
branches.sort.map do |branch|
- link_to(project_ref_path(project, branch), class: "label label-gray ref-name") do
- icon('code-fork') + " #{branch}"
- end
- end.join(" ").html_safe
+ commit_branch_link(project_ref_path(project, branch), branch)
+ end.join(' ').html_safe
+ end
+
+ # Returns a link formatted as a commit tag link
+ def commit_tag_link(url, text)
+ link_to(url, class: 'label label-gray ref-name') do
+ icon('tag') + " #{text}"
+ end
end
# Returns the sorted links to tags, separated by a comma
def commit_tags_links(project, tags)
sorted = VersionSorter.rsort(tags)
sorted.map do |tag|
- link_to(project_ref_path(project, tag), class: "label label-gray ref-name") do
- icon('tag') + " #{tag}"
- end
- end.join(" ").html_safe
+ commit_tag_link(project_ref_path(project, tag), tag)
+ end.join(' ').html_safe
end
def link_to_browse_code(project, commit)
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/applications/ingress.rb b/app/models/clusters/applications/ingress.rb
new file mode 100644
index 00000000000..44bd979741e
--- /dev/null
+++ b/app/models/clusters/applications/ingress.rb
@@ -0,0 +1,44 @@
+module Clusters
+ module Applications
+ class Ingress < ActiveRecord::Base
+ self.table_name = 'clusters_applications_ingress'
+
+ include ::Clusters::Concerns::ApplicationStatus
+
+ belongs_to :cluster, class_name: 'Clusters::Cluster', foreign_key: :cluster_id
+
+ validates :cluster, presence: true
+
+ default_value_for :ingress_type, :nginx
+ default_value_for :version, :nginx
+
+ after_initialize :set_initial_status
+
+ enum ingress_type: {
+ nginx: 1
+ }
+
+ def self.application_name
+ self.to_s.demodulize.underscore
+ end
+
+ def set_initial_status
+ return unless not_installable?
+
+ self.status = 'installable' if cluster&.application_helm_installed?
+ end
+
+ def name
+ self.class.application_name
+ end
+
+ def chart
+ 'stable/nginx-ingress'
+ end
+
+ def install_command
+ Gitlab::Kubernetes::Helm::InstallCommand.new(name, false, chart)
+ end
+ end
+ end
+end
diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb
index 955dba51745..185d9473aab 100644
--- a/app/models/clusters/cluster.rb
+++ b/app/models/clusters/cluster.rb
@@ -4,6 +4,11 @@ module Clusters
self.table_name = 'clusters'
+ APPLICATIONS = {
+ Applications::Helm.application_name => Applications::Helm,
+ Applications::Ingress.application_name => Applications::Ingress
+ }.freeze
+
belongs_to :user
has_many :cluster_projects, class_name: 'Clusters::Project'
@@ -15,6 +20,9 @@ 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'
+ has_one :application_ingress, class_name: 'Clusters::Applications::Ingress'
+
accepts_nested_attributes_for :provider_gcp, update_only: true
accepts_nested_attributes_for :platform_kubernetes, update_only: true
@@ -28,10 +36,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 +54,21 @@ 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,
+ application_ingress || build_application_ingress
+ ]
+ end
+
def provider
return provider_gcp if gcp?
end
@@ -59,6 +84,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..b7671f5e3c4 100644
--- a/app/views/projects/clusters/show.html.haml
+++ b/app/views/projects/clusters/show.html.haml
@@ -4,11 +4,18 @@
- 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),
+ install_ingress_path: install_applications_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster, :ingress),
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 +56,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 +68,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/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml
index 3f4415f9e5e..8b9c1bbb602 100644
--- a/app/views/projects/commit/_commit_box.html.haml
+++ b/app/views/projects/commit/_commit_box.html.haml
@@ -61,7 +61,7 @@
%span.cgray= n_('parent', 'parents', @commit.parents.count)
- @commit.parents.each do |parent|
= link_to parent.short_id, project_commit_path(@project, parent), class: "commit-sha"
- %span.commit-info.branches
+ .commit-info.branches
%i.fa.fa-spinner.fa-spin
- if @commit.last_pipeline
diff --git a/app/views/projects/commit/_limit_exceeded_message.html.haml b/app/views/projects/commit/_limit_exceeded_message.html.haml
new file mode 100644
index 00000000000..84a52d49487
--- /dev/null
+++ b/app/views/projects/commit/_limit_exceeded_message.html.haml
@@ -0,0 +1,8 @@
+.has-tooltip{ class: "limit-box limit-box-#{objects} prepend-left-5", data: { title: "Project has too many #{label_for_message} to search"} }
+ .limit-icon
+ - if objects == :branch
+ = icon('code-fork')
+ - else
+ = icon('tag')
+ .limit-message
+ %span #{label_for_message.capitalize} unavailable
diff --git a/app/views/projects/commit/branches.html.haml b/app/views/projects/commit/branches.html.haml
index 911c9ddce06..8611129b356 100644
--- a/app/views/projects/commit/branches.html.haml
+++ b/app/views/projects/commit/branches.html.haml
@@ -1,15 +1,15 @@
-- if @branches.any? || @tags.any?
+- if @branches_limit_exceeded
+ = render 'limit_exceeded_message', objects: :branch, label_for_message: "branches"
+- elsif @branches.any?
- branch = commit_default_branch(@project, @branches)
- = link_to(project_ref_path(@project, branch), class: "label label-gray ref-name") do
- = icon('code-fork')
- = branch
+ = commit_branch_link(project_ref_path(@project, branch), branch)
- -# `commit_default_branch` deletes the default branch from `@branches`,
- -# so only render this if we have more branches left
- - if @branches.any? || @tags.any?
- %span
- = link_to "…", "#", class: "js-details-expand label label-gray"
-
- %span.js-details-content.hide
- = commit_branches_links(@project, @branches) if @branches.any?
- = commit_tags_links(@project, @tags) if @tags.any?
+- if @branches.any? || @tags.any? || @tags_limit_exceeded
+ %span
+ = link_to "…", "#", class: "js-details-expand label label-gray"
+ %span.js-details-content.hide
+ = commit_branches_links(@project, @branches)
+ - if @tags_limit_exceeded
+ = render 'limit_exceeded_message', objects: :tag, label_for_message: "tags"
+ - else
+ = commit_tags_links(@project, @tags)
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/changelogs/unreleased/37824-many-branches-lock-server.yml b/changelogs/unreleased/37824-many-branches-lock-server.yml
new file mode 100644
index 00000000000..f75f79ec4a0
--- /dev/null
+++ b/changelogs/unreleased/37824-many-branches-lock-server.yml
@@ -0,0 +1,6 @@
+---
+title: While displaying a commit, do not show list of related branches if there are
+ thousands of branches
+merge_request: 14812
+author:
+type: other
diff --git a/changelogs/unreleased/add-ingress-to-cluster-applications.yml b/changelogs/unreleased/add-ingress-to-cluster-applications.yml
new file mode 100644
index 00000000000..0064e8672f8
--- /dev/null
+++ b/changelogs/unreleased/add-ingress-to-cluster-applications.yml
@@ -0,0 +1,5 @@
+---
+title: Add Ingress to available Cluster applications
+merge_request:
+author:
+type: added
diff --git a/changelogs/unreleased/add-typescript.yml b/changelogs/unreleased/add-typescript.yml
new file mode 100644
index 00000000000..a048f240855
--- /dev/null
+++ b/changelogs/unreleased/add-typescript.yml
@@ -0,0 +1,5 @@
+---
+title: Adds typescript support
+merge_request:
+author:
+type: added
diff --git a/changelogs/unreleased/feature-hashed-storage-repo-import.yml b/changelogs/unreleased/feature-hashed-storage-repo-import.yml
new file mode 100644
index 00000000000..73c16a99053
--- /dev/null
+++ b/changelogs/unreleased/feature-hashed-storage-repo-import.yml
@@ -0,0 +1,5 @@
+---
+title: Improve GitLab Import rake task to work with Hashed Storage and Subgroups
+merge_request:
+author:
+type: changed
diff --git a/config/karma.config.js b/config/karma.config.js
index e459f5cdac3..9f018d14b8f 100644
--- a/config/karma.config.js
+++ b/config/karma.config.js
@@ -67,14 +67,5 @@ module.exports = function(config) {
karmaConfig.customLaunchers.ChromeHeadlessCustom.flags.push('--enable-logging', '--v=1');
}
- if (process.env.DEBUG) {
- karmaConfig.logLevel = config.LOG_DEBUG;
- process.env.CHROME_LOG_FILE = process.env.CHROME_LOG_FILE || 'chrome_debug.log';
- }
-
- if (process.env.CHROME_LOG_FILE) {
- karmaConfig.customLaunchers.ChromeHeadlessCustom.flags.push('--enable-logging', '--v=1');
- }
-
config.set(karmaConfig);
};
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/config/webpack.config.js b/config/webpack.config.js
index f7a7182a627..67d7cae3ccf 100644
--- a/config/webpack.config.js
+++ b/config/webpack.config.js
@@ -108,6 +108,10 @@ var config = {
loader: 'vue-loader',
},
{
+ test: /\.ts$/,
+ loader: 'ts-loader',
+ },
+ {
test: /\.svg$/,
loader: 'raw-loader',
},
@@ -252,7 +256,7 @@ var config = {
],
resolve: {
- extensions: ['.js'],
+ extensions: ['.js', '.ts'],
alias: {
'~': path.join(ROOT_PATH, 'app/assets/javascripts'),
'emojis': path.join(ROOT_PATH, 'fixtures/emojis'),
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/migrate/20171106101200_create_clusters_kubernetes_ingress_apps.rb b/db/migrate/20171106101200_create_clusters_kubernetes_ingress_apps.rb
new file mode 100644
index 00000000000..21f48b1d1b4
--- /dev/null
+++ b/db/migrate/20171106101200_create_clusters_kubernetes_ingress_apps.rb
@@ -0,0 +1,21 @@
+class CreateClustersKubernetesIngressApps < ActiveRecord::Migration
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def change
+ create_table :clusters_applications_ingress 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.integer :ingress_type, null: false
+
+ t.string :version, null: false
+ t.string :cluster_ip
+ t.text :status_reason
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index c4c43235f02..c60cb729b75 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -11,7 +11,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema.define(version: 20171101134435) do
+ActiveRecord::Schema.define(version: 20171106101200) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -519,6 +519,26 @@ 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 "clusters_applications_ingress", 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.integer "ingress_type", null: false
+ t.string "version", null: false
+ t.string "cluster_ip"
+ 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 +1913,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/development/ux_guide/components.md b/doc/development/ux_guide/components.md
index fa31c496b30..16a811dbc74 100644
--- a/doc/development/ux_guide/components.md
+++ b/doc/development/ux_guide/components.md
@@ -10,6 +10,7 @@
* [Tables](#tables)
* [Blocks](#blocks)
* [Panels](#panels)
+* [Dialog modals](#dialog-modals)
* [Alerts](#alerts)
* [Forms](#forms)
* [Search box](#search-box)
@@ -254,6 +255,38 @@ Skeleton loading can replace any existing UI elements for the period in which th
---
+## Dialog modals
+
+Dialog modals are only used for having a conversation and confirmation with the user. The user is not able to access the features on the main page until closing the modal.
+
+### Usage
+
+* When the action is irreversible, dialog modals provide the details and confirm with the user before they take an advanced action.
+* When the action will affect privacy or authorization, dialog modals provide advanced information and confirm with the user.
+
+### Style
+
+* Dialog modals contain the header, body, and actions.
+ * **Header(1):** The header title is a question instead of a descriptive phrase.
+ * **Body(2):** The content in body should never be ambiguous and unclear. It provides specific information.
+ * **Actions(3):** Contains a affirmative action, a dismissive action, and an extra action. The order of actions from left to right: Dismissive action → Extra action → Affirmative action
+* Confirmations regarding labels should keep labeling styling.
+* References to commits, branches, and tags should be **monospaced**.
+
+![layout-modal](img/modals-layout-for-modals.png)
+
+### Placement
+
+* Dialog modals should always be the center of the screen horizontally and be positioned **72px** from the top.
+
+| Dialog with 2 actions | Dialog with 3 actions | Special confirmation |
+| --------------------- | --------------------- | -------------------- |
+| ![two-actions](img/modals-general-confimation-dialog.png) | ![three-actions](img/modals-three-buttons.png) | ![spcial-confirmation](img/modals-special-confimation-dialog.png) |
+
+> TODO: Special case for dialog modal.
+
+---
+
## Panels
> TODO: Catalog how we are currently using panels and rationalize how they relate to alerts
diff --git a/doc/development/ux_guide/img/modals-general-confimation-dialog.png b/doc/development/ux_guide/img/modals-general-confimation-dialog.png
new file mode 100644
index 00000000000..00a17374a0b
--- /dev/null
+++ b/doc/development/ux_guide/img/modals-general-confimation-dialog.png
Binary files differ
diff --git a/doc/development/ux_guide/img/modals-layout-for-modals.png b/doc/development/ux_guide/img/modals-layout-for-modals.png
new file mode 100644
index 00000000000..6c7bc09e750
--- /dev/null
+++ b/doc/development/ux_guide/img/modals-layout-for-modals.png
Binary files differ
diff --git a/doc/development/ux_guide/img/modals-special-confimation-dialog.png b/doc/development/ux_guide/img/modals-special-confimation-dialog.png
new file mode 100644
index 00000000000..bf1e56326c5
--- /dev/null
+++ b/doc/development/ux_guide/img/modals-special-confimation-dialog.png
Binary files differ
diff --git a/doc/development/ux_guide/img/modals-three-buttons.png b/doc/development/ux_guide/img/modals-three-buttons.png
new file mode 100644
index 00000000000..519439e64e4
--- /dev/null
+++ b/doc/development/ux_guide/img/modals-three-buttons.png
Binary files differ
diff --git a/doc/raketasks/import.md b/doc/raketasks/import.md
index 2b305cb5c99..97e9b36d1a6 100644
--- a/doc/raketasks/import.md
+++ b/doc/raketasks/import.md
@@ -3,49 +3,47 @@
## Notes
- The owner of the project will be the first admin
-- The groups will be created as needed
+- The groups will be created as needed, including subgroups
- The owner of the group will be the first admin
- Existing projects will be skipped
+- The existing Git repos will be moved from disk (removed from the original path)
## How to use
-### Create a new folder inside the git repositories path. This will be the name of the new group.
+### Create a new folder to import your Git repositories from.
-- For omnibus-gitlab, it is located at: `/var/opt/gitlab/git-data/repositories` by default, unless you changed
-it in the `/etc/gitlab/gitlab.rb` file.
-- For installations from source, it is usually located at: `/home/git/repositories` or you can see where
-your repositories are located by looking at `config/gitlab.yml` under the `repositories => storages` entries
-(you'll usually use the `default` storage path to start).
-
-New folder needs to have git user ownership and read/write/execute access for git user and its group:
+The new folder needs to have git user ownership and read/write/execute access for git user and its group:
```
-sudo -u git mkdir /var/opt/gitlab/git-data/repositories/new_group
+sudo -u git mkdir /var/opt/gitlab/git-data/repository-import-<date>/new_group
```
-If you are using an installation from source, replace `/var/opt/gitlab/git-data`
-with `/home/git`.
-
### Copy your bare repositories inside this newly created folder:
+- Any .git repositories found on any of the subfolders will be imported as projects
+- Groups will be created as needed, these could be nested folders. Example:
+
+If we copy the repos to `/var/opt/gitlab/git-data/repository-import-<date>`, and repo A needs to be under the groups G1 and G2, it will
+have to be created under those folders: `/var/opt/gitlab/git-data/repository-import-<date>/G1/G2/A.git`.
+
+
```
-sudo cp -r /old/git/foo.git /var/opt/gitlab/git-data/repositories/new_group/
+sudo cp -r /old/git/foo.git /var/opt/gitlab/git-data/repository-import-<date>/new_group/
# Do this once when you are done copying git repositories
-sudo chown -R git:git /var/opt/gitlab/git-data/repositories/new_group/
+sudo chown -R git:git /var/opt/gitlab/git-data/repository-import-<date>
```
`foo.git` needs to be owned by the git user and git users group.
-If you are using an installation from source, replace `/var/opt/gitlab/git-data`
-with `/home/git`.
+If you are using an installation from source, replace `/var/opt/gitlab/` with `/home/git`.
### Run the command below depending on your type of installation:
#### Omnibus Installation
```
-$ sudo gitlab-rake gitlab:import:repos
+$ sudo gitlab-rake gitlab:import:repos['/var/opt/gitlab/git-data/repository-import-<date>']
```
#### Installation from source
@@ -54,16 +52,21 @@ Before running this command you need to change the directory to where your GitLa
```
$ cd /home/git/gitlab
-$ sudo -u git -H bundle exec rake gitlab:import:repos RAILS_ENV=production
+$ sudo -u git -H bundle exec rake gitlab:import:repos['/var/opt/gitlab/git-data/repository-import-<date>'] RAILS_ENV=production
```
#### Example output
```
-Processing abcd.git
+Processing /var/opt/gitlab/git-data/repository-import-1/a/b/c/blah.git
+ * Using namespace: a/b/c
+ * Created blah (a/b/c/blah)
+ * Skipping repo /var/opt/gitlab/git-data/repository-import-1/a/b/c/blah.wiki.git
+Processing /var/opt/gitlab/git-data/repository-import-1/abcd.git
* Created abcd (abcd.git)
-Processing group/xyz.git
- * Created Group group (2)
+Processing /var/opt/gitlab/git-data/repository-import-1/group/xyz.git
+ * Using namespace: group (2)
* Created xyz (group/xyz.git)
+ * Skipping repo /var/opt/gitlab/git-data/repository-import-1/@shared/a/b/abcd.git
[...]
```
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/doc/user/project/container_registry.md b/doc/user/project/container_registry.md
index 2c4dfcff4a6..394aa9209e4 100644
--- a/doc/user/project/container_registry.md
+++ b/doc/user/project/container_registry.md
@@ -27,7 +27,8 @@ to enable it.
1. First, ask your system administrator to enable GitLab Container Registry
following the [administration documentation](../../administration/container_registry.md).
If you are using GitLab.com, this is enabled by default so you can start using
- the Registry immediately.
+ the Registry immediately. Currently there is a soft (10GB) size restriction for
+ registry on GitLab.com, as part of the [repository size limit](repository/index.html#repository-size).
1. Go to your [project's General settings](settings/index.md#sharing-and-permissions)
and enable the **Container Registry** feature on your project. For new
projects this might be enabled by default. For existing projects
diff --git a/lib/gitlab/bare_repository_import/importer.rb b/lib/gitlab/bare_repository_import/importer.rb
new file mode 100644
index 00000000000..196de667805
--- /dev/null
+++ b/lib/gitlab/bare_repository_import/importer.rb
@@ -0,0 +1,101 @@
+module Gitlab
+ module BareRepositoryImport
+ class Importer
+ NoAdminError = Class.new(StandardError)
+
+ def self.execute(import_path)
+ import_path << '/' unless import_path.ends_with?('/')
+ repos_to_import = Dir.glob(import_path + '**/*.git')
+
+ unless user = User.admins.order_id_asc.first
+ raise NoAdminError.new('No admin user found to import repositories')
+ end
+
+ repos_to_import.each do |repo_path|
+ bare_repo = Gitlab::BareRepositoryImport::Repository.new(import_path, repo_path)
+
+ if bare_repo.hashed? || bare_repo.wiki?
+ log " * Skipping repo #{bare_repo.repo_path}".color(:yellow)
+
+ next
+ end
+
+ log "Processing #{repo_path}".color(:yellow)
+
+ new(user, bare_repo).create_project_if_needed
+ end
+ end
+
+ attr_reader :user, :project_name, :bare_repo
+
+ delegate :log, to: :class
+ delegate :project_name, :project_full_path, :group_path, :repo_path, :wiki_path, to: :bare_repo
+
+ def initialize(user, bare_repo)
+ @user = user
+ @bare_repo = bare_repo
+ end
+
+ def create_project_if_needed
+ if project = Project.find_by_full_path(project_full_path)
+ log " * #{project.name} (#{project_full_path}) exists"
+
+ return project
+ end
+
+ create_project
+ end
+
+ private
+
+ def create_project
+ group = find_or_create_groups
+
+ project = Projects::CreateService.new(user,
+ name: project_name,
+ path: project_name,
+ skip_disk_validation: true,
+ namespace_id: group&.id).execute
+
+ if project.persisted? && mv_repo(project)
+ log " * Created #{project.name} (#{project_full_path})".color(:green)
+
+ ProjectCacheWorker.perform_async(project.id)
+ else
+ log " * Failed trying to create #{project.name} (#{project_full_path})".color(:red)
+ log " Errors: #{project.errors.messages}".color(:red) if project.errors.any?
+ end
+
+ project
+ end
+
+ def mv_repo(project)
+ FileUtils.mv(repo_path, File.join(project.repository_storage_path, project.disk_path + '.git'))
+
+ if bare_repo.wiki_exists?
+ FileUtils.mv(wiki_path, File.join(project.repository_storage_path, project.disk_path + '.wiki.git'))
+ end
+
+ true
+ rescue => e
+ log " * Failed to move repo: #{e.message}".color(:red)
+
+ false
+ end
+
+ def find_or_create_groups
+ return nil unless group_path.present?
+
+ log " * Using namespace: #{group_path}"
+
+ Groups::NestedCreateService.new(user, group_path: group_path).execute
+ end
+
+ # This is called from within a rake task only used by Admins, so allow writing
+ # to STDOUT
+ def self.log(message)
+ puts message # rubocop:disable Rails/Output
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/bare_repository_import/repository.rb b/lib/gitlab/bare_repository_import/repository.rb
new file mode 100644
index 00000000000..8574ac6eb30
--- /dev/null
+++ b/lib/gitlab/bare_repository_import/repository.rb
@@ -0,0 +1,42 @@
+module Gitlab
+ module BareRepositoryImport
+ class Repository
+ attr_reader :group_path, :project_name, :repo_path
+
+ def initialize(root_path, repo_path)
+ @root_path = root_path
+ @repo_path = repo_path
+
+ # Split path into 'all/the/namespaces' and 'project_name'
+ @group_path, _, @project_name = repo_relative_path.rpartition('/')
+ end
+
+ def wiki_exists?
+ File.exist?(wiki_path)
+ end
+
+ def wiki?
+ @wiki ||= repo_path.end_with?('.wiki.git')
+ end
+
+ def wiki_path
+ @wiki_path ||= repo_path.sub(/\.git$/, '.wiki.git')
+ end
+
+ def hashed?
+ @hashed ||= group_path.start_with?('@hashed')
+ end
+
+ def project_full_path
+ @project_full_path ||= "#{group_path}/#{project_name}"
+ end
+
+ private
+
+ def repo_relative_path
+ # Remove root path and `.git` at the end
+ repo_path[@root_path.size...-4]
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/bare_repository_importer.rb b/lib/gitlab/bare_repository_importer.rb
deleted file mode 100644
index 1d98d187805..00000000000
--- a/lib/gitlab/bare_repository_importer.rb
+++ /dev/null
@@ -1,97 +0,0 @@
-module Gitlab
- class BareRepositoryImporter
- NoAdminError = Class.new(StandardError)
-
- def self.execute
- Gitlab.config.repositories.storages.each do |storage_name, repository_storage|
- git_base_path = repository_storage['path']
- repos_to_import = Dir.glob(git_base_path + '/**/*.git')
-
- repos_to_import.each do |repo_path|
- if repo_path.end_with?('.wiki.git')
- log " * Skipping wiki repo"
- next
- end
-
- log "Processing #{repo_path}".color(:yellow)
-
- repo_relative_path = repo_path[repository_storage['path'].length..-1]
- .sub(/^\//, '') # Remove leading `/`
- .sub(/\.git$/, '') # Remove `.git` at the end
- new(storage_name, repo_relative_path).create_project_if_needed
- end
- end
- end
-
- attr_reader :storage_name, :full_path, :group_path, :project_path, :user
- delegate :log, to: :class
-
- def initialize(storage_name, repo_path)
- @storage_name = storage_name
- @full_path = repo_path
-
- unless @user = User.admins.order_id_asc.first
- raise NoAdminError.new('No admin user found to import repositories')
- end
-
- @group_path, @project_path = File.split(repo_path)
- @group_path = nil if @group_path == '.'
- end
-
- def create_project_if_needed
- if project = Project.find_by_full_path(full_path)
- log " * #{project.name} (#{full_path}) exists"
- return project
- end
-
- create_project
- end
-
- private
-
- def create_project
- group = find_or_create_group
-
- project_params = {
- name: project_path,
- path: project_path,
- repository_storage: storage_name,
- namespace_id: group&.id,
- skip_disk_validation: true
- }
-
- project = Projects::CreateService.new(user, project_params).execute
-
- if project.persisted?
- log " * Created #{project.name} (#{full_path})".color(:green)
- ProjectCacheWorker.perform_async(project.id)
- else
- log " * Failed trying to create #{project.name} (#{full_path})".color(:red)
- log " Errors: #{project.errors.messages}".color(:red)
- end
-
- project
- end
-
- def find_or_create_group
- return nil unless group_path
-
- if namespace = Namespace.find_by_full_path(group_path)
- log " * Namespace #{group_path} exists.".color(:green)
- return namespace
- end
-
- log " * Creating Group: #{group_path}"
- Groups::NestedCreateService.new(user, group_path: group_path).execute
- end
-
- # This is called from within a rake task only used by Admins, so allow writing
- # to STDOUT
- #
- # rubocop:disable Rails/Output
- def self.log(message)
- puts message
- end
- # rubocop:enable Rails/Output
- end
-end
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/lib/tasks/gitlab/import.rake b/lib/tasks/gitlab/import.rake
index d227a0c8bdb..adfcc3cda22 100644
--- a/lib/tasks/gitlab/import.rake
+++ b/lib/tasks/gitlab/import.rake
@@ -2,23 +2,21 @@ namespace :gitlab do
namespace :import do
# How to use:
#
- # 1. copy the bare repos under the repository storage paths (commonly the default path is /home/git/repositories)
- # 2. run: bundle exec rake gitlab:import:repos RAILS_ENV=production
+ # 1. copy the bare repos to a specific path that contain the group or subgroups structure as folders
+ # 2. run: bundle exec rake gitlab:import:repos[/path/to/repos] RAILS_ENV=production
#
# Notes:
# * The project owner will set to the first administator of the system
# * Existing projects will be skipped
- #
- #
desc "GitLab | Import bare repositories from repositories -> storages into GitLab project instance"
- task repos: :environment do
- if Project.current_application_settings.hashed_storage_enabled
- puts 'Cannot import repositories when Hashed Storage is enabled'.color(:red)
+ task :repos, [:import_path] => :environment do |_t, args|
+ unless args.import_path
+ puts 'Please specify an import path that contains the repositories'.color(:red)
exit 1
end
- Gitlab::BareRepositoryImporter.execute
+ Gitlab::BareRepositoryImport::Importer.execute(args.import_path)
end
end
end
diff --git a/package.json b/package.json
index e607981143d..6315f9eced9 100644
--- a/package.json
+++ b/package.json
@@ -61,6 +61,8 @@
"three-orbit-controls": "^82.1.0",
"three-stl-loader": "^1.0.4",
"timeago.js": "^2.0.5",
+ "ts-loader": "^3.1.1",
+ "typescript": "^2.6.1",
"underscore": "^1.8.3",
"url-loader": "^0.5.8",
"visibilityjs": "^1.2.4",
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/controllers/projects/commit_controller_spec.rb b/spec/controllers/projects/commit_controller_spec.rb
index 4612fc6e441..5dc27e2bbba 100644
--- a/spec/controllers/projects/commit_controller_spec.rb
+++ b/spec/controllers/projects/commit_controller_spec.rb
@@ -134,8 +134,8 @@ describe Projects::CommitController do
end
end
- describe "GET branches" do
- it "contains branch and tags information" do
+ describe 'GET branches' do
+ it 'contains branch and tags information' do
commit = project.commit('5937ac0a7beb003549fc5fd26fc247adbce4a52e')
get(:branches,
@@ -143,8 +143,26 @@ describe Projects::CommitController do
project_id: project,
id: commit.id)
- expect(assigns(:branches)).to include("master", "feature_conflict")
- expect(assigns(:tags)).to include("v1.1.0")
+ expect(assigns(:branches)).to include('master', 'feature_conflict')
+ expect(assigns(:branches_limit_exceeded)).to be_falsey
+ expect(assigns(:tags)).to include('v1.1.0')
+ expect(assigns(:tags_limit_exceeded)).to be_falsey
+ end
+
+ it 'returns :limit_exceeded when number of branches/tags reach a threshhold' do
+ commit = project.commit('5937ac0a7beb003549fc5fd26fc247adbce4a52e')
+ allow_any_instance_of(Repository).to receive(:branch_count).and_return(1001)
+ allow_any_instance_of(Repository).to receive(:tag_count).and_return(1001)
+
+ get(:branches,
+ namespace_id: project.namespace,
+ project_id: project,
+ id: commit.id)
+
+ expect(assigns(:branches)).to eq([])
+ expect(assigns(:branches_limit_exceeded)).to be_truthy
+ expect(assigns(:tags)).to eq([])
+ expect(assigns(:tags_limit_exceeded)).to be_truthy
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/factories/clusters/applications/ingress.rb b/spec/factories/clusters/applications/ingress.rb
new file mode 100644
index 00000000000..b103a980655
--- /dev/null
+++ b/spec/factories/clusters/applications/ingress.rb
@@ -0,0 +1,35 @@
+FactoryGirl.define do
+ factory :cluster_applications_ingress, class: Clusters::Applications::Ingress 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..197e6df4997 100644
--- a/spec/features/projects/clusters_spec.rb
+++ b/spec/features/projects/clusters_spec.rb
@@ -50,10 +50,24 @@ 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
+ page.within('.js-cluster-application-row-helm') do
+ 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')
+ end
+
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 +92,78 @@ 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
+ page.within('.js-cluster-application-row-helm') do
+ 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
+ end
+
+ context 'when user installs application: Helm Tiller' do
+ before do
+ allow(ClusterInstallAppWorker).to receive(:perform_async).and_return(nil)
+
+ page.within('.js-cluster-application-row-helm') do
+ page.find(:css, '.js-cluster-application-install-button').click
+ end
+ end
+
+ it 'user sees status transition' do
+ page.within('.js-cluster-application-row-helm') do
+ # FE sends request and gets the response, 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 polling 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')
+ end
+
+ expect(page).to have_content('Helm Tiller was successfully installed on your cluster')
+ end
+ end
+
+ context 'when user installs application: Ingress' do
+ before do
+ allow(ClusterInstallAppWorker).to receive(:perform_async).and_return(nil)
+ # Helm Tiller needs to be installed before you can install Ingress
+ create(:cluster_applications_helm, :installed, cluster: cluster)
+
+ visit project_clusters_path(project)
+
+ page.within('.js-cluster-application-row-ingress') do
+ page.find(:css, '.js-cluster-application-install-button').click
+ end
+ end
+
+ it 'user sees status transition' do
+ page.within('.js-cluster-application-row-ingress') do
+ # FE sends request and gets the response, 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_ingress.make_installing!
+
+ # FE starts polling 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_ingress.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')
+ end
+
+ expect(page).to have_content('Ingress 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..7460da031c4
--- /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/bare_repository_import/importer_spec.rb b/spec/lib/gitlab/bare_repository_import/importer_spec.rb
new file mode 100644
index 00000000000..7f3bf5fc41c
--- /dev/null
+++ b/spec/lib/gitlab/bare_repository_import/importer_spec.rb
@@ -0,0 +1,168 @@
+require 'spec_helper'
+
+describe Gitlab::BareRepositoryImport::Importer, repository: true do
+ let!(:admin) { create(:admin) }
+ let!(:base_dir) { Dir.mktmpdir + '/' }
+ let(:bare_repository) { Gitlab::BareRepositoryImport::Repository.new(base_dir, File.join(base_dir, "#{project_path}.git")) }
+
+ subject(:importer) { described_class.new(admin, bare_repository) }
+
+ before do
+ allow(described_class).to receive(:log)
+ end
+
+ after do
+ FileUtils.rm_rf(base_dir)
+ end
+
+ shared_examples 'importing a repository' do
+ describe '.execute' do
+ it 'creates a project for a repository in storage' do
+ FileUtils.mkdir_p(File.join(base_dir, "#{project_path}.git"))
+ fake_importer = double
+
+ expect(described_class).to receive(:new).and_return(fake_importer)
+ expect(fake_importer).to receive(:create_project_if_needed)
+
+ described_class.execute(base_dir)
+ end
+
+ it 'skips wiki repos' do
+ repo_dir = File.join(base_dir, 'the-group', 'the-project.wiki.git')
+ FileUtils.mkdir_p(File.join(repo_dir))
+
+ expect(described_class).to receive(:log).with(" * Skipping repo #{repo_dir}")
+ expect(described_class).not_to receive(:new)
+
+ described_class.execute(base_dir)
+ end
+
+ context 'without admin users' do
+ let(:admin) { nil }
+
+ it 'raises an error' do
+ expect { described_class.execute(base_dir) }.to raise_error(Gitlab::BareRepositoryImport::Importer::NoAdminError)
+ end
+ end
+ end
+
+ describe '#create_project_if_needed' do
+ it 'starts an import for a project that did not exist' do
+ expect(importer).to receive(:create_project)
+
+ importer.create_project_if_needed
+ end
+
+ it 'skips importing when the project already exists' do
+ project = create(:project, path: 'a-project', namespace: existing_group)
+
+ expect(importer).not_to receive(:create_project)
+ expect(importer).to receive(:log).with(" * #{project.name} (#{project_path}) exists")
+
+ importer.create_project_if_needed
+ end
+
+ it 'creates a project with the correct path in the database' do
+ importer.create_project_if_needed
+
+ expect(Project.find_by_full_path(project_path)).not_to be_nil
+ end
+
+ it 'creates the Git repo in disk' do
+ FileUtils.mkdir_p(File.join(base_dir, "#{project_path}.git"))
+
+ importer.create_project_if_needed
+
+ project = Project.find_by_full_path(project_path)
+
+ expect(File).to exist(File.join(project.repository_storage_path, project.disk_path + '.git'))
+ end
+
+ context 'hashed storage enabled' do
+ it 'creates a project with the correct path in the database' do
+ stub_application_setting(hashed_storage_enabled: true)
+
+ importer.create_project_if_needed
+
+ expect(Project.find_by_full_path(project_path)).not_to be_nil
+ end
+ end
+ end
+ end
+
+ context 'with subgroups', :nested_groups do
+ let(:project_path) { 'a-group/a-sub-group/a-project' }
+
+ let(:existing_group) do
+ group = create(:group, path: 'a-group')
+ create(:group, path: 'a-sub-group', parent: group)
+ end
+
+ it_behaves_like 'importing a repository'
+ end
+
+ context 'without subgroups' do
+ let(:project_path) { 'a-group/a-project' }
+ let(:existing_group) { create(:group, path: 'a-group') }
+
+ it_behaves_like 'importing a repository'
+ end
+
+ context 'without groups' do
+ let(:project_path) { 'a-project' }
+
+ it 'starts an import for a project that did not exist' do
+ expect(importer).to receive(:create_project)
+
+ importer.create_project_if_needed
+ end
+
+ it 'creates a project with the correct path in the database' do
+ importer.create_project_if_needed
+
+ expect(Project.find_by_full_path("#{admin.full_path}/#{project_path}")).not_to be_nil
+ end
+
+ it 'creates the Git repo in disk' do
+ FileUtils.mkdir_p(File.join(base_dir, "#{project_path}.git"))
+
+ importer.create_project_if_needed
+
+ project = Project.find_by_full_path("#{admin.full_path}/#{project_path}")
+
+ expect(File).to exist(File.join(project.repository_storage_path, project.disk_path + '.git'))
+ end
+ end
+
+ context 'with Wiki' do
+ let(:project_path) { 'a-group/a-project' }
+ let(:existing_group) { create(:group, path: 'a-group') }
+
+ it_behaves_like 'importing a repository'
+
+ it 'creates the Wiki git repo in disk' do
+ FileUtils.mkdir_p(File.join(base_dir, "#{project_path}.git"))
+ FileUtils.mkdir_p(File.join(base_dir, "#{project_path}.wiki.git"))
+
+ importer.create_project_if_needed
+
+ project = Project.find_by_full_path(project_path)
+
+ expect(File).to exist(File.join(project.repository_storage_path, project.disk_path + '.wiki.git'))
+ end
+ end
+
+ context 'when subgroups are not available' do
+ let(:project_path) { 'a-group/a-sub-group/a-project' }
+
+ before do
+ expect(Group).to receive(:supports_nested_groups?) { false }
+ end
+
+ describe '#create_project_if_needed' do
+ it 'raises an error' do
+ expect { importer.create_project_if_needed }.to raise_error('Nested groups are not supported on MySQL')
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/bare_repository_import/repository_spec.rb b/spec/lib/gitlab/bare_repository_import/repository_spec.rb
new file mode 100644
index 00000000000..2db737f5fb6
--- /dev/null
+++ b/spec/lib/gitlab/bare_repository_import/repository_spec.rb
@@ -0,0 +1,51 @@
+require 'spec_helper'
+
+describe ::Gitlab::BareRepositoryImport::Repository do
+ let(:project_repo_path) { described_class.new('/full/path/', '/full/path/to/repo.git') }
+
+ it 'stores the repo path' do
+ expect(project_repo_path.repo_path).to eq('/full/path/to/repo.git')
+ end
+
+ it 'stores the group path' do
+ expect(project_repo_path.group_path).to eq('to')
+ end
+
+ it 'stores the project name' do
+ expect(project_repo_path.project_name).to eq('repo')
+ end
+
+ it 'stores the wiki path' do
+ expect(project_repo_path.wiki_path).to eq('/full/path/to/repo.wiki.git')
+ end
+
+ describe '#wiki?' do
+ it 'returns true if it is a wiki' do
+ wiki_path = described_class.new('/full/path/', '/full/path/to/a/b/my.wiki.git')
+
+ expect(wiki_path.wiki?).to eq(true)
+ end
+
+ it 'returns false if it is not a wiki' do
+ expect(project_repo_path.wiki?).to eq(false)
+ end
+ end
+
+ describe '#hashed?' do
+ it 'returns true if it is a hashed folder' do
+ path = described_class.new('/full/path/', '/full/path/@hashed/my.repo.git')
+
+ expect(path.hashed?).to eq(true)
+ end
+
+ it 'returns false if it is not a hashed folder' do
+ expect(project_repo_path.hashed?).to eq(false)
+ end
+ end
+
+ describe '#project_full_path' do
+ it 'returns the project full path' do
+ expect(project_repo_path.repo_path).to eq('/full/path/to/repo.git')
+ end
+ end
+end
diff --git a/spec/lib/gitlab/bare_repository_importer_spec.rb b/spec/lib/gitlab/bare_repository_importer_spec.rb
deleted file mode 100644
index 36d1844b5b1..00000000000
--- a/spec/lib/gitlab/bare_repository_importer_spec.rb
+++ /dev/null
@@ -1,100 +0,0 @@
-require 'spec_helper'
-
-describe Gitlab::BareRepositoryImporter, repository: true do
- subject(:importer) { described_class.new('default', project_path) }
-
- let!(:admin) { create(:admin) }
-
- before do
- allow(described_class).to receive(:log)
- end
-
- shared_examples 'importing a repository' do
- describe '.execute' do
- it 'creates a project for a repository in storage' do
- FileUtils.mkdir_p(File.join(TestEnv.repos_path, "#{project_path}.git"))
- fake_importer = double
-
- expect(described_class).to receive(:new).with('default', project_path)
- .and_return(fake_importer)
- expect(fake_importer).to receive(:create_project_if_needed)
-
- described_class.execute
- end
-
- it 'skips wiki repos' do
- FileUtils.mkdir_p(File.join(TestEnv.repos_path, 'the-group', 'the-project.wiki.git'))
-
- expect(described_class).to receive(:log).with(' * Skipping wiki repo')
- expect(described_class).not_to receive(:new)
-
- described_class.execute
- end
- end
-
- describe '#initialize' do
- context 'without admin users' do
- let(:admin) { nil }
-
- it 'raises an error' do
- expect { importer }.to raise_error(Gitlab::BareRepositoryImporter::NoAdminError)
- end
- end
- end
-
- describe '#create_project_if_needed' do
- it 'starts an import for a project that did not exist' do
- expect(importer).to receive(:create_project)
-
- importer.create_project_if_needed
- end
-
- it 'skips importing when the project already exists' do
- project = create(:project, path: 'a-project', namespace: existing_group)
-
- expect(importer).not_to receive(:create_project)
- expect(importer).to receive(:log).with(" * #{project.name} (#{project_path}) exists")
-
- importer.create_project_if_needed
- end
-
- it 'creates a project with the correct path in the database' do
- importer.create_project_if_needed
-
- expect(Project.find_by_full_path(project_path)).not_to be_nil
- end
- end
- end
-
- context 'with subgroups', :nested_groups do
- let(:project_path) { 'a-group/a-sub-group/a-project' }
-
- let(:existing_group) do
- group = create(:group, path: 'a-group')
- create(:group, path: 'a-sub-group', parent: group)
- end
-
- it_behaves_like 'importing a repository'
- end
-
- context 'without subgroups' do
- let(:project_path) { 'a-group/a-project' }
- let(:existing_group) { create(:group, path: 'a-group') }
-
- it_behaves_like 'importing a repository'
- end
-
- context 'when subgroups are not available' do
- let(:project_path) { 'a-group/a-sub-group/a-project' }
-
- before do
- expect(Group).to receive(:supports_nested_groups?) { false }
- end
-
- describe '#create_project_if_needed' do
- it 'raises an error' do
- expect { importer.create_project_if_needed }.to raise_error('Nested groups are not supported on MySQL')
- end
- end
- end
-end
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/clusters/applications/ingress_spec.rb b/spec/models/clusters/applications/ingress_spec.rb
new file mode 100644
index 00000000000..b83472e1944
--- /dev/null
+++ b/spec/models/clusters/applications/ingress_spec.rb
@@ -0,0 +1,108 @@
+require 'rails_helper'
+
+describe Clusters::Applications::Ingress 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 '#status' do
+ let(:cluster) { create(:cluster, :provided_by_gcp) }
+
+ subject { described_class.new(cluster: cluster) }
+
+ it 'defaults to :not_installable' do
+ expect(subject.status_name).to be(:not_installable)
+ end
+
+ context 'when application helm is scheduled' do
+ before do
+ create(:cluster_applications_helm, :scheduled, cluster: cluster)
+ end
+
+ it 'defaults to :not_installable' do
+ expect(subject.status_name).to be(:not_installable)
+ end
+ end
+
+ context 'when application helm is installed' do
+ before do
+ create(:cluster_applications_helm, :installed, cluster: cluster)
+ end
+
+ 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: false, chart: subject.chart)
+ end
+ end
+
+ describe 'status state machine' do
+ describe '#make_installing' do
+ subject { create(:cluster_applications_ingress, :scheduled) }
+
+ it 'is installing' do
+ subject.make_installing!
+
+ expect(subject).to be_installing
+ end
+ end
+
+ describe '#make_installed' do
+ subject { create(:cluster_applications_ingress, :installing) }
+
+ it 'is installed' do
+ subject.make_installed
+
+ expect(subject).to be_installed
+ end
+ end
+
+ describe '#make_errored' do
+ subject { create(:cluster_applications_ingress, :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_ingress, :installable) }
+
+ it 'is scheduled' do
+ subject.make_scheduled
+
+ expect(subject).to be_scheduled
+ end
+
+ describe 'when was errored' do
+ subject { create(:cluster_applications_ingress, :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/clusters/cluster_spec.rb b/spec/models/clusters/cluster_spec.rb
index 12123a3d753..b91a5e7a272 100644
--- a/spec/models/clusters/cluster_spec.rb
+++ b/spec/models/clusters/cluster_spec.rb
@@ -178,4 +178,25 @@ describe Clusters::Cluster do
it { is_expected.to be_nil }
end
end
+
+ describe '#applications' do
+ set(:cluster) { create(:cluster) }
+
+ subject { cluster.applications }
+
+ context 'when none of applications are created' do
+ it 'returns a list of a new objects' do
+ is_expected.not_to be_empty
+ end
+ end
+
+ context 'when applications are created' do
+ let!(:helm) { create(:cluster_applications_helm, cluster: cluster) }
+ let!(:ingress) { create(:cluster_applications_ingress, cluster: cluster) }
+
+ it 'returns a list of created applications' do
+ is_expected.to contain_exactly(helm, ingress)
+ 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/features/discussion_comments_shared_example.rb b/spec/support/features/discussion_comments_shared_example.rb
index aabc64d972b..c24940393f9 100644
--- a/spec/support/features/discussion_comments_shared_example.rb
+++ b/spec/support/features/discussion_comments_shared_example.rb
@@ -71,7 +71,7 @@ shared_examples 'discussion comments' do |resource_name|
expect(page).not_to have_selector menu_selector
find(toggle_selector).click
- find('body').click
+ execute_script("document.querySelector('body').click()")
expect(page).not_to have_selector menu_selector
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)
diff --git a/spec/views/projects/commit/branches.html.haml_spec.rb b/spec/views/projects/commit/branches.html.haml_spec.rb
new file mode 100644
index 00000000000..b9d4dc80fe0
--- /dev/null
+++ b/spec/views/projects/commit/branches.html.haml_spec.rb
@@ -0,0 +1,109 @@
+require 'spec_helper'
+
+describe 'projects/commit/branches.html.haml' do
+ let(:project) { create(:project, :repository) }
+
+ before do
+ assign(:project, project)
+ end
+
+ context 'when branches and tags are available' do
+ before do
+ assign(:branches, ['master', 'test-branch'])
+ assign(:branches_limit_exceeded, false)
+ assign(:tags, ['tag1'])
+ assign(:tags_limit_exceeded, false)
+
+ render
+ end
+
+ it 'shows default branch' do
+ expect(rendered).to have_link('master')
+ end
+
+ it 'shows js expand link' do
+ expect(rendered).to have_selector('.js-details-expand')
+ end
+
+ it 'shows branch and tag links' do
+ expect(rendered).to have_link('test-branch')
+ expect(rendered).to have_link('tag1')
+ end
+ end
+
+ context 'when branches are available but no tags' do
+ before do
+ assign(:branches, ['master', 'test-branch'])
+ assign(:branches_limit_exceeded, false)
+ assign(:tags, [])
+ assign(:tags_limit_exceeded, true)
+
+ render
+ end
+
+ it 'shows branches' do
+ expect(rendered).to have_link('master')
+ expect(rendered).to have_link('test-branch')
+ end
+
+ it 'shows js expand link' do
+ expect(rendered).to have_selector('.js-details-expand')
+ end
+
+ it 'shows limit exceeded message for tags' do
+ expect(rendered).to have_text('Tags unavailable')
+ end
+ end
+
+ context 'when tags are available but no branches (just default)' do
+ before do
+ assign(:branches, ['master'])
+ assign(:branches_limit_exceeded, true)
+ assign(:tags, %w(tag1 tag2))
+ assign(:tags_limit_exceeded, false)
+
+ render
+ end
+
+ it 'shows default branch' do
+ expect(rendered).to have_text('master')
+ end
+
+ it 'shows js expand link' do
+ expect(rendered).to have_selector('.js-details-expand')
+ end
+
+ it 'shows tags' do
+ expect(rendered).to have_link('tag1')
+ expect(rendered).to have_link('tag2')
+ end
+
+ it 'shows limit exceeded for branches' do
+ expect(rendered).to have_text('Branches unavailable')
+ end
+ end
+
+ context 'when branches and tags are not available' do
+ before do
+ assign(:branches, ['master'])
+ assign(:branches_limit_exceeded, true)
+ assign(:tags, [])
+ assign(:tags_limit_exceeded, true)
+
+ render
+ end
+
+ it 'shows default branch' do
+ expect(rendered).to have_text('master')
+ end
+
+ it 'shows js expand link' do
+ expect(rendered).to have_selector('.js-details-expand')
+ end
+
+ it 'shows too many to search' do
+ expect(rendered).to have_text('Branches unavailable')
+ expect(rendered).to have_text('Tags unavailable')
+ end
+ end
+end
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 00000000000..3a569cf7c19
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "compilerOptions": {
+ "target": "es5",
+ "strict": true,
+ "module": "es2015",
+ "moduleResolution": "node"
+ }
+} \ No newline at end of file
diff --git a/vendor/gitignore/Composer.gitignore b/vendor/gitignore/Composer.gitignore
index c4222678424..a67d42b32f8 100644
--- a/vendor/gitignore/Composer.gitignore
+++ b/vendor/gitignore/Composer.gitignore
@@ -1,6 +1,6 @@
composer.phar
/vendor/
-# Commit your application's lock file http://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file
+# Commit your application's lock file https://getcomposer.org/doc/01-basic-usage.md#commit-your-composer-lock-file-to-version-control
# You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file
# composer.lock
diff --git a/vendor/gitignore/Global/Windows.gitignore b/vendor/gitignore/Global/Windows.gitignore
index dff26a9ab70..846a1db836c 100644
--- a/vendor/gitignore/Global/Windows.gitignore
+++ b/vendor/gitignore/Global/Windows.gitignore
@@ -7,7 +7,7 @@ ehthumbs_vista.db
*.stackdump
# Folder config file
-Desktop.ini
+[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
diff --git a/vendor/gitignore/Terraform.gitignore b/vendor/gitignore/Terraform.gitignore
index 9b5aebb1b35..1fef4ab91e1 100644
--- a/vendor/gitignore/Terraform.gitignore
+++ b/vendor/gitignore/Terraform.gitignore
@@ -1,10 +1,9 @@
-# Compiled files
-*.tfstate
-*.tfstate.*.backup
-*.tfstate.backup
+# Local .terraform directories
+**/.terraform/*
-# Module directory
-.terraform/
+# .tfstate files
+*.tfstate
+*.tfstate.*
-# Variable values for development
-terraform.tfvars
+# .tfvars files
+*.tfvars
diff --git a/vendor/gitignore/VisualStudio.gitignore b/vendor/gitignore/VisualStudio.gitignore
index 0867ec5a7ee..509668db67a 100644
--- a/vendor/gitignore/VisualStudio.gitignore
+++ b/vendor/gitignore/VisualStudio.gitignore
@@ -171,11 +171,11 @@ PublishScripts/
# NuGet Packages
*.nupkg
# The packages folder can be ignored because of Package Restore
-**/packages/*
+**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
-!**/packages/build/
+!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
-#!**/packages/repositories.config
+#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
diff --git a/vendor/gitlab-ci-yml/Auto-DevOps.gitlab-ci.yml b/vendor/gitlab-ci-yml/Auto-DevOps.gitlab-ci.yml
index c93e6567baf..88261502d7f 100644
--- a/vendor/gitlab-ci-yml/Auto-DevOps.gitlab-ci.yml
+++ b/vendor/gitlab-ci-yml/Auto-DevOps.gitlab-ci.yml
@@ -335,7 +335,9 @@ production:
function check_kube_domain() {
if [ -z ${AUTO_DEVOPS_DOMAIN+x} ]; then
- echo "In order to deploy, AUTO_DEVOPS_DOMAIN must be set as a variable at the group or project level, or manually added in .gitlab-cy.yml"
+ echo "In order to deploy or use Review Apps, AUTO_DEVOPS_DOMAIN variable must be set"
+ echo "You can do it in Auto DevOps project settings or defining a secret variable at group or project level"
+ echo "You can also manually add it in .gitlab-ci.yml"
false
else
true
diff --git a/vendor/gitlab-ci-yml/OpenShift.gitlab-ci.yml b/vendor/gitlab-ci-yml/OpenShift.gitlab-ci.yml
index 636cb0a9a99..290b9997084 100644
--- a/vendor/gitlab-ci-yml/OpenShift.gitlab-ci.yml
+++ b/vendor/gitlab-ci-yml/OpenShift.gitlab-ci.yml
@@ -31,14 +31,14 @@ test2:
- oc project "$CI_PROJECT_NAME-$CI_PROJECT_ID" 2> /dev/null || oc new-project "$CI_PROJECT_NAME-$CI_PROJECT_ID"
script:
- "oc get services $APP 2> /dev/null || oc new-app . --name=$APP --strategy=docker"
- - "oc start-build $APP --from-dir=. --follow || sleep 3s || oc start-build $APP --from-dir=. --follow"
+ - "oc start-build $APP --from-dir=. --follow || sleep 3s && oc start-build $APP --from-dir=. --follow"
- "oc get routes $APP 2> /dev/null || oc expose service $APP --hostname=$APP_HOST"
review:
<<: *deploy
stage: review
variables:
- APP: $CI_COMMIT_REF_NAME
+ APP: review-$CI_COMMIT_REF_NAME
APP_HOST: $CI_PROJECT_NAME-$CI_ENVIRONMENT_SLUG.$OPENSHIFT_DOMAIN
environment:
name: review/$CI_COMMIT_REF_NAME
@@ -56,7 +56,7 @@ stop-review:
- oc delete all -l "app=$APP"
when: manual
variables:
- APP: $CI_COMMIT_REF_NAME
+ APP: review-$CI_COMMIT_REF_NAME
GIT_STRATEGY: none
environment:
name: review/$CI_COMMIT_REF_NAME
diff --git a/vendor/gitlab-ci-yml/Rust.gitlab-ci.yml b/vendor/gitlab-ci-yml/Rust.gitlab-ci.yml
index 7810121c350..6573eceaa59 100644
--- a/vendor/gitlab-ci-yml/Rust.gitlab-ci.yml
+++ b/vendor/gitlab-ci-yml/Rust.gitlab-ci.yml
@@ -1,6 +1,6 @@
-# Unofficial language image. Look for the different tagged releases at:
-# https://hub.docker.com/r/scorpil/rust/tags/
-image: "scorpil/rust:stable"
+# Official language image. Look for the different tagged releases at:
+# https://hub.docker.com/r/library/rust/tags/
+image: "rust:latest"
# Optional: Pick zero or more services to be used on all builds.
# Only needed when using a docker container to run your tests in.
diff --git a/vendor/gitlab-ci-yml/dotNET.gitlab-ci.yml b/vendor/gitlab-ci-yml/dotNET.gitlab-ci.yml
new file mode 100644
index 00000000000..fc3d4ecdbba
--- /dev/null
+++ b/vendor/gitlab-ci-yml/dotNET.gitlab-ci.yml
@@ -0,0 +1,86 @@
+# The following script will work for any project that can be built from command line by msbuild
+# It uses powershell shell executor, so you need to add the following line to your config.toml file
+# (located in gitlab-runner.exe directory):
+# shell = "powershell"
+#
+# The script is composed of 3 stages: build, test and deploy.
+#
+# The build stage restores NuGet packages and uses msbuild to build the exe and msi
+# One major issue you'll find is that you can't build msi projects from command line
+# if you use vdproj. There are workarounds building msi via devenv, but they rarely work
+# The best solution is migrating your vdproj projects to WiX, as it can be build directly
+# by msbuild.
+#
+# The test stage runs nunit from command line against Test project inside your solution
+# It also saves the resulting TestResult.xml file
+#
+# The deploy stage copies the exe and msi from build stage to a network drive
+# You need to have the network drive mapped as Local System user for gitlab-runner service to see it
+# The best way to persist the mapping is via a scheduled task (see: https://stackoverflow.com/a/7867064/1288473),
+# running the following batch command: net use P: \\x.x.x.x\Projects /u:your_user your_pass /persistent:yes
+
+
+# place project specific paths in variables to make the rest of the script more generic
+variables:
+ EXE_RELEASE_FOLDER: 'YourApp\bin\Release'
+ MSI_RELEASE_FOLDER: 'Setup\bin\Release'
+ TEST_FOLDER: 'Tests\bin\Release'
+ DEPLOY_FOLDER: 'P:\Projects\YourApp\Builds'
+
+ NUGET_PATH: 'C:\NuGet\nuget.exe'
+ MSBUILD_PATH: 'C:\Program Files (x86)\MSBuild\14.0\Bin\msbuild.exe'
+ NUNIT_PATH: 'C:\Program Files (x86)\NUnit.org\nunit-console\nunit3-console.exe'
+
+stages:
+ - build
+ - test
+ - deploy
+
+build_job:
+ stage: build
+ only:
+ - tags # the build process will only be started by git tag commits
+ script:
+ - '& "$env:NUGET_PATH" restore' # restore Nuget dependencies
+ - '& "$env:MSBUILD_PATH" /p:Configuration=Release' # build the project
+ artifacts:
+ expire_in: 1 week # save gitlab server space, we copy the files we need to deploy folder later on
+ paths:
+ - '$env:EXE_RELEASE_FOLDER\YourApp.exe' # saving exe to copy to deploy folder
+ - '$env:MSI_RELEASE_FOLDER\YourApp Setup.msi' # saving msi to copy to deploy folder
+ - '$env:TEST_FOLDER\' # saving entire Test project so NUnit can run tests
+
+test_job:
+ stage: test
+ only:
+ - tags
+ script:
+ - '& "$env:NUNIT_PATH" ".\$env:TEST_FOLDER\Tests.dll"' # running NUnit tests
+ artifacts:
+ expire_in: 1 week # save gitlab server space, we copy the files we need to deploy folder later on
+ paths:
+ - '.\TestResult.xml' # saving NUnit results to copy to deploy folder
+ dependencies:
+ - build_job
+
+deploy_job:
+ stage: deploy
+ only:
+ - tags
+ script:
+ # Compose a folder for each release based on commit tag.
+ # Assuming your tag is Rev1.0.0.1, and your last commit message is 'First commit'
+ # the artifact files will be copied to:
+ # P:\Projects\YourApp\Builds\Rev1.0.0.1 - First commit\
+ - '$commitSubject = git log -1 --pretty=%s'
+ - '$deployFolder = $($env:DEPLOY_FOLDER) + "\" + $($env:CI_BUILD_TAG) + " - " + $commitSubject + "\"'
+
+ # xcopy takes care of recursively creating required folders
+ - 'xcopy /y ".\$env:EXE_RELEASE_FOLDER\YourApp.exe" "$deployFolder"'
+ - 'xcopy /y ".\$env:MSI_RELEASE_FOLDER\YourApp Setup.msi" "$deployFolder"'
+ - 'xcopy /y ".\TestResult.xml" "$deployFolder"'
+
+ dependencies:
+ - build_job
+ - test_job
+ \ No newline at end of file
diff --git a/yarn.lock b/yarn.lock
index bf92370d44f..efae3892c31 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -101,6 +101,12 @@ ansi-styles@^2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe"
+ansi-styles@^3.1.0:
+ version "3.2.0"
+ resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.0.tgz#c159b8d5be0f9e5a6f346dab94f16ce022161b88"
+ dependencies:
+ color-convert "^1.9.0"
+
anymatch@^1.3.0:
version "1.3.2"
resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-1.3.2.tgz#553dcb8f91e3c889845dfdba34c77721b90b9d7a"
@@ -217,18 +223,12 @@ async@1.x, async@^1.4.0, async@^1.4.2, async@^1.5.2:
version "1.5.2"
resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a"
-async@2.4.1:
+async@2.4.1, async@^2.1.2, async@^2.1.4:
version "2.4.1"
resolved "https://registry.yarnpkg.com/async/-/async-2.4.1.tgz#62a56b279c98a11d0987096a01cc3eeb8eb7bbd7"
dependencies:
lodash "^4.14.0"
-async@^2.1.2, async@^2.1.4:
- version "2.5.0"
- resolved "https://registry.yarnpkg.com/async/-/async-2.5.0.tgz#843190fd6b7357a0b9e1c956edddd5ec8462b54d"
- dependencies:
- lodash "^4.14.0"
-
async@~0.9.0:
version "0.9.2"
resolved "https://registry.yarnpkg.com/async/-/async-0.9.2.tgz#aea74d5e61c1f899613bf64bda66d4c78f2fd17d"
@@ -267,7 +267,7 @@ axios@^0.16.2:
follow-redirects "^1.2.3"
is-buffer "^1.1.5"
-babel-code-frame@^6.11.0, babel-code-frame@^6.22.0:
+babel-code-frame@^6.11.0, babel-code-frame@^6.16.0, babel-code-frame@^6.22.0:
version "6.22.0"
resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.22.0.tgz#027620bee567a88c32561574e7fd0801d33118e4"
dependencies:
@@ -275,14 +275,6 @@ babel-code-frame@^6.11.0, babel-code-frame@^6.22.0:
esutils "^2.0.2"
js-tokens "^3.0.0"
-babel-code-frame@^6.16.0:
- version "6.26.0"
- resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.26.0.tgz#63fd43f7dc1e3bb7ce35947db8fe369a3f58c74b"
- dependencies:
- chalk "^1.1.3"
- esutils "^2.0.2"
- js-tokens "^3.0.2"
-
babel-core@^6.22.1, babel-core@^6.23.0:
version "6.23.1"
resolved "https://registry.yarnpkg.com/babel-core/-/babel-core-6.23.1.tgz#c143cb621bb2f621710c220c5d579d15b8a442df"
@@ -932,11 +924,7 @@ bluebird@^2.10.2:
version "2.11.0"
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-2.11.0.tgz#534b9033c022c9579c56ba3b3e5a5caafbb650e1"
-bluebird@^3.0.5, bluebird@^3.1.1:
- version "3.4.7"
- resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.4.7.tgz#f72d760be09b7f76d08ed8fae98b289a8d05fab3"
-
-bluebird@^3.3.0:
+bluebird@^3.0.5, bluebird@^3.1.1, bluebird@^3.3.0:
version "3.5.0"
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.0.tgz#791420d7f551eea2897453a8a77653f96606d67c"
@@ -1067,10 +1055,6 @@ buffer-indexof@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/buffer-indexof/-/buffer-indexof-1.1.0.tgz#f54f647c4f4e25228baa656a2e57e43d5f270982"
-buffer-shims@^1.0.0:
- version "1.0.0"
- resolved "https://registry.yarnpkg.com/buffer-shims/-/buffer-shims-1.0.0.tgz#9978ce317388c649ad8793028c3477ef044a8b51"
-
buffer-xor@^1.0.2:
version "1.0.3"
resolved "https://registry.yarnpkg.com/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9"
@@ -1170,6 +1154,14 @@ chalk@1.1.3, chalk@^1.0.0, chalk@^1.1.0, chalk@^1.1.1, chalk@^1.1.3:
strip-ansi "^3.0.0"
supports-color "^2.0.0"
+chalk@^2.3.0:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.3.0.tgz#b5ea48efc9c1793dccc9b4767c93914d3f2d52ba"
+ dependencies:
+ ansi-styles "^3.1.0"
+ escape-string-regexp "^1.0.5"
+ supports-color "^4.0.0"
+
chokidar@^1.4.1, chokidar@^1.4.3, chokidar@^1.6.0, chokidar@^1.7.0:
version "1.7.0"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-1.7.0.tgz#798e689778151c8076b4b360e5edd28cda2bb468"
@@ -1253,7 +1245,7 @@ code-point-at@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"
-color-convert@^1.3.0:
+color-convert@^1.3.0, color-convert@^1.9.0:
version "1.9.0"
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.0.tgz#1accf97dd739b983bf994d56fec8f95853641b7a"
dependencies:
@@ -1454,11 +1446,7 @@ copy-webpack-plugin@^4.0.1:
minimatch "^3.0.0"
node-dir "^0.1.10"
-core-js@^2.2.0:
- version "2.5.0"
- resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.0.tgz#569c050918be6486b3837552028ae0466b717086"
-
-core-js@^2.4.0, core-js@^2.4.1:
+core-js@^2.2.0, core-js@^2.4.0, core-js@^2.4.1:
version "2.4.1"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.4.1.tgz#4de911e667b0eae9124e34254b53aea6fc618d3e"
@@ -1997,7 +1985,7 @@ engine.io@1.8.3:
engine.io-parser "1.3.2"
ws "1.1.2"
-enhanced-resolve@^3.4.0:
+enhanced-resolve@^3.0.0, enhanced-resolve@^3.4.0:
version "3.4.1"
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-3.4.1.tgz#0421e339fd71419b3da13d129b3979040230476e"
dependencies:
@@ -2078,11 +2066,7 @@ es6-map@^0.1.3:
es6-symbol "~3.1.1"
event-emitter "~0.3.5"
-es6-promise@^3.0.2:
- version "3.3.1"
- resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-3.3.1.tgz#a08cdde84ccdbf34d027a1451bc91d4bcd28a613"
-
-es6-promise@~3.0.2:
+es6-promise@^3.0.2, es6-promise@~3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-3.0.2.tgz#010d5858423a5f118979665f46486a95c6ee2bb6"
@@ -2263,10 +2247,6 @@ esprima@2.7.x, esprima@^2.6.0, esprima@^2.7.1:
version "2.7.3"
resolved "https://registry.yarnpkg.com/esprima/-/esprima-2.7.3.tgz#96e3b70d5779f6ad49cd032673d1c312767ba581"
-esprima@^3.1.1:
- version "3.1.3"
- resolved "https://registry.yarnpkg.com/esprima/-/esprima-3.1.3.tgz#fdca51cee6133895e3c88d535ce49dbff62a4633"
-
esprima@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.0.tgz#4499eddcd1110e0b218bacf2fa7f7f59f55ca804"
@@ -2667,11 +2647,11 @@ fstream@^1.0.0, fstream@^1.0.10, fstream@^1.0.2:
mkdirp ">=0.5 0"
rimraf "2"
-function-bind@^1.0.2:
+function-bind@^1.0.2, function-bind@~1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.0.tgz#16176714c801798e4e8f2cf7f7529467bb4a5771"
-function-bind@^1.1.1, function-bind@~1.1.0:
+function-bind@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
@@ -2757,25 +2737,25 @@ glob@^6.0.4:
once "^1.3.0"
path-is-absolute "^1.0.0"
-glob@^7.0.0, glob@^7.1.1, glob@~7.1.2:
- version "7.1.2"
- resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.2.tgz#c19c9df9a028702d678612384a6552404c636d15"
+glob@^7.0.0, glob@^7.0.3, glob@^7.0.5, glob@^7.1.1:
+ version "7.1.1"
+ resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.1.tgz#805211df04faaf1c63a3600306cdf5ade50b2ec8"
dependencies:
fs.realpath "^1.0.0"
inflight "^1.0.4"
inherits "2"
- minimatch "^3.0.4"
+ minimatch "^3.0.2"
once "^1.3.0"
path-is-absolute "^1.0.0"
-glob@^7.0.3, glob@^7.0.5:
- version "7.1.1"
- resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.1.tgz#805211df04faaf1c63a3600306cdf5ade50b2ec8"
+glob@~7.1.2:
+ version "7.1.2"
+ resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.2.tgz#c19c9df9a028702d678612384a6552404c636d15"
dependencies:
fs.realpath "^1.0.0"
inflight "^1.0.4"
inherits "2"
- minimatch "^3.0.2"
+ minimatch "^3.0.4"
once "^1.3.0"
path-is-absolute "^1.0.0"
@@ -2978,14 +2958,10 @@ html-comment-regex@^1.1.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/html-comment-regex/-/html-comment-regex-1.1.1.tgz#668b93776eaae55ebde8f3ad464b307a4963625e"
-html-entities@1.2.0:
+html-entities@1.2.0, html-entities@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-1.2.0.tgz#41948caf85ce82fed36e4e6a0ed371a6664379e2"
-html-entities@^1.2.0:
- version "1.2.1"
- resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-1.2.1.tgz#0df29351f0721163515dfb9e5543e5f6eed5162f"
-
htmlparser2@^3.8.2:
version "3.9.2"
resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.9.2.tgz#1bdf87acca0f3f9e53fa4fcceb0f4b4cbb00b338"
@@ -3391,10 +3367,6 @@ isexe@^1.1.1:
version "1.1.2"
resolved "https://registry.yarnpkg.com/isexe/-/isexe-1.1.2.tgz#36f3e22e60750920f5e7241a476a8c6a42275ad0"
-isexe@^2.0.0:
- version "2.0.0"
- resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
-
isobject@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89"
@@ -3538,18 +3510,7 @@ js-tokens@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.1.tgz#08e9f132484a2c45a30907e9dc4d5567b7f114d7"
-js-tokens@^3.0.2:
- version "3.0.2"
- resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b"
-
-js-yaml@3.x, js-yaml@^3.4.3, js-yaml@^3.7.0:
- version "3.8.1"
- resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.8.1.tgz#782ba50200be7b9e5a8537001b7804db3ad02628"
- dependencies:
- argparse "^1.0.7"
- esprima "^3.1.1"
-
-js-yaml@^3.5.1:
+js-yaml@3.x, js-yaml@^3.4.3, js-yaml@^3.5.1, js-yaml@^3.7.0:
version "3.9.1"
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.9.1.tgz#08775cebdfdd359209f0d2acd383c8f86a6904a0"
dependencies:
@@ -4090,7 +4051,7 @@ miller-rabin@^4.0.0:
bn.js "^4.0.0"
brorand "^1.0.1"
-"mime-db@>= 1.29.0 < 2", mime-db@~1.29.0:
+"mime-db@>= 1.29.0 < 2":
version "1.29.0"
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.29.0.tgz#48d26d235589651704ac5916ca06001914266878"
@@ -4098,13 +4059,7 @@ mime-db@~1.27.0:
version "1.27.0"
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.27.0.tgz#820f572296bbd20ec25ed55e5b5de869e5436eb1"
-mime-types@^2.1.12, mime-types@~2.1.7:
- version "2.1.16"
- resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.16.tgz#2b858a52e5ecd516db897ac2be87487830698e23"
- dependencies:
- mime-db "~1.29.0"
-
-mime-types@~2.1.11, mime-types@~2.1.15:
+mime-types@^2.1.12, mime-types@~2.1.11, mime-types@~2.1.15, mime-types@~2.1.7:
version "2.1.15"
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.15.tgz#a4ebf5064094569237b8cf70046776d09fc92aed"
dependencies:
@@ -4328,16 +4283,7 @@ nopt@~1.0.10:
dependencies:
abbrev "1"
-normalize-package-data@^2.3.2:
- version "2.3.5"
- resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.3.5.tgz#8d924f142960e1777e7ffe170543631cc7cb02df"
- dependencies:
- hosted-git-info "^2.1.4"
- is-builtin-module "^1.0.0"
- semver "2 || 3 || 4 || 5"
- validate-npm-package-license "^3.0.1"
-
-normalize-package-data@^2.3.4:
+normalize-package-data@^2.3.2, normalize-package-data@^2.3.4:
version "2.4.0"
resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.4.0.tgz#12f95a307d58352075a04907b84ac8be98ac012f"
dependencies:
@@ -4512,7 +4458,7 @@ os-locale@^2.0.0:
lcid "^1.0.0"
mem "^1.1.0"
-os-tmpdir@^1.0.0, os-tmpdir@^1.0.1, os-tmpdir@~1.0.1, os-tmpdir@~1.0.2:
+os-tmpdir@^1.0.0, os-tmpdir@^1.0.1, os-tmpdir@~1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
@@ -5208,7 +5154,7 @@ read-pkg@^2.0.0:
normalize-package-data "^2.3.2"
path-type "^2.0.0"
-readable-stream@^2.0.0, readable-stream@^2.0.6, readable-stream@^2.1.4, readable-stream@^2.2.2, readable-stream@^2.2.9:
+readable-stream@^2.0.0, readable-stream@^2.0.6, readable-stream@^2.1.0, readable-stream@^2.1.4, readable-stream@^2.2.2, readable-stream@^2.2.9:
version "2.3.3"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.3.tgz#368f2512d79f9d46fdfc71349ae7878bbc1eb95c"
dependencies:
@@ -5231,18 +5177,6 @@ readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.5, readable
string_decoder "~0.10.x"
util-deprecate "~1.0.1"
-readable-stream@^2.1.0:
- version "2.2.2"
- resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.2.2.tgz#a9e6fec3c7dda85f8bb1b3ba7028604556fc825e"
- dependencies:
- buffer-shims "^1.0.0"
- core-util-is "~1.0.0"
- inherits "~2.0.1"
- isarray "~1.0.0"
- process-nextick-args "~1.0.6"
- string_decoder "~0.10.x"
- util-deprecate "~1.0.1"
-
readable-stream@~1.0.2:
version "1.0.34"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.0.34.tgz#125820e34bc842d2f2aaafafe4c2916ee32c157c"
@@ -5528,14 +5462,10 @@ semver-diff@^2.0.0:
dependencies:
semver "^5.0.3"
-"semver@2 || 3 || 4 || 5", semver@^5.3.0:
+"semver@2 || 3 || 4 || 5", semver@^5.0.1, semver@^5.0.3, semver@^5.3.0:
version "5.3.0"
resolved "https://registry.yarnpkg.com/semver/-/semver-5.3.0.tgz#9b2ce5d3de02d17c6012ad326aa6b4d0cf54f94f"
-semver@^5.0.3:
- version "5.4.1"
- resolved "https://registry.yarnpkg.com/semver/-/semver-5.4.1.tgz#e059c09d8571f0540823733433505d3a2f00b18e"
-
semver@~4.3.3:
version "4.3.6"
resolved "https://registry.yarnpkg.com/semver/-/semver-4.3.6.tgz#300bc6e0e86374f7ba61068b5b1ecd57fc6532da"
@@ -5943,7 +5873,7 @@ supports-color@^3.1.0, supports-color@^3.1.1, supports-color@^3.1.2, supports-co
dependencies:
has-flag "^1.0.0"
-supports-color@^4.2.1:
+supports-color@^4.0.0, supports-color@^4.2.1:
version "4.2.1"
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-4.2.1.tgz#65a4bb2631e90e02420dba5554c375a4754bb836"
dependencies:
@@ -6057,10 +5987,6 @@ thunky@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/thunky/-/thunky-0.1.0.tgz#bf30146824e2b6e67b0f2d7a4ac8beb26908684e"
-time-stamp@^2.0.0:
- version "2.0.0"
- resolved "https://registry.yarnpkg.com/time-stamp/-/time-stamp-2.0.0.tgz#95c6a44530e15ba8d6f4a3ecb8c3a3fac46da357"
-
timeago.js@^2.0.5:
version "2.0.5"
resolved "https://registry.yarnpkg.com/timeago.js/-/timeago.js-2.0.5.tgz#730c74fbdb0b0917a553675a4460e3a7f80db86c"
@@ -6089,18 +6015,12 @@ tiny-emitter@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-1.1.0.tgz#ab405a21ffed814a76c19739648093d70654fecb"
-tmp@0.0.31:
+tmp@0.0.31, tmp@0.0.x:
version "0.0.31"
resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.31.tgz#8f38ab9438e17315e5dbd8b3657e8bfb277ae4a7"
dependencies:
os-tmpdir "~1.0.1"
-tmp@0.0.x:
- version "0.0.33"
- resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9"
- dependencies:
- os-tmpdir "~1.0.2"
-
to-array@0.1.4:
version "0.1.4"
resolved "https://registry.yarnpkg.com/to-array/-/to-array-0.1.4.tgz#17e6c11f73dd4f3d74cda7a4ff3238e9ad9bf890"
@@ -6141,6 +6061,15 @@ tryit@^1.0.1:
version "1.0.3"
resolved "https://registry.yarnpkg.com/tryit/-/tryit-1.0.3.tgz#393be730a9446fd1ead6da59a014308f36c289cb"
+ts-loader@^3.1.1:
+ version "3.1.1"
+ resolved "https://registry.yarnpkg.com/ts-loader/-/ts-loader-3.1.1.tgz#602d93c12029eaf8fa1ee478a90785d40c5f6658"
+ dependencies:
+ chalk "^2.3.0"
+ enhanced-resolve "^3.0.0"
+ loader-utils "^1.0.2"
+ semver "^5.0.1"
+
tty-browserify@0.0.0:
version "0.0.0"
resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6"
@@ -6172,6 +6101,10 @@ typedarray@^0.0.6:
version "0.0.6"
resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
+typescript@^2.6.1:
+ version "2.6.1"
+ resolved "https://registry.yarnpkg.com/typescript/-/typescript-2.6.1.tgz#ef39cdea27abac0b500242d6726ab90e0c846631"
+
uglify-js@^2.6, uglify-js@^2.8.29:
version "2.8.29"
resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.8.29.tgz#29c5733148057bb4e1f75df35b7a9cb72e6a59dd"
@@ -6447,7 +6380,7 @@ webpack-bundle-analyzer@^2.8.2:
opener "^1.4.3"
ws "^2.3.1"
-webpack-dev-middleware@^1.0.11:
+webpack-dev-middleware@^1.0.11, webpack-dev-middleware@^1.11.0:
version "1.11.0"
resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-1.11.0.tgz#09691d0973a30ad1f82ac73a12e2087f0a4754f9"
dependencies:
@@ -6456,16 +6389,6 @@ webpack-dev-middleware@^1.0.11:
path-is-absolute "^1.0.0"
range-parser "^1.0.3"
-webpack-dev-middleware@^1.11.0:
- version "1.12.0"
- resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-1.12.0.tgz#d34efefb2edda7e1d3b5dbe07289513219651709"
- dependencies:
- memory-fs "~0.4.1"
- mime "^1.3.4"
- path-is-absolute "^1.0.0"
- range-parser "^1.0.3"
- time-stamp "^2.0.0"
-
webpack-dev-server@^2.6.1:
version "2.7.1"
resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-2.7.1.tgz#21580f5a08cd065c71144cf6f61c345bca59a8b8"
@@ -6554,18 +6477,12 @@ which-module@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a"
-which@^1.1.1, which@^1.2.1:
+which@^1.1.1, which@^1.2.1, which@^1.2.9:
version "1.2.12"
resolved "https://registry.yarnpkg.com/which/-/which-1.2.12.tgz#de67b5e450269f194909ef23ece4ebe416fa1192"
dependencies:
isexe "^1.1.1"
-which@^1.2.9:
- version "1.3.0"
- resolved "https://registry.yarnpkg.com/which/-/which-1.3.0.tgz#ff04bdfc010ee547d780bec38e1ac1c2777d253a"
- dependencies:
- isexe "^2.0.0"
-
wide-align@^1.1.0:
version "1.1.2"
resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.2.tgz#571e0f1b0604636ebc0dfc21b0339bbe31341710"