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
This commit is contained in:
commit
fb70fadaca
Binary file not shown.
After Width: | Height: | Size: 3.9 KiB |
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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':
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
.edit-cluster-form {
|
||||
.clipboard-addon {
|
||||
background-color: $white-light;
|
||||
}
|
||||
|
||||
.alert-block {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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
|
|
@ -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,
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -0,0 +1,6 @@
|
|||
class ClusterEntity < Grape::Entity
|
||||
include RequestAwareEntity
|
||||
|
||||
expose :status_name, as: :status
|
||||
expose :status_reason
|
||||
end
|
|
@ -0,0 +1,7 @@
|
|||
class ClusterSerializer < BaseSerializer
|
||||
entity ClusterEntity
|
||||
|
||||
def represent_status(resource)
|
||||
represent(resource, { only: [:status, :status_reason] })
|
||||
end
|
||||
end
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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'
|
|
@ -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 }
|
|
@ -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 }
|
|
@ -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 }
|
|
@ -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'
|
|
@ -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"})
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
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
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
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]
|
||||
|
|
|
@ -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
|
32
db/schema.rb
32
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
|
||||
|
|
|
@ -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
|
|
@ -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,
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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 ""
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"type": "object",
|
||||
"required" : [
|
||||
"status"
|
||||
],
|
||||
"properties" : {
|
||||
"status": { "type": "string" },
|
||||
"status_reason": { "type": ["string", "null"] }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
Loading…
Reference in New Issue