diff options
74 files changed, 2910 insertions, 3 deletions
diff --git a/app/assets/images/auth_buttons/signin_with_google.png b/app/assets/images/auth_buttons/signin_with_google.png Binary files differnew file mode 100644 index 00000000000..b1327b4f7b4 --- /dev/null +++ b/app/assets/images/auth_buttons/signin_with_google.png 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 diff --git a/changelogs/unreleased/feature-sm-35954-create-kubernetes-cluster-on-gke-from-k8s-service.yml b/changelogs/unreleased/feature-sm-35954-create-kubernetes-cluster-on-gke-from-k8s-service.yml new file mode 100644 index 00000000000..14b35b6daee --- /dev/null +++ b/changelogs/unreleased/feature-sm-35954-create-kubernetes-cluster-on-gke-from-k8s-service.yml @@ -0,0 +1,5 @@ +--- +title: Create Kubernetes cluster on GKE from k8s service +merge_request: 14470 +author: +type: added diff --git a/config/routes.rb b/config/routes.rb index 5683725c8a2..405bfcc2d8e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -87,6 +87,7 @@ Rails.application.routes.draw do resources :issues, module: :boards, only: [:index, :update] end + draw :google_api draw :import draw :uploads draw :explore diff --git a/config/routes/google_api.rb b/config/routes/google_api.rb new file mode 100644 index 00000000000..3fb236d3d51 --- /dev/null +++ b/config/routes/google_api.rb @@ -0,0 +1,5 @@ +namespace :google_api do + resource :auth, only: [], controller: :authorizations do + match :callback, via: [:get, :post] + end +end diff --git a/config/routes/project.rb b/config/routes/project.rb index 70d7673250c..7f0e056c884 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -183,6 +183,16 @@ constraints(ProjectUrlConstrainer.new) do end end + resources :clusters, except: [:edit] do + collection do + get :login + end + + member do + get :status, format: :json + end + end + resources :environments, except: [:destroy] do member do post :stop diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index 8235e3853dc..e2bb766ee47 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -62,5 +62,6 @@ - [update_user_activity, 1] - [propagate_service_template, 1] - [background_migration, 1] + - [gcp_cluster, 1] - [project_migrate_hashed_storage, 1] - [storage_migrator, 1] diff --git a/db/migrate/20170924094327_create_gcp_clusters.rb b/db/migrate/20170924094327_create_gcp_clusters.rb new file mode 100644 index 00000000000..657dddcbbc4 --- /dev/null +++ b/db/migrate/20170924094327_create_gcp_clusters.rb @@ -0,0 +1,45 @@ +class CreateGcpClusters < ActiveRecord::Migration + DOWNTIME = false + + def change + create_table :gcp_clusters do |t| + # Order columns by best align scheme + t.references :project, null: false, index: { unique: true }, foreign_key: { on_delete: :cascade } + t.references :user, foreign_key: { on_delete: :nullify } + t.references :service, foreign_key: { on_delete: :nullify } + t.integer :status + t.integer :gcp_cluster_size, null: false + + # Timestamps + t.datetime_with_timezone :created_at, null: false + t.datetime_with_timezone :updated_at, null: false + + # Enable/disable + t.boolean :enabled, default: true + + # General + t.text :status_reason + + # k8s integration specific + t.string :project_namespace + + # Cluster details + t.string :endpoint + t.text :ca_cert + t.text :encrypted_kubernetes_token + t.string :encrypted_kubernetes_token_iv + t.string :username + t.text :encrypted_password + t.string :encrypted_password_iv + + # GKE + t.string :gcp_project_id, null: false + t.string :gcp_cluster_zone, null: false + t.string :gcp_cluster_name, null: false + t.string :gcp_machine_type + t.string :gcp_operation_id + t.text :encrypted_gcp_token + t.string :encrypted_gcp_token_iv + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 76985c416af..a71ab36b839 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -580,6 +580,35 @@ ActiveRecord::Schema.define(version: 20171005130944) do add_index "forked_project_links", ["forked_to_project_id"], name: "index_forked_project_links_on_forked_to_project_id", unique: true, using: :btree + create_table "gcp_clusters", force: :cascade do |t| + t.integer "project_id", null: false + t.integer "user_id" + t.integer "service_id" + t.integer "status" + t.integer "gcp_cluster_size", null: false + t.datetime_with_timezone "created_at", null: false + t.datetime_with_timezone "updated_at", null: false + t.boolean "enabled", default: true + t.text "status_reason" + t.string "project_namespace" + t.string "endpoint" + t.text "ca_cert" + t.text "encrypted_kubernetes_token" + t.string "encrypted_kubernetes_token_iv" + t.string "username" + t.text "encrypted_password" + t.string "encrypted_password_iv" + t.string "gcp_project_id", null: false + t.string "gcp_cluster_zone", null: false + t.string "gcp_cluster_name", null: false + t.string "gcp_machine_type" + t.string "gcp_operation_id" + t.text "encrypted_gcp_token" + t.string "encrypted_gcp_token_iv" + end + + add_index "gcp_clusters", ["project_id"], name: "index_gcp_clusters_on_project_id", unique: true, using: :btree + create_table "gpg_key_subkeys", force: :cascade do |t| t.integer "gpg_key_id", null: false t.binary "keyid" @@ -1741,6 +1770,9 @@ ActiveRecord::Schema.define(version: 20171005130944) do add_foreign_key "events", "projects", on_delete: :cascade add_foreign_key "events", "users", column: "author_id", name: "fk_edfd187b6f", on_delete: :cascade add_foreign_key "forked_project_links", "projects", column: "forked_to_project_id", name: "fk_434510edb0", on_delete: :cascade + add_foreign_key "gcp_clusters", "projects", on_delete: :cascade + add_foreign_key "gcp_clusters", "services", on_delete: :nullify + add_foreign_key "gcp_clusters", "users", on_delete: :nullify add_foreign_key "gpg_key_subkeys", "gpg_keys", on_delete: :cascade add_foreign_key "gpg_keys", "users", on_delete: :cascade add_foreign_key "gpg_signatures", "gpg_key_subkeys", on_delete: :nullify diff --git a/lib/gitlab/gcp/model.rb b/lib/gitlab/gcp/model.rb new file mode 100644 index 00000000000..195391f0e3c --- /dev/null +++ b/lib/gitlab/gcp/model.rb @@ -0,0 +1,13 @@ +module Gitlab + module Gcp + module Model + def table_name_prefix + "gcp_" + end + + def model_name + @model_name ||= ActiveModel::Name.new(self, nil, self.name.split("::").last) + end + end + end +end diff --git a/lib/gitlab/import_export/import_export.yml b/lib/gitlab/import_export/import_export.yml index 2171c6c7bbb..dec8b4c5acd 100644 --- a/lib/gitlab/import_export/import_export.yml +++ b/lib/gitlab/import_export/import_export.yml @@ -53,6 +53,7 @@ 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 380b336395d..a76cf1addc0 100644 --- a/lib/gitlab/import_export/relation_factory.rb +++ b/lib/gitlab/import_export/relation_factory.rb @@ -8,6 +8,8 @@ module Gitlab triggers: 'Ci::Trigger', pipeline_schedules: 'Ci::PipelineSchedule', builds: 'Ci::Build', + cluster: 'Gcp::Cluster', + clusters: 'Gcp::Cluster', hooks: 'ProjectHook', merge_access_levels: 'ProtectedBranch::MergeAccessLevel', push_access_levels: 'ProtectedBranch::PushAccessLevel', diff --git a/lib/gitlab/path_regex.rb b/lib/gitlab/path_regex.rb index 7c02c9c5c48..e68160c8faf 100644 --- a/lib/gitlab/path_regex.rb +++ b/lib/gitlab/path_regex.rb @@ -33,6 +33,7 @@ module Gitlab explore favicon.ico files + google_api groups health_check help diff --git a/lib/gitlab/usage_data.rb b/lib/gitlab/usage_data.rb index 6857038dba8..3f3ba77d47f 100644 --- a/lib/gitlab/usage_data.rb +++ b/lib/gitlab/usage_data.rb @@ -48,6 +48,7 @@ module Gitlab deploy_keys: DeployKey.count, deployments: Deployment.count, environments: ::Environment.count, + gcp_clusters: ::Gcp::Cluster.count, in_review_folder: ::Environment.in_review_folder.count, groups: Group.count, issues: Issue.count, diff --git a/lib/google_api/auth.rb b/lib/google_api/auth.rb new file mode 100644 index 00000000000..99a82c849e0 --- /dev/null +++ b/lib/google_api/auth.rb @@ -0,0 +1,54 @@ +module GoogleApi + class Auth + attr_reader :access_token, :redirect_uri, :state + + ConfigMissingError = Class.new(StandardError) + + def initialize(access_token, redirect_uri, state: nil) + @access_token = access_token + @redirect_uri = redirect_uri + @state = state + end + + def authorize_url + client.auth_code.authorize_url( + redirect_uri: redirect_uri, + scope: scope, + state: state # This is used for arbitary redirection + ) + end + + def get_token(code) + ret = client.auth_code.get_token(code, redirect_uri: redirect_uri) + return ret.token, ret.expires_at + end + + protected + + def scope + raise NotImplementedError + end + + private + + def config + Gitlab.config.omniauth.providers.find { |provider| provider.name == "google_oauth2" } + end + + def client + return @client if defined?(@client) + + unless config + raise ConfigMissingError + end + + @client = ::OAuth2::Client.new( + config.app_id, + config.app_secret, + site: 'https://accounts.google.com', + token_url: '/o/oauth2/token', + authorize_url: '/o/oauth2/auth' + ) + end + end +end diff --git a/lib/google_api/cloud_platform/client.rb b/lib/google_api/cloud_platform/client.rb new file mode 100644 index 00000000000..a440a3e3562 --- /dev/null +++ b/lib/google_api/cloud_platform/client.rb @@ -0,0 +1,88 @@ +require 'google/apis/container_v1' + +module GoogleApi + module CloudPlatform + class Client < GoogleApi::Auth + DEFAULT_MACHINE_TYPE = 'n1-standard-1'.freeze + SCOPE = 'https://www.googleapis.com/auth/cloud-platform'.freeze + LEAST_TOKEN_LIFE_TIME = 10.minutes + + class << self + def session_key_for_token + :cloud_platform_access_token + end + + def session_key_for_expires_at + :cloud_platform_expires_at + end + + def new_session_key_for_redirect_uri + SecureRandom.hex.tap do |state| + yield session_key_for_redirect_uri(state) + end + end + + def session_key_for_redirect_uri(state) + "cloud_platform_second_redirect_uri_#{state}" + end + end + + def scope + SCOPE + end + + def validate_token(expires_at) + return false unless access_token + return false unless expires_at + + # Making sure that the token will have been still alive during the cluster creation. + return false if token_life_time(expires_at) < LEAST_TOKEN_LIFE_TIME + + true + end + + def projects_zones_clusters_get(project_id, zone, cluster_id) + service = Google::Apis::ContainerV1::ContainerService.new + service.authorization = access_token + + service.get_zone_cluster(project_id, zone, cluster_id) + end + + def projects_zones_clusters_create(project_id, zone, cluster_name, cluster_size, machine_type:) + service = Google::Apis::ContainerV1::ContainerService.new + service.authorization = access_token + + request_body = Google::Apis::ContainerV1::CreateClusterRequest.new( + { + "cluster": { + "name": cluster_name, + "initial_node_count": cluster_size, + "node_config": { + "machine_type": machine_type + } + } + } ) + + service.create_cluster(project_id, zone, request_body) + end + + def projects_zones_operations(project_id, zone, operation_id) + service = Google::Apis::ContainerV1::ContainerService.new + service.authorization = access_token + + service.get_zone_operation(project_id, zone, operation_id) + end + + def parse_operation_id(self_link) + m = self_link.match(%r{projects/.*/zones/.*/operations/(.*)}) + m[1] if m + end + + private + + def token_life_time(expires_at) + DateTime.strptime(expires_at, '%s').to_time.utc - Time.now.utc + end + end + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 99f773e6bca..7569af9d175 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -8,8 +8,8 @@ msgid "" msgstr "" "Project-Id-Version: gitlab 1.0.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2017-10-03 16:06-0400\n" -"PO-Revision-Date: 2017-10-03 16:06-0400\n" +"POT-Creation-Date: 2017-10-04 23:47+0100\n" +"PO-Revision-Date: 2017-10-04 23:47+0100\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" "Language: \n" @@ -367,6 +367,129 @@ msgstr "" msgid "Clone repository" msgstr "" +msgid "ClusterIntegration|A %{link_to_container_project} must have been created under this account" +msgstr "" + +msgid "ClusterIntegration|Cluster integration" +msgstr "" + +msgid "ClusterIntegration|Cluster integration is disabled for this project." +msgstr "" + +msgid "ClusterIntegration|Cluster integration is enabled for this project." +msgstr "" + +msgid "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." +msgstr "" + +msgid "ClusterIntegration|Cluster is being created on Google Container Engine..." +msgstr "" + +msgid "ClusterIntegration|Cluster name" +msgstr "" + +msgid "ClusterIntegration|Cluster was successfully created on Google Container Engine" +msgstr "" + +msgid "ClusterIntegration|Copy cluster name" +msgstr "" + +msgid "ClusterIntegration|Create cluster" +msgstr "" + +msgid "ClusterIntegration|Create new cluster on Google Container Engine" +msgstr "" + +msgid "ClusterIntegration|Enable cluster integration" +msgstr "" + +msgid "ClusterIntegration|Google Cloud Platform project ID" +msgstr "" + +msgid "ClusterIntegration|Google Container Engine" +msgstr "" + +msgid "ClusterIntegration|Google Container Engine project" +msgstr "" + +msgid "ClusterIntegration|Google Container Engine" +msgstr "" + +msgid "ClusterIntegration|Learn more about %{link_to_documentation}" +msgstr "" + +msgid "ClusterIntegration|See machine types" +msgstr "" + +msgid "ClusterIntegration|Make sure your account %{link_to_requirements} to create clusters" +msgstr "" + +msgid "ClusterIntegration|Manage your cluster by visiting %{link_gke}" +msgstr "" + +msgid "ClusterIntegration|Number of nodes" +msgstr "" + +msgid "ClusterIntegration|Project namespace (optional, unique)" +msgstr "" + +msgid "ClusterIntegration|Remove cluster integration" +msgstr "" + +msgid "ClusterIntegration|Remove integration" +msgstr "" + +msgid "ClusterIntegration|Removing cluster integration will remove the cluster configuration you have added to this project. It will not delete your project." +msgstr "" + +msgid "ClusterIntegration|Save changes" +msgstr "" + +msgid "ClusterIntegration|See your projects" +msgstr "" + +msgid "ClusterIntegration|See zones" +msgstr "" + +msgid "ClusterIntegration|Something went wrong on our end." +msgstr "" + +msgid "ClusterIntegration|Something went wrong while creating your cluster on Google Container Engine." +msgstr "" + +msgid "ClusterIntegration|Please make sure that your Google account meets the following requirements:" +msgstr "" + +msgid "ClusterIntegration|Toggle Cluster" +msgstr "" + +msgid "ClusterIntegration|Read our %{link_to_help_page} on cluster integration." +msgstr "" + +msgid "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." +msgstr "" + +msgid "ClusterIntegration|Your account must have %{link_to_container_engine}" +msgstr "" + +msgid "ClusterIntegration|Zone" +msgstr "" + +msgid "ClusterIntegration|access to Google Container Engine" +msgstr "" + +msgid "ClusterIntegration|cluster" +msgstr "" + +msgid "ClusterIntegration|help page" +msgstr "" + +msgid "ClusterIntegration|meets the requirements" +msgstr "" + +msgid "ClusterIntegration|properly configured" +msgstr "" + msgid "Comments" msgstr "" @@ -640,6 +763,9 @@ msgstr "" msgid "GoToYourFork|Fork" msgstr "" +msgid "Google authentication is not %{link_to_documentation}. Ask your GitLab administrator if you want to use this service." +msgstr "" + msgid "GroupSettings|Prevent sharing a project within %{group} with other groups" msgstr "" diff --git a/spec/controllers/google_api/authorizations_controller_spec.rb b/spec/controllers/google_api/authorizations_controller_spec.rb new file mode 100644 index 00000000000..80d553f0f34 --- /dev/null +++ b/spec/controllers/google_api/authorizations_controller_spec.rb @@ -0,0 +1,49 @@ +require 'spec_helper' + +describe GoogleApi::AuthorizationsController do + describe 'GET|POST #callback' do + let(:user) { create(:user) } + let(:token) { 'token' } + let(:expires_at) { 1.hour.since.strftime('%s') } + + subject { get :callback, code: 'xxx', state: @state } + + before do + sign_in(user) + + allow_any_instance_of(GoogleApi::CloudPlatform::Client) + .to receive(:get_token).and_return([token, expires_at]) + end + + it 'sets token and expires_at in session' do + subject + + expect(session[GoogleApi::CloudPlatform::Client.session_key_for_token]) + .to eq(token) + expect(session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at]) + .to eq(expires_at) + end + + context 'when redirect uri key is stored in state' do + set(:project) { create(:project) } + let(:redirect_uri) { project_clusters_url(project).to_s } + + before do + @state = GoogleApi::CloudPlatform::Client + .new_session_key_for_redirect_uri do |key| + session[key] = redirect_uri + end + end + + it 'redirects to the URL stored in state param' do + expect(subject).to redirect_to(redirect_uri) + end + end + + context 'when redirection url is not stored in state' do + it 'redirects to root_path' do + expect(subject).to redirect_to(root_path) + end + end + end +end diff --git a/spec/controllers/projects/clusters_controller_spec.rb b/spec/controllers/projects/clusters_controller_spec.rb new file mode 100644 index 00000000000..7985028d73b --- /dev/null +++ b/spec/controllers/projects/clusters_controller_spec.rb @@ -0,0 +1,308 @@ +require 'spec_helper' + +describe Projects::ClustersController do + set(:user) { create(:user) } + set(:project) { create(:project) } + let(:role) { :master } + + before do + project.team << [user, role] + + sign_in(user) + end + + describe 'GET index' do + subject do + get :index, namespace_id: project.namespace, + project_id: project + end + + context 'when cluster is already created' do + let!(:cluster) { create(:gcp_cluster, :created_on_gke, project: project) } + + it 'redirects to show a cluster' do + subject + + expect(response).to redirect_to(project_cluster_path(project, cluster)) + end + end + + context 'when we do not have cluster' do + it 'redirects to create a cluster' do + subject + + expect(response).to redirect_to(new_project_cluster_path(project)) + end + end + end + + describe 'GET login' do + render_views + + subject do + get :login, namespace_id: project.namespace, + project_id: project + end + + context 'when we do have omniauth configured' do + it 'shows login button' do + subject + + expect(response.body).to include('auth_buttons/signin_with_google') + end + end + + context 'when we do not have omniauth configured' do + before do + stub_omniauth_setting(providers: []) + end + + it 'shows notice message' do + subject + + expect(response.body).to include('Ask your GitLab administrator if you want to use this service.') + end + end + end + + shared_examples 'requires to login' do + it 'redirects to create a cluster' do + subject + + expect(response).to redirect_to(login_project_clusters_path(project)) + end + end + + describe 'GET new' do + render_views + + subject do + get :new, namespace_id: project.namespace, + project_id: project + end + + context 'when logged' do + before do + make_logged_in + end + + it 'shows a creation form' do + subject + + expect(response.body).to include('Create cluster') + end + end + + context 'when not logged' do + it_behaves_like 'requires to login' + end + end + + describe 'POST create' do + subject do + post :create, params.merge(namespace_id: project.namespace, + project_id: project) + end + + context 'when not logged' do + let(:params) { {} } + + it_behaves_like 'requires to login' + end + + context 'when logged in' do + before do + make_logged_in + end + + context 'when all required parameters are set' do + let(:params) do + { + cluster: { + gcp_cluster_name: 'new-cluster', + gcp_project_id: '111' + } + } + end + + before do + expect(ClusterProvisionWorker).to receive(:perform_async) { } + end + + it 'creates a new cluster' do + expect { subject }.to change { Gcp::Cluster.count } + + expect(response).to redirect_to(project_cluster_path(project, project.cluster)) + end + end + + context 'when not all required parameters are set' do + render_views + + let(:params) do + { + cluster: { + project_namespace: 'some namespace' + } + } + end + + it 'shows an error message' do + expect { subject }.not_to change { Gcp::Cluster.count } + + expect(response).to render_template(:new) + end + end + end + end + + describe 'GET status' do + let(:cluster) { create(:gcp_cluster, :created_on_gke, project: project) } + + subject do + get :status, namespace_id: project.namespace, + project_id: project, + id: cluster, + format: :json + end + + it "responds with matching schema" do + subject + + expect(response).to have_http_status(:ok) + expect(response).to match_response_schema('cluster_status') + end + end + + describe 'GET show' do + render_views + + let(:cluster) { create(:gcp_cluster, :created_on_gke, project: project) } + + subject do + get :show, namespace_id: project.namespace, + project_id: project, + id: cluster + end + + context 'when logged as master' do + it "allows to update cluster" do + subject + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Save") + end + + it "allows remove integration" do + subject + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Remove integration") + end + end + + context 'when logged as developer' do + let(:role) { :developer } + + it "does not allow to access page" do + subject + + expect(response).to have_http_status(:not_found) + end + end + end + + describe 'PUT update' do + render_views + + let(:service) { project.build_kubernetes_service } + let(:cluster) { create(:gcp_cluster, :created_on_gke, project: project, service: service) } + let(:params) { {} } + + subject do + put :update, params.merge(namespace_id: project.namespace, + project_id: project, + id: cluster) + end + + context 'when logged as master' do + context 'when valid params are used' do + let(:params) do + { + cluster: { enabled: false } + } + end + + it "redirects back to show page" do + subject + + expect(response).to redirect_to(project_cluster_path(project, project.cluster)) + expect(flash[:notice]).to eq('Cluster was successfully updated.') + end + end + + context 'when invalid params are used' do + let(:params) do + { + cluster: { project_namespace: 'my Namespace 321321321 #' } + } + end + + it "rejects changes" do + subject + + expect(response).to have_http_status(:ok) + expect(response).to render_template(:show) + end + end + end + + context 'when logged as developer' do + let(:role) { :developer } + + it "does not allow to update cluster" do + subject + + expect(response).to have_http_status(:not_found) + end + end + end + + describe 'delete update' do + let(:cluster) { create(:gcp_cluster, :created_on_gke, project: project) } + + subject do + delete :destroy, namespace_id: project.namespace, + project_id: project, + id: cluster + end + + context 'when logged as master' do + it "redirects back to clusters list" do + subject + + expect(response).to redirect_to(project_clusters_path(project)) + expect(flash[:notice]).to eq('Cluster integration was successfully removed.') + end + end + + context 'when logged as developer' do + let(:role) { :developer } + + it "does not allow to destroy cluster" do + subject + + expect(response).to have_http_status(:not_found) + end + end + end + + def make_logged_in + session[GoogleApi::CloudPlatform::Client.session_key_for_token] = '1234' + session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at] = in_hour.to_i.to_s + end + + def in_hour + Time.now + 1.hour + end +end diff --git a/spec/factories/gcp/cluster.rb b/spec/factories/gcp/cluster.rb new file mode 100644 index 00000000000..630e40da888 --- /dev/null +++ b/spec/factories/gcp/cluster.rb @@ -0,0 +1,38 @@ +FactoryGirl.define do + factory :gcp_cluster, class: Gcp::Cluster do + project + user + enabled true + gcp_project_id 'gcp-project-12345' + gcp_cluster_name 'test-cluster' + gcp_cluster_zone 'us-central1-a' + gcp_cluster_size 1 + gcp_machine_type 'n1-standard-4' + + trait :with_kubernetes_service do + after(:create) do |cluster, evaluator| + create(:kubernetes_service, project: cluster.project).tap do |service| + cluster.update(service: service) + end + end + end + + trait :custom_project_namespace do + project_namespace 'sample-app' + end + + trait :created_on_gke do + status_event :make_created + endpoint '111.111.111.111' + ca_cert 'xxxxxx' + kubernetes_token 'xxxxxx' + username 'xxxxxx' + password 'xxxxxx' + end + + trait :errored do + status_event :make_errored + status_reason 'general error' + end + end +end diff --git a/spec/features/projects/clusters_spec.rb b/spec/features/projects/clusters_spec.rb new file mode 100644 index 00000000000..810f2c39b43 --- /dev/null +++ b/spec/features/projects/clusters_spec.rb @@ -0,0 +1,111 @@ +require 'spec_helper' + +feature 'Clusters', :js do + let!(:project) { create(:project, :repository) } + let!(:user) { create(:user) } + + before do + project.add_master(user) + gitlab_sign_in(user) + end + + context 'when user has signed in Google' do + before do + allow_any_instance_of(GoogleApi::CloudPlatform::Client) + .to receive(:validate_token).and_return(true) + end + + context 'when user does not have a cluster and visits cluster index page' do + before do + visit project_clusters_path(project) + end + + it 'user sees a new page' do + expect(page).to have_button('Create cluster') + end + + context 'when user filled form with valid parameters' do + before do + double.tap do |dbl| + allow(dbl).to receive(:status).and_return('RUNNING') + allow(dbl).to receive(:self_link) + .and_return('projects/gcp-project-12345/zones/us-central1-a/operations/ope-123') + allow_any_instance_of(GoogleApi::CloudPlatform::Client) + .to receive(:projects_zones_clusters_create).and_return(dbl) + end + + allow(WaitForClusterCreationWorker).to receive(:perform_in).and_return(nil) + + fill_in 'cluster_gcp_project_id', with: 'gcp-project-123' + fill_in 'cluster_gcp_cluster_name', with: 'dev-cluster' + click_button 'Create cluster' + end + + it 'user sees a cluster details page and creation status' do + expect(page).to have_content('Cluster is being created on Google Container Engine...') + + Gcp::Cluster.last.make_created! + + expect(page).to have_content('Cluster was successfully created on Google Container Engine') + end + end + + context 'when user filled form with invalid parameters' do + before do + click_button 'Create cluster' + end + + it 'user sees a validation error' do + expect(page).to have_css('#error_explanation') + end + end + end + + context 'when user has a cluster and visits cluster index page' do + let!(:cluster) { create(:gcp_cluster, :created_on_gke, :with_kubernetes_service, project: project) } + + before do + visit project_clusters_path(project) + end + + it 'user sees an cluster details page' do + expect(page).to have_button('Save') + expect(page.find(:css, '.cluster-name').value).to eq(cluster.gcp_cluster_name) + end + + context 'when user disables the cluster' do + before do + page.find(:css, '.js-toggle-cluster').click + click_button 'Save' + end + + it 'user sees the succeccful message' do + expect(page).to have_content('Cluster was successfully updated.') + end + end + + context 'when user destory the cluster' do + before do + page.accept_confirm do + click_link 'Remove integration' + end + end + + it 'user sees creation form with the succeccful message' do + expect(page).to have_content('Cluster integration was successfully removed.') + expect(page).to have_button('Create cluster') + end + end + end + end + + context 'when user has not signed in Google' do + before do + visit project_clusters_path(project) + end + + it 'user sees a login page' do + expect(page).to have_css('.signin-with-google') + end + end +end diff --git a/spec/fixtures/api/schemas/cluster_status.json b/spec/fixtures/api/schemas/cluster_status.json new file mode 100644 index 00000000000..1f255a17881 --- /dev/null +++ b/spec/fixtures/api/schemas/cluster_status.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "required" : [ + "status" + ], + "properties" : { + "status": { "type": "string" }, + "status_reason": { "type": ["string", "null"] } + }, + "additionalProperties": false +} diff --git a/spec/javascripts/clusters_spec.js b/spec/javascripts/clusters_spec.js new file mode 100644 index 00000000000..eb1cd6eb804 --- /dev/null +++ b/spec/javascripts/clusters_spec.js @@ -0,0 +1,79 @@ +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/javascripts/fixtures/clusters.rb b/spec/javascripts/fixtures/clusters.rb new file mode 100644 index 00000000000..5774f36f026 --- /dev/null +++ b/spec/javascripts/fixtures/clusters.rb @@ -0,0 +1,34 @@ +require 'spec_helper' + +describe Projects::ClustersController, '(JavaScript fixtures)', type: :controller do + include JavaScriptFixturesHelpers + + let(:admin) { create(:admin) } + let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} + let(:project) { create(:project, :repository, namespace: namespace) } + let(:cluster) { project.create_cluster!(gcp_cluster_name: "gke-test-creation-1", gcp_project_id: 'gitlab-internal-153318', gcp_cluster_zone: 'us-central1-a', gcp_cluster_size: '1', project_namespace: 'aaa', gcp_machine_type: 'n1-standard-1')} + + render_views + + before(:all) do + clean_frontend_fixtures('clusters/') + end + + before do + sign_in(admin) + end + + after do + remove_repository(project) + end + + it 'clusters/show_cluster.html.raw' do |example| + get :show, + namespace_id: project.namespace.to_param, + project_id: project, + id: cluster + + expect(response).to be_success + store_frontend_fixture(response, example.description) + end +end diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 3fb8edeb701..ec425fd2803 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -147,6 +147,10 @@ deploy_keys: - user - deploy_keys_projects - projects +cluster: +- project +- user +- service services: - project - service_hook @@ -177,6 +181,7 @@ project: - tag_taggings - tags - chat_services +- cluster - creator - group - namespace diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index 0ab493a4246..80d92b2e6a3 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -313,6 +313,32 @@ Ci::PipelineSchedule: - deleted_at - created_at - updated_at +Gcp::Cluster: +- id +- project_id +- user_id +- service_id +- enabled +- status +- status_reason +- project_namespace +- endpoint +- ca_cert +- encrypted_kubernetes_token +- encrypted_kubernetes_token_iv +- username +- encrypted_password +- encrypted_password_iv +- gcp_project_id +- gcp_cluster_zone +- gcp_cluster_name +- gcp_cluster_size +- gcp_machine_type +- gcp_operation_id +- encrypted_gcp_token +- encrypted_gcp_token_iv +- created_at +- updated_at DeployKey: - id - user_id diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb index ee152872acc..777e9c8e21d 100644 --- a/spec/lib/gitlab/usage_data_spec.rb +++ b/spec/lib/gitlab/usage_data_spec.rb @@ -60,6 +60,7 @@ describe Gitlab::UsageData do deploy_keys deployments environments + gcp_clusters in_review_folder groups issues diff --git a/spec/lib/google_api/auth_spec.rb b/spec/lib/google_api/auth_spec.rb new file mode 100644 index 00000000000..87a3f43274f --- /dev/null +++ b/spec/lib/google_api/auth_spec.rb @@ -0,0 +1,41 @@ +require 'spec_helper' + +describe GoogleApi::Auth do + let(:redirect_uri) { 'http://localhost:3000/google_api/authorizations/callback' } + let(:redirect_to) { 'http://localhost:3000/namaspace/project/clusters' } + + let(:client) do + GoogleApi::CloudPlatform::Client + .new(nil, redirect_uri, state: redirect_to) + end + + describe '#authorize_url' do + subject { client.authorize_url } + + it 'returns authorize_url' do + is_expected.to start_with('https://accounts.google.com/o/oauth2') + is_expected.to include(URI.encode(redirect_uri, URI::PATTERN::RESERVED)) + is_expected.to include(URI.encode(redirect_to, URI::PATTERN::RESERVED)) + end + end + + describe '#get_token' do + let(:token) do + double.tap do |dbl| + allow(dbl).to receive(:token).and_return('token') + allow(dbl).to receive(:expires_at).and_return('expires_at') + end + end + + before do + allow_any_instance_of(OAuth2::Strategy::AuthCode) + .to receive(:get_token).and_return(token) + end + + it 'returns token and expires_at' do + token, expires_at = client.get_token('xxx') + expect(token).to eq('token') + expect(expires_at).to eq('expires_at') + end + end +end diff --git a/spec/lib/google_api/cloud_platform/client_spec.rb b/spec/lib/google_api/cloud_platform/client_spec.rb new file mode 100644 index 00000000000..acc5bd1da35 --- /dev/null +++ b/spec/lib/google_api/cloud_platform/client_spec.rb @@ -0,0 +1,128 @@ +require 'spec_helper' + +describe GoogleApi::CloudPlatform::Client do + let(:token) { 'token' } + let(:client) { described_class.new(token, nil) } + + describe '.session_key_for_redirect_uri' do + let(:state) { 'random_string' } + + subject { described_class.session_key_for_redirect_uri(state) } + + it 'creates a new session key' do + is_expected.to eq('cloud_platform_second_redirect_uri_random_string') + end + end + + describe '.new_session_key_for_redirect_uri' do + it 'generates a new session key' do + expect { |b| described_class.new_session_key_for_redirect_uri(&b) } + .to yield_with_args(String) + end + end + + describe '#validate_token' do + subject { client.validate_token(expires_at) } + + let(:expires_at) { 1.hour.since.utc.strftime('%s') } + + context 'when token is nil' do + let(:token) { nil } + + it { is_expected.to be_falsy } + end + + context 'when expires_at is nil' do + let(:expires_at) { nil } + + it { is_expected.to be_falsy } + end + + context 'when expires in 1 hour' do + it { is_expected.to be_truthy } + end + + context 'when expires in 10 minutes' do + let(:expires_at) { 5.minutes.since.utc.strftime('%s') } + + it { is_expected.to be_falsy } + end + end + + describe '#projects_zones_clusters_get' do + subject { client.projects_zones_clusters_get(spy, spy, spy) } + let(:gke_cluster) { double } + + before do + allow_any_instance_of(Google::Apis::ContainerV1::ContainerService) + .to receive(:get_zone_cluster).and_return(gke_cluster) + end + + it { is_expected.to eq(gke_cluster) } + end + + describe '#projects_zones_clusters_create' do + subject do + client.projects_zones_clusters_create( + spy, spy, cluster_name, cluster_size, machine_type: machine_type) + end + + let(:cluster_name) { 'test-cluster' } + let(:cluster_size) { 1 } + let(:machine_type) { 'n1-standard-4' } + let(:operation) { double } + + before do + allow_any_instance_of(Google::Apis::ContainerV1::ContainerService) + .to receive(:create_cluster).and_return(operation) + end + + it { is_expected.to eq(operation) } + + it 'sets corresponded parameters' do + expect_any_instance_of(Google::Apis::ContainerV1::CreateClusterRequest) + .to receive(:initialize).with( + { + "cluster": { + "name": cluster_name, + "initial_node_count": cluster_size, + "node_config": { + "machine_type": machine_type + } + } + } ) + + subject + end + end + + describe '#projects_zones_operations' do + subject { client.projects_zones_operations(spy, spy, spy) } + let(:operation) { double } + + before do + allow_any_instance_of(Google::Apis::ContainerV1::ContainerService) + .to receive(:get_zone_operation).and_return(operation) + end + + it { is_expected.to eq(operation) } + end + + describe '#parse_operation_id' do + subject { client.parse_operation_id(self_link) } + + context 'when expected url' do + let(:self_link) do + 'projects/gcp-project-12345/zones/us-central1-a/operations/ope-123' + end + + it { is_expected.to eq('ope-123') } + end + + context 'when unexpected url' do + let(:self_link) { '???' } + + it { is_expected.to be_nil } + end + end +end diff --git a/spec/models/gcp/cluster_spec.rb b/spec/models/gcp/cluster_spec.rb new file mode 100644 index 00000000000..350fbc257d9 --- /dev/null +++ b/spec/models/gcp/cluster_spec.rb @@ -0,0 +1,240 @@ +require 'spec_helper' + +describe Gcp::Cluster do + it { is_expected.to belong_to(:project) } + it { is_expected.to belong_to(:user) } + it { is_expected.to belong_to(:service) } + + it { is_expected.to validate_presence_of(:gcp_cluster_zone) } + + describe '#default_value_for' do + let(:cluster) { described_class.new } + + it { expect(cluster.gcp_cluster_zone).to eq('us-central1-a') } + it { expect(cluster.gcp_cluster_size).to eq(3) } + it { expect(cluster.gcp_machine_type).to eq('n1-standard-4') } + end + + describe '#validates' do + subject { cluster.valid? } + + context 'when validates gcp_project_id' do + let(:cluster) { build(:gcp_cluster, gcp_project_id: gcp_project_id) } + + context 'when valid' do + let(:gcp_project_id) { 'gcp-project-12345' } + + it { is_expected.to be_truthy } + end + + context 'when empty' do + let(:gcp_project_id) { '' } + + it { is_expected.to be_falsey } + end + + context 'when too long' do + let(:gcp_project_id) { 'A' * 64 } + + it { is_expected.to be_falsey } + end + + context 'when includes abnormal character' do + let(:gcp_project_id) { '!!!!!!' } + + it { is_expected.to be_falsey } + end + end + + context 'when validates gcp_cluster_name' do + let(:cluster) { build(:gcp_cluster, gcp_cluster_name: gcp_cluster_name) } + + context 'when valid' do + let(:gcp_cluster_name) { 'test-cluster' } + + it { is_expected.to be_truthy } + end + + context 'when empty' do + let(:gcp_cluster_name) { '' } + + it { is_expected.to be_falsey } + end + + context 'when too long' do + let(:gcp_cluster_name) { 'A' * 64 } + + it { is_expected.to be_falsey } + end + + context 'when includes abnormal character' do + let(:gcp_cluster_name) { '!!!!!!' } + + it { is_expected.to be_falsey } + end + end + + context 'when validates gcp_cluster_size' do + let(:cluster) { build(:gcp_cluster, gcp_cluster_size: gcp_cluster_size) } + + context 'when valid' do + let(:gcp_cluster_size) { 1 } + + it { is_expected.to be_truthy } + end + + context 'when zero' do + let(:gcp_cluster_size) { 0 } + + it { is_expected.to be_falsey } + end + end + + context 'when validates project_namespace' do + let(:cluster) { build(:gcp_cluster, project_namespace: project_namespace) } + + context 'when valid' do + let(:project_namespace) { 'default-namespace' } + + it { is_expected.to be_truthy } + end + + context 'when empty' do + let(:project_namespace) { '' } + + it { is_expected.to be_truthy } + end + + context 'when too long' do + let(:project_namespace) { 'A' * 64 } + + it { is_expected.to be_falsey } + end + + context 'when includes abnormal character' do + let(:project_namespace) { '!!!!!!' } + + it { is_expected.to be_falsey } + end + end + + context 'when validates restrict_modification' do + let(:cluster) { create(:gcp_cluster) } + + before do + cluster.make_creating! + end + + context 'when created' do + before do + cluster.make_created! + end + + it { is_expected.to be_truthy } + end + + context 'when creating' do + it { is_expected.to be_falsey } + end + end + end + + describe '#state_machine' do + let(:cluster) { build(:gcp_cluster) } + + context 'when transits to created state' do + before do + cluster.gcp_token = 'tmp' + cluster.gcp_operation_id = 'tmp' + cluster.make_created! + end + + it 'nullify gcp_token and gcp_operation_id' do + expect(cluster.gcp_token).to be_nil + expect(cluster.gcp_operation_id).to be_nil + expect(cluster).to be_created + end + end + + context 'when transits to errored state' do + let(:reason) { 'something wrong' } + + before do + cluster.make_errored!(reason) + end + + it 'sets status_reason' do + expect(cluster.status_reason).to eq(reason) + expect(cluster).to be_errored + end + end + end + + describe '#project_namespace_placeholder' do + subject { cluster.project_namespace_placeholder } + + let(:cluster) { create(:gcp_cluster) } + + it 'returns a placeholder' do + is_expected.to eq("#{cluster.project.path}-#{cluster.project.id}") + end + end + + describe '#on_creation?' do + subject { cluster.on_creation? } + + let(:cluster) { create(:gcp_cluster) } + + context 'when status is creating' do + before do + cluster.make_creating! + end + + it { is_expected.to be_truthy } + end + + context 'when status is created' do + before do + cluster.make_created! + end + + it { is_expected.to be_falsey } + end + end + + describe '#api_url' do + subject { cluster.api_url } + + let(:cluster) { create(:gcp_cluster, :created_on_gke) } + let(:api_url) { 'https://' + cluster.endpoint } + + it { is_expected.to eq(api_url) } + end + + describe '#restrict_modification' do + subject { cluster.restrict_modification } + + let(:cluster) { create(:gcp_cluster) } + + context 'when status is created' do + before do + cluster.make_created! + end + + it { is_expected.to be_truthy } + end + + context 'when status is creating' do + before do + cluster.make_creating! + end + + it { is_expected.to be_falsey } + + it 'sets error' do + is_expected.to be_falsey + expect(cluster.errors).not_to be_empty + end + end + end +end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 3d6a79e0649..9b470e79a76 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -76,6 +76,7 @@ describe Project do it { is_expected.to have_many(:uploads).dependent(:destroy) } it { is_expected.to have_many(:pipeline_schedules) } it { is_expected.to have_many(:members_and_requesters) } + it { is_expected.to have_one(:cluster) } context 'after initialized' do it "has a project_feature" do diff --git a/spec/policies/gcp/cluster_policy_spec.rb b/spec/policies/gcp/cluster_policy_spec.rb new file mode 100644 index 00000000000..e213aa3d557 --- /dev/null +++ b/spec/policies/gcp/cluster_policy_spec.rb @@ -0,0 +1,28 @@ +require 'spec_helper' + +describe Gcp::ClusterPolicy, :models do + set(:project) { create(:project) } + set(:cluster) { create(:gcp_cluster, project: project) } + let(:user) { create(:user) } + let(:policy) { described_class.new(user, cluster) } + + describe 'rules' do + context 'when developer' do + before do + project.add_developer(user) + end + + it { expect(policy).to be_disallowed :update_cluster } + it { expect(policy).to be_disallowed :admin_cluster } + end + + context 'when master' do + before do + project.add_master(user) + end + + it { expect(policy).to be_allowed :update_cluster } + it { expect(policy).to be_allowed :admin_cluster } + end + end +end diff --git a/spec/presenters/gcp/cluster_presenter_spec.rb b/spec/presenters/gcp/cluster_presenter_spec.rb new file mode 100644 index 00000000000..8d86dc31582 --- /dev/null +++ b/spec/presenters/gcp/cluster_presenter_spec.rb @@ -0,0 +1,35 @@ +require 'spec_helper' + +describe Gcp::ClusterPresenter do + let(:project) { create(:project) } + let(:cluster) { create(:gcp_cluster, project: project) } + + subject(:presenter) do + described_class.new(cluster) + end + + it 'inherits from Gitlab::View::Presenter::Delegated' do + expect(described_class.superclass).to eq(Gitlab::View::Presenter::Delegated) + end + + describe '#initialize' do + it 'takes a cluster and optional params' do + expect { presenter }.not_to raise_error + end + + it 'exposes cluster' do + expect(presenter.cluster).to eq(cluster) + end + + it 'forwards missing methods to cluster' do + expect(presenter.gcp_cluster_zone).to eq(cluster.gcp_cluster_zone) + end + end + + describe '#gke_cluster_url' do + subject { described_class.new(cluster).gke_cluster_url } + + it { is_expected.to include(cluster.gcp_cluster_zone) } + it { is_expected.to include(cluster.gcp_cluster_name) } + end +end diff --git a/spec/serializers/cluster_entity_spec.rb b/spec/serializers/cluster_entity_spec.rb new file mode 100644 index 00000000000..2c7f49974f1 --- /dev/null +++ b/spec/serializers/cluster_entity_spec.rb @@ -0,0 +1,22 @@ +require 'spec_helper' + +describe ClusterEntity do + set(:cluster) { create(:gcp_cluster, :errored) } + let(:request) { double('request') } + + let(:entity) do + described_class.new(cluster) + end + + describe '#as_json' do + subject { entity.as_json } + + it 'contains status' do + expect(subject[:status]).to eq(:errored) + end + + it 'contains status reason' do + expect(subject[:status_reason]).to eq('general error') + end + end +end diff --git a/spec/serializers/cluster_serializer_spec.rb b/spec/serializers/cluster_serializer_spec.rb new file mode 100644 index 00000000000..1ac6784d28f --- /dev/null +++ b/spec/serializers/cluster_serializer_spec.rb @@ -0,0 +1,19 @@ +require 'spec_helper' + +describe ClusterSerializer do + let(:serializer) do + described_class.new + end + + describe '#represent_status' do + subject { serializer.represent_status(resource) } + + context 'when represents only status' do + let(:resource) { create(:gcp_cluster, :errored) } + + it 'serializes only status' do + expect(subject.keys).to contain_exactly(:status, :status_reason) + end + end + end +end diff --git a/spec/services/ci/create_cluster_service_spec.rb b/spec/services/ci/create_cluster_service_spec.rb new file mode 100644 index 00000000000..6e7398fbffa --- /dev/null +++ b/spec/services/ci/create_cluster_service_spec.rb @@ -0,0 +1,47 @@ +require 'spec_helper' + +describe Ci::CreateClusterService do + describe '#execute' do + let(:access_token) { 'xxx' } + let(:project) { create(:project) } + let(:user) { create(:user) } + let(:result) { described_class.new(project, user, params).execute(access_token) } + + context 'when correct params' do + let(:params) do + { + gcp_project_id: 'gcp-project', + gcp_cluster_name: 'test-cluster', + gcp_cluster_zone: 'us-central1-a', + gcp_cluster_size: 1 + } + end + + it 'creates a cluster object' do + expect(ClusterProvisionWorker).to receive(:perform_async) + expect { result }.to change { Gcp::Cluster.count }.by(1) + expect(result.gcp_project_id).to eq('gcp-project') + expect(result.gcp_cluster_name).to eq('test-cluster') + expect(result.gcp_cluster_zone).to eq('us-central1-a') + expect(result.gcp_cluster_size).to eq(1) + expect(result.gcp_token).to eq(access_token) + end + end + + context 'when invalid params' do + let(:params) do + { + gcp_project_id: 'gcp-project', + gcp_cluster_name: 'test-cluster', + gcp_cluster_zone: 'us-central1-a', + gcp_cluster_size: 'ABC' + } + end + + it 'returns an error' do + expect(ClusterProvisionWorker).not_to receive(:perform_async) + expect { result }.to change { Gcp::Cluster.count }.by(0) + end + end + end +end diff --git a/spec/services/ci/fetch_gcp_operation_service_spec.rb b/spec/services/ci/fetch_gcp_operation_service_spec.rb new file mode 100644 index 00000000000..7792979c5cb --- /dev/null +++ b/spec/services/ci/fetch_gcp_operation_service_spec.rb @@ -0,0 +1,36 @@ +require 'spec_helper' +require 'google/apis' + +describe Ci::FetchGcpOperationService do + describe '#execute' do + let(:cluster) { create(:gcp_cluster) } + let(:operation) { double } + + context 'when suceeded' do + before do + allow_any_instance_of(GoogleApi::CloudPlatform::Client) + .to receive(:projects_zones_operations).and_return(operation) + end + + it 'fetch the gcp operaion' do + expect { |b| described_class.new.execute(cluster, &b) } + .to yield_with_args(operation) + end + end + + context 'when raises an error' do + let(:error) { Google::Apis::ServerError.new('a') } + + before do + allow_any_instance_of(GoogleApi::CloudPlatform::Client) + .to receive(:projects_zones_operations).and_raise(error) + end + + it 'sets an error to cluster object' do + expect { |b| described_class.new.execute(cluster, &b) } + .not_to yield_with_args + expect(cluster.reload).to be_errored + end + end + end +end diff --git a/spec/services/ci/fetch_kubernetes_token_service_spec.rb b/spec/services/ci/fetch_kubernetes_token_service_spec.rb new file mode 100644 index 00000000000..1d05c9671a9 --- /dev/null +++ b/spec/services/ci/fetch_kubernetes_token_service_spec.rb @@ -0,0 +1,64 @@ +require 'spec_helper' + +describe Ci::FetchKubernetesTokenService do + describe '#execute' do + subject { described_class.new(api_url, ca_pem, username, password).execute } + + let(:api_url) { 'http://111.111.111.111' } + let(:ca_pem) { '' } + let(:username) { 'admin' } + let(:password) { 'xxx' } + + context 'when params correct' do + let(:token) { 'xxx.token.xxx' } + + let(:secrets_json) do + [ + { + 'metadata': { + name: metadata_name + }, + 'data': { + 'token': Base64.encode64(token) + } + } + ] + end + + before do + allow_any_instance_of(Kubeclient::Client) + .to receive(:get_secrets).and_return(secrets_json) + end + + context 'when default-token exists' do + let(:metadata_name) { 'default-token-123' } + + it { is_expected.to eq(token) } + end + + context 'when default-token does not exist' do + let(:metadata_name) { 'another-token-123' } + + it { is_expected.to be_nil } + end + end + + context 'when api_url is nil' do + let(:api_url) { nil } + + it { expect { subject }.to raise_error("Incomplete settings") } + end + + context 'when username is nil' do + let(:username) { nil } + + it { expect { subject }.to raise_error("Incomplete settings") } + end + + context 'when password is nil' do + let(:password) { nil } + + it { expect { subject }.to raise_error("Incomplete settings") } + end + end +end diff --git a/spec/services/ci/finalize_cluster_creation_service_spec.rb b/spec/services/ci/finalize_cluster_creation_service_spec.rb new file mode 100644 index 00000000000..def3709fdb4 --- /dev/null +++ b/spec/services/ci/finalize_cluster_creation_service_spec.rb @@ -0,0 +1,61 @@ +require 'spec_helper' + +describe Ci::FinalizeClusterCreationService do + describe '#execute' do + let(:cluster) { create(:gcp_cluster) } + let(:result) { described_class.new.execute(cluster) } + + context 'when suceeded to get cluster from api' do + let(:gke_cluster) { double } + + before do + allow(gke_cluster).to receive(:endpoint).and_return('111.111.111.111') + allow(gke_cluster).to receive(:master_auth).and_return(spy) + allow_any_instance_of(GoogleApi::CloudPlatform::Client) + .to receive(:projects_zones_clusters_get).and_return(gke_cluster) + end + + context 'when suceeded to get kubernetes token' do + let(:kubernetes_token) { 'abc' } + + before do + allow_any_instance_of(Ci::FetchKubernetesTokenService) + .to receive(:execute).and_return(kubernetes_token) + end + + it 'executes integration cluster' do + expect_any_instance_of(Ci::IntegrateClusterService).to receive(:execute) + described_class.new.execute(cluster) + end + end + + context 'when failed to get kubernetes token' do + before do + allow_any_instance_of(Ci::FetchKubernetesTokenService) + .to receive(:execute).and_return(nil) + end + + it 'sets an error to cluster object' do + described_class.new.execute(cluster) + + expect(cluster.reload).to be_errored + end + end + end + + context 'when failed to get cluster from api' do + let(:error) { Google::Apis::ServerError.new('a') } + + before do + allow_any_instance_of(GoogleApi::CloudPlatform::Client) + .to receive(:projects_zones_clusters_get).and_raise(error) + end + + it 'sets an error to cluster object' do + described_class.new.execute(cluster) + + expect(cluster.reload).to be_errored + end + end + end +end diff --git a/spec/services/ci/integrate_cluster_service_spec.rb b/spec/services/ci/integrate_cluster_service_spec.rb new file mode 100644 index 00000000000..3a79c205bd1 --- /dev/null +++ b/spec/services/ci/integrate_cluster_service_spec.rb @@ -0,0 +1,42 @@ +require 'spec_helper' + +describe Ci::IntegrateClusterService do + describe '#execute' do + let(:cluster) { create(:gcp_cluster, :custom_project_namespace) } + let(:endpoint) { '123.123.123.123' } + let(:ca_cert) { 'ca_cert_xxx' } + let(:token) { 'token_xxx' } + let(:username) { 'username_xxx' } + let(:password) { 'password_xxx' } + + before do + described_class + .new.execute(cluster, endpoint, ca_cert, token, username, password) + + cluster.reload + end + + context 'when correct params' do + it 'creates a cluster object' do + expect(cluster.endpoint).to eq(endpoint) + expect(cluster.ca_cert).to eq(ca_cert) + expect(cluster.kubernetes_token).to eq(token) + expect(cluster.username).to eq(username) + expect(cluster.password).to eq(password) + expect(cluster.service.active).to be_truthy + expect(cluster.service.api_url).to eq(cluster.api_url) + expect(cluster.service.ca_pem).to eq(ca_cert) + expect(cluster.service.namespace).to eq(cluster.project_namespace) + expect(cluster.service.token).to eq(token) + end + end + + context 'when invalid params' do + let(:endpoint) { nil } + + it 'sets an error to cluster object' do + expect(cluster).to be_errored + end + end + end +end diff --git a/spec/services/ci/provision_cluster_service_spec.rb b/spec/services/ci/provision_cluster_service_spec.rb new file mode 100644 index 00000000000..5ce5c788314 --- /dev/null +++ b/spec/services/ci/provision_cluster_service_spec.rb @@ -0,0 +1,85 @@ +require 'spec_helper' + +describe Ci::ProvisionClusterService do + describe '#execute' do + let(:cluster) { create(:gcp_cluster) } + let(:operation) { spy } + + shared_examples 'error' do + it 'sets an error to cluster object' do + described_class.new.execute(cluster) + + expect(cluster.reload).to be_errored + end + end + + context 'when suceeded to request provision' do + before do + allow_any_instance_of(GoogleApi::CloudPlatform::Client) + .to receive(:projects_zones_clusters_create).and_return(operation) + end + + context 'when operation status is RUNNING' do + before do + allow(operation).to receive(:status).and_return('RUNNING') + end + + context 'when suceeded to parse gcp operation id' do + before do + allow_any_instance_of(GoogleApi::CloudPlatform::Client) + .to receive(:parse_operation_id).and_return('operation-123') + end + + context 'when cluster status is scheduled' do + before do + allow_any_instance_of(GoogleApi::CloudPlatform::Client) + .to receive(:parse_operation_id).and_return('operation-123') + end + + it 'schedules a worker for status minitoring' do + expect(WaitForClusterCreationWorker).to receive(:perform_in) + + described_class.new.execute(cluster) + end + end + + context 'when cluster status is creating' do + before do + cluster.make_creating! + end + + it_behaves_like 'error' + end + end + + context 'when failed to parse gcp operation id' do + before do + allow_any_instance_of(GoogleApi::CloudPlatform::Client) + .to receive(:parse_operation_id).and_return(nil) + end + + it_behaves_like 'error' + end + end + + context 'when operation status is others' do + before do + allow(operation).to receive(:status).and_return('others') + end + + it_behaves_like 'error' + end + end + + context 'when failed to request provision' do + let(:error) { Google::Apis::ServerError.new('a') } + + before do + allow_any_instance_of(GoogleApi::CloudPlatform::Client) + .to receive(:projects_zones_clusters_create).and_raise(error) + end + + it_behaves_like 'error' + end + end +end diff --git a/spec/services/ci/update_cluster_service_spec.rb b/spec/services/ci/update_cluster_service_spec.rb new file mode 100644 index 00000000000..a289385b88f --- /dev/null +++ b/spec/services/ci/update_cluster_service_spec.rb @@ -0,0 +1,37 @@ +require 'spec_helper' + +describe Ci::UpdateClusterService do + describe '#execute' do + let(:cluster) { create(:gcp_cluster, :created_on_gke, :with_kubernetes_service) } + + before do + described_class.new(cluster.project, cluster.user, params).execute(cluster) + + cluster.reload + end + + context 'when correct params' do + context 'when enabled is true' do + let(:params) { { 'enabled' => 'true' } } + + it 'enables cluster and overwrite kubernetes service' do + expect(cluster.enabled).to be_truthy + expect(cluster.service.active).to be_truthy + expect(cluster.service.api_url).to eq(cluster.api_url) + expect(cluster.service.ca_pem).to eq(cluster.ca_cert) + expect(cluster.service.namespace).to eq(cluster.project_namespace) + expect(cluster.service.token).to eq(cluster.kubernetes_token) + end + end + + context 'when enabled is false' do + let(:params) { { 'enabled' => 'false' } } + + it 'disables cluster and kubernetes service' do + expect(cluster.enabled).to be_falsy + expect(cluster.service.active).to be_falsy + end + end + end + end +end diff --git a/spec/workers/cluster_provision_worker_spec.rb b/spec/workers/cluster_provision_worker_spec.rb new file mode 100644 index 00000000000..11f208289db --- /dev/null +++ b/spec/workers/cluster_provision_worker_spec.rb @@ -0,0 +1,23 @@ +require 'spec_helper' + +describe ClusterProvisionWorker do + describe '#perform' do + context 'when cluster exists' do + let(:cluster) { create(:gcp_cluster) } + + it 'provision a cluster' do + expect_any_instance_of(Ci::ProvisionClusterService).to receive(:execute) + + described_class.new.perform(cluster.id) + end + end + + context 'when cluster does not exist' do + it 'does not provision a cluster' do + expect_any_instance_of(Ci::ProvisionClusterService).not_to receive(:execute) + + described_class.new.perform(123) + end + end + end +end diff --git a/spec/workers/concerns/cluster_queue_spec.rb b/spec/workers/concerns/cluster_queue_spec.rb new file mode 100644 index 00000000000..1050651fa51 --- /dev/null +++ b/spec/workers/concerns/cluster_queue_spec.rb @@ -0,0 +1,15 @@ +require 'spec_helper' + +describe ClusterQueue do + let(:worker) do + Class.new do + include Sidekiq::Worker + include ClusterQueue + end + end + + it 'sets a default pipelines queue automatically' do + expect(worker.sidekiq_options['queue']) + .to eq :gcp_cluster + end +end diff --git a/spec/workers/wait_for_cluster_creation_worker_spec.rb b/spec/workers/wait_for_cluster_creation_worker_spec.rb new file mode 100644 index 00000000000..dcd4a3b9aec --- /dev/null +++ b/spec/workers/wait_for_cluster_creation_worker_spec.rb @@ -0,0 +1,67 @@ +require 'spec_helper' + +describe WaitForClusterCreationWorker do + describe '#perform' do + context 'when cluster exists' do + let(:cluster) { create(:gcp_cluster) } + let(:operation) { double } + + before do + allow(operation).to receive(:status).and_return(status) + allow(operation).to receive(:start_time).and_return(1.minute.ago) + allow(operation).to receive(:status_message).and_return('error') + allow_any_instance_of(Ci::FetchGcpOperationService).to receive(:execute).and_yield(operation) + end + + context 'when operation status is RUNNING' do + let(:status) { 'RUNNING' } + + it 'reschedules worker' do + expect(described_class).to receive(:perform_in) + + described_class.new.perform(cluster.id) + end + + context 'when operation timeout' do + before do + allow(operation).to receive(:start_time).and_return(30.minutes.ago.utc) + end + + it 'sets an error message on cluster' do + described_class.new.perform(cluster.id) + + expect(cluster.reload).to be_errored + end + end + end + + context 'when operation status is DONE' do + let(:status) { 'DONE' } + + it 'finalizes cluster creation' do + expect_any_instance_of(Ci::FinalizeClusterCreationService).to receive(:execute) + + described_class.new.perform(cluster.id) + end + end + + context 'when operation status is others' do + let(:status) { 'others' } + + it 'sets an error message on cluster' do + described_class.new.perform(cluster.id) + + expect(cluster.reload).to be_errored + end + end + end + + context 'when cluster does not exist' do + it 'does not provision a cluster' do + expect_any_instance_of(Ci::FetchGcpOperationService).not_to receive(:execute) + + described_class.new.perform(1234) + end + end + end +end |