summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/assets/images/auth_buttons/signin_with_google.pngbin0 -> 3983 bytes
-rw-r--r--app/assets/javascripts/clusters.js112
-rw-r--r--app/assets/javascripts/dispatcher.js5
-rw-r--r--app/assets/stylesheets/pages/clusters.scss9
-rw-r--r--app/controllers/google_api/authorizations_controller.rb29
-rw-r--r--app/controllers/projects/clusters_controller.rb136
-rw-r--r--app/helpers/projects_helper.rb1
-rw-r--r--app/models/gcp/cluster.rb113
-rw-r--r--app/models/project.rb1
-rw-r--r--app/policies/gcp/cluster_policy.rb12
-rw-r--r--app/policies/project_policy.rb2
-rw-r--r--app/presenters/gcp/cluster_presenter.rb9
-rw-r--r--app/serializers/cluster_entity.rb6
-rw-r--r--app/serializers/cluster_serializer.rb7
-rw-r--r--app/services/ci/create_cluster_service.rb15
-rw-r--r--app/services/ci/fetch_gcp_operation_service.rb17
-rw-r--r--app/services/ci/fetch_kubernetes_token_service.rb72
-rw-r--r--app/services/ci/finalize_cluster_creation_service.rb33
-rw-r--r--app/services/ci/integrate_cluster_service.rb26
-rw-r--r--app/services/ci/provision_cluster_service.rb36
-rw-r--r--app/services/ci/update_cluster_service.rb22
-rw-r--r--app/views/layouts/nav/sidebar/_project.html.haml8
-rw-r--r--app/views/projects/clusters/_form.html.haml37
-rw-r--r--app/views/projects/clusters/_header.html.haml14
-rw-r--r--app/views/projects/clusters/_sidebar.html.haml7
-rw-r--r--app/views/projects/clusters/login.html.haml16
-rw-r--r--app/views/projects/clusters/new.html.haml9
-rw-r--r--app/views/projects/clusters/show.html.haml70
-rw-r--r--app/workers/cluster_provision_worker.rb10
-rw-r--r--app/workers/concerns/cluster_queue.rb10
-rw-r--r--app/workers/wait_for_cluster_creation_worker.rb27
31 files changed, 870 insertions, 1 deletions
diff --git a/app/assets/images/auth_buttons/signin_with_google.png b/app/assets/images/auth_buttons/signin_with_google.png
new file mode 100644
index 00000000000..b1327b4f7b4
--- /dev/null
+++ b/app/assets/images/auth_buttons/signin_with_google.png
Binary files differ
diff --git a/app/assets/javascripts/clusters.js b/app/assets/javascripts/clusters.js
new file mode 100644
index 00000000000..50dbeb06362
--- /dev/null
+++ b/app/assets/javascripts/clusters.js
@@ -0,0 +1,112 @@
+/* globals Flash */
+import Visibility from 'visibilityjs';
+import axios from 'axios';
+import Poll from './lib/utils/poll';
+import { s__ } from './locale';
+import './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;
+ }
+ fetchData() {
+ return axios.get(this.options.endpoint);
+ }
+}
+
+export default class Clusters {
+ constructor() {
+ 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) => {
+ const { status, status_reason } = data.data;
+ this.updateContainer(status, status_reason);
+ },
+ errorCallback: () => {
+ Flash(s__('ClusterIntegration|Something went wrong on our end.'));
+ },
+ });
+
+ if (!Visibility.hidden()) {
+ this.poll.makeRequest();
+ } else {
+ this.service.fetchData();
+ }
+
+ Visibility.change(() => {
+ if (!Visibility.hidden()) {
+ this.poll.restart();
+ } else {
+ this.poll.stop();
+ }
+ });
+ }
+
+ 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/dispatcher.js b/app/assets/javascripts/dispatcher.js
index bbaa4e4d91e..e4e7cae540e 100644
--- a/app/assets/javascripts/dispatcher.js
+++ b/app/assets/javascripts/dispatcher.js
@@ -525,6 +525,11 @@ import { ajaxGet, convertPermissionToBoolean } from './lib/utils/common_utils';
case 'admin:impersonation_tokens:index':
new gl.DueDateSelectors();
break;
+ case 'projects:clusters:show':
+ import(/* webpackChunkName: "clusters" */ './clusters')
+ .then(cluster => new cluster.default()) // eslint-disable-line new-cap
+ .catch(() => {});
+ break;
}
switch (path[0]) {
case 'sessions':
diff --git a/app/assets/stylesheets/pages/clusters.scss b/app/assets/stylesheets/pages/clusters.scss
new file mode 100644
index 00000000000..5538e46a6c4
--- /dev/null
+++ b/app/assets/stylesheets/pages/clusters.scss
@@ -0,0 +1,9 @@
+.edit-cluster-form {
+ .clipboard-addon {
+ background-color: $white-light;
+ }
+
+ .alert-block {
+ margin-bottom: 20px;
+ }
+}
diff --git a/app/controllers/google_api/authorizations_controller.rb b/app/controllers/google_api/authorizations_controller.rb
new file mode 100644
index 00000000000..5551057ff55
--- /dev/null
+++ b/app/controllers/google_api/authorizations_controller.rb
@@ -0,0 +1,29 @@
+module GoogleApi
+ class AuthorizationsController < ApplicationController
+ def callback
+ token, expires_at = GoogleApi::CloudPlatform::Client
+ .new(nil, callback_google_api_auth_url)
+ .get_token(params[:code])
+
+ session[GoogleApi::CloudPlatform::Client.session_key_for_token] = token
+ session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at] =
+ expires_at.to_s
+
+ state_redirect_uri = redirect_uri_from_session_key(params[:state])
+
+ if state_redirect_uri
+ redirect_to state_redirect_uri
+ else
+ redirect_to root_path
+ end
+ end
+
+ private
+
+ def redirect_uri_from_session_key(state)
+ key = GoogleApi::CloudPlatform::Client
+ .session_key_for_redirect_uri(params[:state])
+ session[key] if key
+ end
+ end
+end
diff --git a/app/controllers/projects/clusters_controller.rb b/app/controllers/projects/clusters_controller.rb
new file mode 100644
index 00000000000..03019b0becc
--- /dev/null
+++ b/app/controllers/projects/clusters_controller.rb
@@ -0,0 +1,136 @@
+class Projects::ClustersController < Projects::ApplicationController
+ before_action :cluster, except: [:login, :index, :new, :create]
+ before_action :authorize_read_cluster!
+ before_action :authorize_create_cluster!, only: [:new, :create]
+ before_action :authorize_google_api, only: [:new, :create]
+ before_action :authorize_update_cluster!, only: [:update]
+ before_action :authorize_admin_cluster!, only: [:destroy]
+
+ def index
+ if project.cluster
+ redirect_to project_cluster_path(project, project.cluster)
+ else
+ redirect_to new_project_cluster_path(project)
+ end
+ end
+
+ def login
+ begin
+ state = generate_session_key_redirect(namespace_project_clusters_url.to_s)
+
+ @authorize_url = GoogleApi::CloudPlatform::Client.new(
+ nil, callback_google_api_auth_url,
+ state: state).authorize_url
+ rescue GoogleApi::Auth::ConfigMissingError
+ # no-op
+ end
+ end
+
+ def new
+ @cluster = project.build_cluster
+ end
+
+ def create
+ @cluster = Ci::CreateClusterService
+ .new(project, current_user, create_params)
+ .execute(token_in_session)
+
+ if @cluster.persisted?
+ redirect_to project_cluster_path(project, @cluster)
+ else
+ render :new
+ end
+ end
+
+ def status
+ respond_to do |format|
+ format.json do
+ Gitlab::PollingInterval.set_header(response, interval: 10_000)
+
+ render json: ClusterSerializer
+ .new(project: @project, current_user: @current_user)
+ .represent_status(@cluster)
+ end
+ end
+ end
+
+ def show
+ end
+
+ def update
+ Ci::UpdateClusterService
+ .new(project, current_user, update_params)
+ .execute(cluster)
+
+ if cluster.valid?
+ flash[:notice] = "Cluster was successfully updated."
+ redirect_to project_cluster_path(project, project.cluster)
+ else
+ render :show
+ end
+ end
+
+ def destroy
+ if cluster.destroy
+ flash[:notice] = "Cluster integration was successfully removed."
+ redirect_to project_clusters_path(project), status: 302
+ else
+ flash[:notice] = "Cluster integration was not removed."
+ render :show
+ end
+ end
+
+ private
+
+ def cluster
+ @cluster ||= project.cluster.present(current_user: current_user)
+ end
+
+ def create_params
+ params.require(:cluster).permit(
+ :gcp_project_id,
+ :gcp_cluster_zone,
+ :gcp_cluster_name,
+ :gcp_cluster_size,
+ :gcp_machine_type,
+ :project_namespace,
+ :enabled)
+ end
+
+ def update_params
+ params.require(:cluster).permit(
+ :project_namespace,
+ :enabled)
+ end
+
+ def authorize_google_api
+ unless GoogleApi::CloudPlatform::Client.new(token_in_session, nil)
+ .validate_token(expires_at_in_session)
+ redirect_to action: 'login'
+ end
+ end
+
+ def token_in_session
+ @token_in_session ||=
+ session[GoogleApi::CloudPlatform::Client.session_key_for_token]
+ end
+
+ def expires_at_in_session
+ @expires_at_in_session ||=
+ session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at]
+ end
+
+ def generate_session_key_redirect(uri)
+ GoogleApi::CloudPlatform::Client.new_session_key_for_redirect_uri do |key|
+ session[key] = uri
+ end
+ end
+
+ def authorize_update_cluster!
+ access_denied! unless can?(current_user, :update_cluster, cluster)
+ end
+
+ def authorize_admin_cluster!
+ access_denied! unless can?(current_user, :admin_cluster, cluster)
+ end
+end
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index 4c0cce54527..20e050195ea 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -293,6 +293,7 @@ module ProjectsHelper
snippets: :read_project_snippet,
settings: :admin_project,
builds: :read_build,
+ clusters: :read_cluster,
labels: :read_label,
issues: :read_issue,
project_members: :read_project_member,
diff --git a/app/models/gcp/cluster.rb b/app/models/gcp/cluster.rb
new file mode 100644
index 00000000000..18bd6a6dcb4
--- /dev/null
+++ b/app/models/gcp/cluster.rb
@@ -0,0 +1,113 @@
+module Gcp
+ class Cluster < ActiveRecord::Base
+ extend Gitlab::Gcp::Model
+ include Presentable
+
+ belongs_to :project, inverse_of: :cluster
+ belongs_to :user
+ belongs_to :service
+
+ default_value_for :gcp_cluster_zone, 'us-central1-a'
+ default_value_for :gcp_cluster_size, 3
+ default_value_for :gcp_machine_type, 'n1-standard-4'
+
+ attr_encrypted :password,
+ mode: :per_attribute_iv,
+ key: Gitlab::Application.secrets.db_key_base,
+ algorithm: 'aes-256-cbc'
+
+ attr_encrypted :kubernetes_token,
+ mode: :per_attribute_iv,
+ key: Gitlab::Application.secrets.db_key_base,
+ algorithm: 'aes-256-cbc'
+
+ attr_encrypted :gcp_token,
+ mode: :per_attribute_iv,
+ key: Gitlab::Application.secrets.db_key_base,
+ algorithm: 'aes-256-cbc'
+
+ validates :gcp_project_id,
+ length: 1..63,
+ format: {
+ with: Gitlab::Regex.kubernetes_namespace_regex,
+ message: Gitlab::Regex.kubernetes_namespace_regex_message
+ }
+
+ validates :gcp_cluster_name,
+ length: 1..63,
+ format: {
+ with: Gitlab::Regex.kubernetes_namespace_regex,
+ message: Gitlab::Regex.kubernetes_namespace_regex_message
+ }
+
+ validates :gcp_cluster_zone, presence: true
+
+ validates :gcp_cluster_size,
+ presence: true,
+ numericality: {
+ only_integer: true,
+ greater_than: 0
+ }
+
+ validates :project_namespace,
+ allow_blank: true,
+ length: 1..63,
+ format: {
+ with: Gitlab::Regex.kubernetes_namespace_regex,
+ message: Gitlab::Regex.kubernetes_namespace_regex_message
+ }
+
+ # if we do not do status transition we prevent change
+ validate :restrict_modification, on: :update, unless: :status_changed?
+
+ state_machine :status, initial: :scheduled do
+ state :scheduled, value: 1
+ state :creating, value: 2
+ state :created, value: 3
+ state :errored, value: 4
+
+ event :make_creating do
+ transition any - [:creating] => :creating
+ end
+
+ event :make_created do
+ transition any - [:created] => :created
+ end
+
+ event :make_errored do
+ transition any - [:errored] => :errored
+ end
+
+ before_transition any => [:errored, :created] do |cluster|
+ cluster.gcp_token = nil
+ cluster.gcp_operation_id = nil
+ end
+
+ before_transition any => [:errored] do |cluster, transition|
+ status_reason = transition.args.first
+ cluster.status_reason = status_reason if status_reason
+ end
+ end
+
+ def project_namespace_placeholder
+ "#{project.path}-#{project.id}"
+ end
+
+ def on_creation?
+ scheduled? || creating?
+ end
+
+ def api_url
+ 'https://' + endpoint if endpoint
+ end
+
+ def restrict_modification
+ if on_creation?
+ errors.add(:base, "cannot modify during creation")
+ return false
+ end
+
+ true
+ end
+ end
+end
diff --git a/app/models/project.rb b/app/models/project.rb
index 5f80195028a..e51e70f01b7 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -165,6 +165,7 @@ class Project < ActiveRecord::Base
has_one :import_data, class_name: 'ProjectImportData', inverse_of: :project, autosave: true
has_one :project_feature, inverse_of: :project
has_one :statistics, class_name: 'ProjectStatistics'
+ has_one :cluster, class_name: 'Gcp::Cluster', inverse_of: :project
# 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/policies/gcp/cluster_policy.rb b/app/policies/gcp/cluster_policy.rb
new file mode 100644
index 00000000000..e77173ea6e1
--- /dev/null
+++ b/app/policies/gcp/cluster_policy.rb
@@ -0,0 +1,12 @@
+module Gcp
+ class ClusterPolicy < BasePolicy
+ alias_method :cluster, :subject
+
+ delegate { @subject.project }
+
+ rule { can?(:master_access) }.policy do
+ enable :update_cluster
+ enable :admin_cluster
+ end
+ end
+end
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index b7b5bd34189..f599eab42f2 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -193,6 +193,8 @@ class ProjectPolicy < BasePolicy
enable :admin_pages
enable :read_pages
enable :update_pages
+ enable :read_cluster
+ enable :create_cluster
end
rule { can?(:public_user_access) }.policy do
diff --git a/app/presenters/gcp/cluster_presenter.rb b/app/presenters/gcp/cluster_presenter.rb
new file mode 100644
index 00000000000..f7908f92a37
--- /dev/null
+++ b/app/presenters/gcp/cluster_presenter.rb
@@ -0,0 +1,9 @@
+module Gcp
+ class ClusterPresenter < Gitlab::View::Presenter::Delegated
+ presents :cluster
+
+ def gke_cluster_url
+ "https://console.cloud.google.com/kubernetes/clusters/details/#{gcp_cluster_zone}/#{gcp_cluster_name}"
+ end
+ end
+end
diff --git a/app/serializers/cluster_entity.rb b/app/serializers/cluster_entity.rb
new file mode 100644
index 00000000000..08a113c4d8a
--- /dev/null
+++ b/app/serializers/cluster_entity.rb
@@ -0,0 +1,6 @@
+class ClusterEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :status_name, as: :status
+ expose :status_reason
+end
diff --git a/app/serializers/cluster_serializer.rb b/app/serializers/cluster_serializer.rb
new file mode 100644
index 00000000000..2c87202a105
--- /dev/null
+++ b/app/serializers/cluster_serializer.rb
@@ -0,0 +1,7 @@
+class ClusterSerializer < BaseSerializer
+ entity ClusterEntity
+
+ def represent_status(resource)
+ represent(resource, { only: [:status, :status_reason] })
+ end
+end
diff --git a/app/services/ci/create_cluster_service.rb b/app/services/ci/create_cluster_service.rb
new file mode 100644
index 00000000000..f7ee0e468e2
--- /dev/null
+++ b/app/services/ci/create_cluster_service.rb
@@ -0,0 +1,15 @@
+module Ci
+ class CreateClusterService < BaseService
+ def execute(access_token)
+ params['gcp_machine_type'] ||= GoogleApi::CloudPlatform::Client::DEFAULT_MACHINE_TYPE
+
+ cluster_params =
+ params.merge(user: current_user,
+ gcp_token: access_token)
+
+ project.create_cluster(cluster_params).tap do |cluster|
+ ClusterProvisionWorker.perform_async(cluster.id) if cluster.persisted?
+ end
+ end
+ end
+end
diff --git a/app/services/ci/fetch_gcp_operation_service.rb b/app/services/ci/fetch_gcp_operation_service.rb
new file mode 100644
index 00000000000..0b68e4d6ea9
--- /dev/null
+++ b/app/services/ci/fetch_gcp_operation_service.rb
@@ -0,0 +1,17 @@
+module Ci
+ class FetchGcpOperationService
+ def execute(cluster)
+ api_client =
+ GoogleApi::CloudPlatform::Client.new(cluster.gcp_token, nil)
+
+ operation = api_client.projects_zones_operations(
+ cluster.gcp_project_id,
+ cluster.gcp_cluster_zone,
+ cluster.gcp_operation_id)
+
+ yield(operation) if block_given?
+ rescue Google::Apis::ServerError, Google::Apis::ClientError, Google::Apis::AuthorizationError => e
+ return cluster.make_errored!("Failed to request to CloudPlatform; #{e.message}")
+ end
+ end
+end
diff --git a/app/services/ci/fetch_kubernetes_token_service.rb b/app/services/ci/fetch_kubernetes_token_service.rb
new file mode 100644
index 00000000000..44da87cb00c
--- /dev/null
+++ b/app/services/ci/fetch_kubernetes_token_service.rb
@@ -0,0 +1,72 @@
+##
+# TODO:
+# Almost components in this class were copied from app/models/project_services/kubernetes_service.rb
+# We should dry up those classes not to repeat the same code.
+# Maybe we should have a special facility (e.g. lib/kubernetes_api) to maintain all Kubernetes API caller.
+module Ci
+ class FetchKubernetesTokenService
+ attr_reader :api_url, :ca_pem, :username, :password
+
+ def initialize(api_url, ca_pem, username, password)
+ @api_url = api_url
+ @ca_pem = ca_pem
+ @username = username
+ @password = password
+ end
+
+ def execute
+ read_secrets.each do |secret|
+ name = secret.dig('metadata', 'name')
+ if /default-token/ =~ name
+ token_base64 = secret.dig('data', 'token')
+ return Base64.decode64(token_base64) if token_base64
+ end
+ end
+
+ nil
+ end
+
+ private
+
+ def read_secrets
+ kubeclient = build_kubeclient!
+
+ kubeclient.get_secrets.as_json
+ rescue KubeException => err
+ raise err unless err.error_code == 404
+ []
+ end
+
+ def build_kubeclient!(api_path: 'api', api_version: 'v1')
+ raise "Incomplete settings" unless api_url && username && password
+
+ ::Kubeclient::Client.new(
+ join_api_url(api_path),
+ api_version,
+ auth_options: { username: username, password: password },
+ ssl_options: kubeclient_ssl_options,
+ http_proxy_uri: ENV['http_proxy']
+ )
+ end
+
+ def join_api_url(api_path)
+ url = URI.parse(api_url)
+ prefix = url.path.sub(%r{/+\z}, '')
+
+ url.path = [prefix, api_path].join("/")
+
+ url.to_s
+ end
+
+ def kubeclient_ssl_options
+ opts = { verify_ssl: OpenSSL::SSL::VERIFY_PEER }
+
+ if ca_pem.present?
+ opts[:cert_store] = OpenSSL::X509::Store.new
+ opts[:cert_store].add_cert(OpenSSL::X509::Certificate.new(ca_pem))
+ end
+
+ opts
+ end
+ end
+end
diff --git a/app/services/ci/finalize_cluster_creation_service.rb b/app/services/ci/finalize_cluster_creation_service.rb
new file mode 100644
index 00000000000..347875c5697
--- /dev/null
+++ b/app/services/ci/finalize_cluster_creation_service.rb
@@ -0,0 +1,33 @@
+module Ci
+ class FinalizeClusterCreationService
+ def execute(cluster)
+ api_client =
+ GoogleApi::CloudPlatform::Client.new(cluster.gcp_token, nil)
+
+ begin
+ gke_cluster = api_client.projects_zones_clusters_get(
+ cluster.gcp_project_id,
+ cluster.gcp_cluster_zone,
+ cluster.gcp_cluster_name)
+ rescue Google::Apis::ServerError, Google::Apis::ClientError, Google::Apis::AuthorizationError => e
+ return cluster.make_errored!("Failed to request to CloudPlatform; #{e.message}")
+ end
+
+ endpoint = gke_cluster.endpoint
+ api_url = 'https://' + endpoint
+ ca_cert = Base64.decode64(gke_cluster.master_auth.cluster_ca_certificate)
+ username = gke_cluster.master_auth.username
+ password = gke_cluster.master_auth.password
+
+ kubernetes_token = Ci::FetchKubernetesTokenService.new(
+ api_url, ca_cert, username, password).execute
+
+ unless kubernetes_token
+ return cluster.make_errored!('Failed to get a default token of kubernetes')
+ end
+
+ Ci::IntegrateClusterService.new.execute(
+ cluster, endpoint, ca_cert, kubernetes_token, username, password)
+ end
+ end
+end
diff --git a/app/services/ci/integrate_cluster_service.rb b/app/services/ci/integrate_cluster_service.rb
new file mode 100644
index 00000000000..d123ce8d26b
--- /dev/null
+++ b/app/services/ci/integrate_cluster_service.rb
@@ -0,0 +1,26 @@
+module Ci
+ class IntegrateClusterService
+ def execute(cluster, endpoint, ca_cert, token, username, password)
+ Gcp::Cluster.transaction do
+ cluster.update!(
+ enabled: true,
+ endpoint: endpoint,
+ ca_cert: ca_cert,
+ kubernetes_token: token,
+ username: username,
+ password: password,
+ service: cluster.project.find_or_initialize_service('kubernetes'),
+ status_event: :make_created)
+
+ cluster.service.update!(
+ active: true,
+ api_url: cluster.api_url,
+ ca_pem: ca_cert,
+ namespace: cluster.project_namespace,
+ token: token)
+ end
+ rescue ActiveRecord::RecordInvalid => e
+ cluster.make_errored!("Failed to integrate cluster into kubernetes_service: #{e.message}")
+ end
+ end
+end
diff --git a/app/services/ci/provision_cluster_service.rb b/app/services/ci/provision_cluster_service.rb
new file mode 100644
index 00000000000..52d80b01813
--- /dev/null
+++ b/app/services/ci/provision_cluster_service.rb
@@ -0,0 +1,36 @@
+module Ci
+ class ProvisionClusterService
+ def execute(cluster)
+ api_client =
+ GoogleApi::CloudPlatform::Client.new(cluster.gcp_token, nil)
+
+ begin
+ operation = api_client.projects_zones_clusters_create(
+ cluster.gcp_project_id,
+ cluster.gcp_cluster_zone,
+ cluster.gcp_cluster_name,
+ cluster.gcp_cluster_size,
+ machine_type: cluster.gcp_machine_type)
+ rescue Google::Apis::ServerError, Google::Apis::ClientError, Google::Apis::AuthorizationError => e
+ return cluster.make_errored!("Failed to request to CloudPlatform; #{e.message}")
+ end
+
+ unless operation.status == 'RUNNING' || operation.status == 'PENDING'
+ return cluster.make_errored!("Operation status is unexpected; #{operation.status_message}")
+ end
+
+ cluster.gcp_operation_id = api_client.parse_operation_id(operation.self_link)
+
+ unless cluster.gcp_operation_id
+ return cluster.make_errored!('Can not find operation_id from self_link')
+ end
+
+ if cluster.make_creating
+ WaitForClusterCreationWorker.perform_in(
+ WaitForClusterCreationWorker::INITIAL_INTERVAL, cluster.id)
+ else
+ return cluster.make_errored!("Failed to update cluster record; #{cluster.errors}")
+ end
+ end
+ end
+end
diff --git a/app/services/ci/update_cluster_service.rb b/app/services/ci/update_cluster_service.rb
new file mode 100644
index 00000000000..70d88fca660
--- /dev/null
+++ b/app/services/ci/update_cluster_service.rb
@@ -0,0 +1,22 @@
+module Ci
+ class UpdateClusterService < BaseService
+ def execute(cluster)
+ Gcp::Cluster.transaction do
+ cluster.update!(params)
+
+ if params['enabled'] == 'true'
+ cluster.service.update!(
+ active: true,
+ api_url: cluster.api_url,
+ ca_pem: cluster.ca_cert,
+ namespace: cluster.project_namespace,
+ token: cluster.kubernetes_token)
+ else
+ cluster.service.update!(active: false)
+ end
+ end
+ rescue ActiveRecord::RecordInvalid => e
+ cluster.errors.add(:base, e.message)
+ end
+ end
+end
diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml
index 8765b814405..759d6ff68ea 100644
--- a/app/views/layouts/nav/sidebar/_project.html.haml
+++ b/app/views/layouts/nav/sidebar/_project.html.haml
@@ -146,7 +146,7 @@
= number_with_delimiter(@project.open_merge_requests_count)
- if project_nav_tab? :pipelines
- = nav_link(controller: [:pipelines, :builds, :jobs, :pipeline_schedules, :environments, :artifacts]) do
+ = nav_link(controller: [:pipelines, :builds, :jobs, :pipeline_schedules, :environments, :artifacts, :clusters]) do
= link_to project_pipelines_path(@project), class: 'shortcuts-pipelines' do
.nav-icon-container
= sprite_icon('pipeline')
@@ -189,6 +189,12 @@
%span
Charts
+ - if project_nav_tab? :clusters
+ = nav_link(controller: :clusters) do
+ = link_to project_clusters_path(@project), title: 'Cluster', class: 'shortcuts-cluster' do
+ %span
+ Cluster
+
- if project_nav_tab? :wiki
= nav_link(controller: :wikis) do
= link_to get_project_wiki_path(@project), class: 'shortcuts-wiki' do
diff --git a/app/views/projects/clusters/_form.html.haml b/app/views/projects/clusters/_form.html.haml
new file mode 100644
index 00000000000..371cdb1e403
--- /dev/null
+++ b/app/views/projects/clusters/_form.html.haml
@@ -0,0 +1,37 @@
+.row
+ .col-sm-8.col-sm-offset-4
+ %p
+ - link_to_help_page = link_to(s_('ClusterIntegration|help page'), help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer')
+ = s_('ClusterIntegration|Read our %{link_to_help_page} on cluster integration.').html_safe % { link_to_help_page: link_to_help_page}
+
+ = form_for [@project.namespace.becomes(Namespace), @project, @cluster] do |field|
+ = form_errors(@cluster)
+ .form-group
+ = field.label :gcp_cluster_name, s_('ClusterIntegration|Cluster name')
+ = field.text_field :gcp_cluster_name, class: 'form-control'
+
+ .form-group
+ = field.label :gcp_project_id, s_('ClusterIntegration|Google Cloud Platform project ID')
+ = link_to(s_('ClusterIntegration|See your projects'), 'https://console.cloud.google.com/home/dashboard', target: '_blank', rel: 'noopener noreferrer')
+ = field.text_field :gcp_project_id, class: 'form-control'
+
+ .form-group
+ = field.label :gcp_cluster_zone, s_('ClusterIntegration|Zone')
+ = link_to(s_('ClusterIntegration|See zones'), 'https://cloud.google.com/compute/docs/regions-zones/regions-zones', target: '_blank', rel: 'noopener noreferrer')
+ = field.text_field :gcp_cluster_zone, class: 'form-control', placeholder: 'us-central1-a'
+
+ .form-group
+ = field.label :gcp_cluster_size, s_('ClusterIntegration|Number of nodes')
+ = field.text_field :gcp_cluster_size, class: 'form-control', placeholder: '3'
+
+ .form-group
+ = field.label :gcp_machine_type, s_('ClusterIntegration|Machine type')
+ = link_to(s_('ClusterIntegration|See machine types'), 'https://cloud.google.com/compute/docs/machine-types', target: '_blank', rel: 'noopener noreferrer')
+ = field.text_field :gcp_machine_type, class: 'form-control', placeholder: 'n1-standard-4'
+
+ .form-group
+ = field.label :project_namespace, s_('ClusterIntegration|Project namespace (optional, unique)')
+ = field.text_field :project_namespace, class: 'form-control', placeholder: @cluster.project_namespace_placeholder
+
+ .form-group
+ = field.submit s_('ClusterIntegration|Create cluster'), class: 'btn btn-save'
diff --git a/app/views/projects/clusters/_header.html.haml b/app/views/projects/clusters/_header.html.haml
new file mode 100644
index 00000000000..0134d46491c
--- /dev/null
+++ b/app/views/projects/clusters/_header.html.haml
@@ -0,0 +1,14 @@
+%h4.prepend-top-0
+ = s_('ClusterIntegration|Create new cluster on Google Container Engine')
+%p
+ = s_('ClusterIntegration|Please make sure that your Google account meets the following requirements:')
+%ul
+ %li
+ - link_to_container_engine = link_to(s_('ClusterIntegration|access to Google Container Engine'), 'https://console.cloud.google.com', target: '_blank', rel: 'noopener noreferrer')
+ = s_('ClusterIntegration|Your account must have %{link_to_container_engine}').html_safe % { link_to_container_engine: link_to_container_engine }
+ %li
+ - link_to_requirements = link_to(s_('ClusterIntegration|meets the requirements'), 'https://cloud.google.com/container-engine/docs/quickstart', target: '_blank', rel: 'noopener noreferrer')
+ = s_('ClusterIntegration|Make sure your account %{link_to_requirements} to create clusters').html_safe % { link_to_requirements: link_to_requirements }
+ %li
+ - link_to_container_project = link_to(s_('ClusterIntegration|Google Container Engine project'), target: '_blank', rel: 'noopener noreferrer')
+ = s_('ClusterIntegration|A %{link_to_container_project} must have been created under this account').html_safe % { link_to_container_project: link_to_container_project }
diff --git a/app/views/projects/clusters/_sidebar.html.haml b/app/views/projects/clusters/_sidebar.html.haml
new file mode 100644
index 00000000000..761879db32b
--- /dev/null
+++ b/app/views/projects/clusters/_sidebar.html.haml
@@ -0,0 +1,7 @@
+%h4.prepend-top-0
+ = s_('ClusterIntegration|Cluster integration')
+%p
+ = s_('ClusterIntegration|With a cluster associated to this project, you can use review apps, deploy your applications, run your pipelines, and much more in an easy way.')
+%p
+ - link = link_to(s_('ClusterIntegration|cluster'), help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer')
+ = s_('ClusterIntegration|Learn more about %{link_to_documentation}').html_safe % { link_to_documentation: link }
diff --git a/app/views/projects/clusters/login.html.haml b/app/views/projects/clusters/login.html.haml
new file mode 100644
index 00000000000..ae132672b7e
--- /dev/null
+++ b/app/views/projects/clusters/login.html.haml
@@ -0,0 +1,16 @@
+- breadcrumb_title "Cluster"
+- page_title _("Login")
+
+.row.prepend-top-default
+ .col-sm-4
+ = render 'sidebar'
+ .col-sm-8
+ = render 'header'
+.row
+ .col-sm-8.col-sm-offset-4.signin-with-google
+ - if @authorize_url
+ = link_to @authorize_url do
+ = image_tag('auth_buttons/signin_with_google.png')
+ - else
+ - link = link_to(s_('ClusterIntegration|properly configured'), help_page_path("integration/google"), target: '_blank', rel: 'noopener noreferrer')
+ = s_('Google authentication is not %{link_to_documentation}. Ask your GitLab administrator if you want to use this service.').html_safe % { link_to_documentation: link }
diff --git a/app/views/projects/clusters/new.html.haml b/app/views/projects/clusters/new.html.haml
new file mode 100644
index 00000000000..c538d41ffad
--- /dev/null
+++ b/app/views/projects/clusters/new.html.haml
@@ -0,0 +1,9 @@
+- breadcrumb_title "Cluster"
+- page_title _("New Cluster")
+
+.row.prepend-top-default
+ .col-sm-4
+ = render 'sidebar'
+ .col-sm-8
+ = render 'header'
+= render 'form'
diff --git a/app/views/projects/clusters/show.html.haml b/app/views/projects/clusters/show.html.haml
new file mode 100644
index 00000000000..aee6f904a62
--- /dev/null
+++ b/app/views/projects/clusters/show.html.haml
@@ -0,0 +1,70 @@
+- breadcrumb_title "Cluster"
+- page_title _("Cluster")
+
+- 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?
+.row.prepend-top-default.edit-cluster-form.js-edit-cluster-form{ data: { status_path: status_path,
+ toggle_status: @cluster.enabled? ? 'true': 'false',
+ cluster_status: @cluster.status_name,
+ cluster_status_reason: @cluster.status_reason } }
+ .col-sm-4
+ = render 'sidebar'
+ .col-sm-8
+ %label.append-bottom-10{ for: 'enable-cluster-integration' }
+ = s_('ClusterIntegration|Enable cluster integration')
+ %p
+ - if @cluster.enabled?
+ - if can?(current_user, :update_cluster, @cluster)
+ = s_('ClusterIntegration|Cluster integration is enabled for this project. Disabling this integration will not affect your cluster, it will only temporarily turn off GitLab\'s connection to it.')
+ - else
+ = s_('ClusterIntegration|Cluster integration is enabled for this project.')
+ - else
+ = s_('ClusterIntegration|Cluster integration is disabled for this project.')
+
+ = form_for [@project.namespace.becomes(Namespace), @project, @cluster] do |field|
+ = form_errors(@cluster)
+ .form-group.append-bottom-20
+ %label.append-bottom-10
+ = field.hidden_field :enabled, { class: 'js-toggle-input'}
+
+ %button{ type: 'button',
+ class: "js-toggle-cluster project-feature-toggle #{'checked' unless !@cluster.enabled?} #{'disabled' unless can?(current_user, :update_cluster, @cluster)}",
+ 'aria-label': s_('ClusterIntegration|Toggle Cluster'),
+ disabled: !can?(current_user, :update_cluster, @cluster),
+ data: { 'enabled-text': 'Enabled', 'disabled-text': 'Disabled' } }
+
+ - if can?(current_user, :update_cluster, @cluster)
+ .form-group
+ = field.submit s_('ClusterIntegration|Save'), class: 'btn btn-success'
+
+ - if can?(current_user, :admin_cluster, @cluster)
+ %label.append-bottom-10{ for: 'google-container-engine' }
+ = s_('ClusterIntegration|Google Container Engine')
+ %p
+ - link_gke = link_to(s_('ClusterIntegration|Google Container Engine'), @cluster.gke_cluster_url, target: '_blank', rel: 'noopener noreferrer')
+ = s_('ClusterIntegration|Manage your cluster by visiting %{link_gke}').html_safe % { link_gke: link_gke }
+
+ .hidden.js-cluster-error.alert.alert-danger.alert-block{ role: 'alert' }
+ = s_('ClusterIntegration|Something went wrong while creating your cluster on Google Container Engine')
+ %p.js-error-reason
+
+ .hidden.js-cluster-creating.alert.alert-info.alert-block{ role: 'alert' }
+ = s_('ClusterIntegration|Cluster is being created on Google Container Engine...')
+
+ .hidden.js-cluster-success.alert.alert-success.alert-block{ role: 'alert' }
+ = s_('ClusterIntegration|Cluster was successfully created on Google Container Engine')
+
+ .form_group.append-bottom-20
+ %label.append-bottom-10{ for: 'cluter-name' }
+ = s_('ClusterIntegration|Cluster name')
+ .input-group
+ %input.form-control.cluster-name{ value: @cluster.gcp_cluster_name, disabled: true }
+ %span.input-group-addon.clipboard-addon
+ = clipboard_button(text: @cluster.gcp_cluster_name, title: s_('ClusterIntegration|Copy cluster name'))
+
+ - if can?(current_user, :admin_cluster, @cluster)
+ .well.form_group
+ %label.text-danger
+ = s_('ClusterIntegration|Remove cluster integration')
+ %p
+ = s_('ClusterIntegration|Removing cluster integration will remove the cluster configuration you have added to this project. It will not delete your project.')
+ = link_to(s_('ClusterIntegration|Remove integration'), namespace_project_cluster_path(@project.namespace, @project, @cluster.id), method: :delete, class: 'btn btn-danger', data: { confirm: "Are you sure you want to remove cluster integration from this project? This will not delete your cluster on Google Container Engine"})
diff --git a/app/workers/cluster_provision_worker.rb b/app/workers/cluster_provision_worker.rb
new file mode 100644
index 00000000000..63300b58a25
--- /dev/null
+++ b/app/workers/cluster_provision_worker.rb
@@ -0,0 +1,10 @@
+class ClusterProvisionWorker
+ include Sidekiq::Worker
+ include ClusterQueue
+
+ def perform(cluster_id)
+ Gcp::Cluster.find_by_id(cluster_id).try do |cluster|
+ Ci::ProvisionClusterService.new.execute(cluster)
+ end
+ end
+end
diff --git a/app/workers/concerns/cluster_queue.rb b/app/workers/concerns/cluster_queue.rb
new file mode 100644
index 00000000000..a5074d13220
--- /dev/null
+++ b/app/workers/concerns/cluster_queue.rb
@@ -0,0 +1,10 @@
+##
+# Concern for setting Sidekiq settings for the various Gcp clusters workers.
+#
+module ClusterQueue
+ extend ActiveSupport::Concern
+
+ included do
+ sidekiq_options queue: :gcp_cluster
+ end
+end
diff --git a/app/workers/wait_for_cluster_creation_worker.rb b/app/workers/wait_for_cluster_creation_worker.rb
new file mode 100644
index 00000000000..5aa3bbdaa9d
--- /dev/null
+++ b/app/workers/wait_for_cluster_creation_worker.rb
@@ -0,0 +1,27 @@
+class WaitForClusterCreationWorker
+ include Sidekiq::Worker
+ include ClusterQueue
+
+ INITIAL_INTERVAL = 2.minutes
+ EAGER_INTERVAL = 10.seconds
+ TIMEOUT = 20.minutes
+
+ def perform(cluster_id)
+ Gcp::Cluster.find_by_id(cluster_id).try do |cluster|
+ Ci::FetchGcpOperationService.new.execute(cluster) do |operation|
+ case operation.status
+ when 'RUNNING'
+ if TIMEOUT < Time.now.utc - operation.start_time.to_time.utc
+ return cluster.make_errored!("Cluster creation time exceeds timeout; #{TIMEOUT}")
+ end
+
+ WaitForClusterCreationWorker.perform_in(EAGER_INTERVAL, cluster.id)
+ when 'DONE'
+ Ci::FinalizeClusterCreationService.new.execute(cluster)
+ else
+ return cluster.make_errored!("Unexpected operation status; #{operation.status} #{operation.status_message}")
+ end
+ end
+ end
+ end
+end