Commit fb70fada authored by Kamil Trzciński's avatar Kamil Trzciński

Merge branch 'feature/sm/35954-create-kubernetes-cluster-on-gke-from-k8s-service' into 'master'

Create Kubernetes cluster on GKE from k8s service

Closes #35954

See merge request gitlab-org/gitlab-ce!14470
parents a68a39e3 86cea3a5
/* 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();
}
}
}
......@@ -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':
......
.edit-cluster-form {
.clipboard-addon {
background-color: $white-light;
}
.alert-block {
margin-bottom: 20px;
}
}
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
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
......@@ -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,
......
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
......@@ -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
......
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
......@@ -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
......
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
class ClusterEntity < Grape::Entity
include RequestAwareEntity
expose :status_name, as: :status
expose :status_reason
end
class ClusterSerializer < BaseSerializer
entity ClusterEntity
def represent_status(resource)
represent(resource, { only: [:status, :status_reason] })
end
end
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
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
##
# 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
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
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
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
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
......@@ -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
......
.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'
%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 }
%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 }
- 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 }
- breadcrumb_title "Cluster"
- page_title _("New Cluster")
.row.prepend-top-default
.col-sm-4
= render 'sidebar'
.col-sm-8
= render 'header'
= render 'form'
- 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"})
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
##
# 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
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
---
title: Create Kubernetes cluster on GKE from k8s service
merge_request: 14470
author:
type: added
......@@ -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
......
namespace :google_api do
resource :auth, only: [], controller: :authorizations do
match :callback, via: [:get, :post]
end
end
......@@ -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
......
......@@ -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]
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
......@@ -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
......
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
......@@ -53,6 +53,7 @@ project_tree:
- :auto_devops
- :triggers
- :pipeline_schedules
- :cluster
- :services
- :hooks
- protected_branches:
......
......@@ -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',
......
......@@ -33,6 +33,7 @@ module Gitlab
explore
favicon.ico
files
google_api
groups
health_check
help
......
......@@ -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,
......
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
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
......@@ -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 ""
......
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
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
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
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
{
"type": "object",
"required" : [
"status"
],
"properties" : {
"status": { "type": "string" },
"status_reason": { "type": ["string", "null"] }
},
"additionalProperties": false
}
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');
});
});
});
});
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
......@@ -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
......
......@@ -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
......
......@@ -60,6 +60,7 @@ describe Gitlab::UsageData do
deploy_keys
deployments
environments
gcp_clusters
in_review_folder
groups
issues
......
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
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
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
......@@ -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
......
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
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
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
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
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
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
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
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
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
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
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
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
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
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
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment