From 013d2622667c5286377d7b76366981338bb4c4f0 Mon Sep 17 00:00:00 2001 From: Mayra Cabrera Date: Tue, 18 Dec 2018 15:40:20 -0600 Subject: Include CRUD endpoints for Cluster API Adds the following initial CRUD endpoints for Clusters API: - GET list of clusters - GET specific cluster - POST add existing cluster (mimic of "Add cluster") - PUT update cluser - DELETE destroy cluster Closes https://gitlab.com/gitlab-org/gitlab-ce/issues/40473 --- ...0473-api-support-for-kubernetes-integration.yml | 5 + lib/api/api.rb | 1 + lib/api/entities.rb | 33 ++ lib/api/project_clusters.rb | 142 +++++++ spec/requests/api/project_clusters_spec.rb | 450 +++++++++++++++++++++ 5 files changed, 631 insertions(+) create mode 100644 changelogs/unreleased/40473-api-support-for-kubernetes-integration.yml create mode 100644 lib/api/project_clusters.rb create mode 100644 spec/requests/api/project_clusters_spec.rb diff --git a/changelogs/unreleased/40473-api-support-for-kubernetes-integration.yml b/changelogs/unreleased/40473-api-support-for-kubernetes-integration.yml new file mode 100644 index 00000000000..5567aad6320 --- /dev/null +++ b/changelogs/unreleased/40473-api-support-for-kubernetes-integration.yml @@ -0,0 +1,5 @@ +--- +title: Add API Support for Kubernetes integration +merge_request: 23922 +author: +type: added diff --git a/lib/api/api.rb b/lib/api/api.rb index 19da0b2c434..72f979220d7 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -129,6 +129,7 @@ module API mount ::API::PagesDomains mount ::API::Pipelines mount ::API::PipelineSchedules + mount ::API::ProjectClusters mount ::API::ProjectExport mount ::API::ProjectImport mount ::API::ProjectHooks diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 7116ab2882b..7b83188c878 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -1516,5 +1516,38 @@ module API expose :from_content expose :to_content end + + module Platform + class Kubernetes < Grape::Entity + expose :api_url + expose :namespace + expose :authorization_type + expose :ca_cert + end + end + + module Provider + class Gcp < Grape::Entity + expose :cluster_id + expose :status_name + expose :gcp_project_id + expose :zone + expose :machine_type + expose :num_nodes + expose :endpoint + end + end + + class Cluster < Grape::Entity + expose :id, :name, :created_at + expose :provider_type, :platform_type, :environment_scope, :cluster_type + expose :user, using: Entities::UserBasic + expose :platform_kubernetes, using: Entities::Platform::Kubernetes + expose :provider_gcp, using: Entities::Provider::Gcp + end + + class ClusterProject < Cluster + expose :project, using: Entities::BasicProjectDetails + end end end diff --git a/lib/api/project_clusters.rb b/lib/api/project_clusters.rb new file mode 100644 index 00000000000..7aada260297 --- /dev/null +++ b/lib/api/project_clusters.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +module API + class ProjectClusters < Grape::API + include PaginationParams + + before { authenticate! } + + # EE::API::ProjectClusters will + # override these methods + helpers do + params :create_params_ee do + end + + params :update_params_ee do + end + end + + params do + requires :id, type: String, desc: 'The ID of the project' + end + resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do + desc 'Get all clusters from the project' do + detail 'This feature was introduced in GitLab 11.7.' + success Entities::Cluster + end + params do + use :pagination + end + get ':id/clusters' do + authorize! :read_cluster, user_project + + present paginate(clusters_for_current_user), with: Entities::Cluster + end + + desc 'Get specific cluster for the project' do + detail 'This feature was introduced in GitLab 11.7.' + success Entities::ClusterProject + end + params do + requires :cluster_id, type: Integer, desc: 'The cluster ID' + end + get ':id/clusters/:cluster_id' do + authorize! :read_cluster, cluster + + present cluster, with: Entities::ClusterProject + end + + desc 'Adds an existing cluster' do + detail 'This feature was introduced in GitLab 11.7.' + success Entities::ClusterProject + end + params do + requires :name, type: String, desc: 'Cluster name' + optional :enabled, type: Boolean, default: true, desc: 'Determines if cluster is active or not, defaults to true' + requires :platform_kubernetes_attributes, type: Hash, desc: %q(Platform Kubernetes data) do + requires :api_url, type: String, allow_blank: false, desc: 'URL to access the Kubernetes API' + requires :token, type: String, desc: 'Token to authenticate against Kubernetes' + optional :ca_cert, type: String, desc: 'TLS certificate (needed if API is using a self-signed TLS certificate)' + optional :namespace, type: String, desc: 'Unique namespace related to Project' + optional :authorization_type, type: String, values: Clusters::Platforms::Kubernetes.authorization_types.keys, default: 'rbac', desc: 'Cluster authorization type, defaults to RBAC' + end + use :create_params_ee + end + post ':id/clusters/user' do + authorize! :create_cluster, user_project + + user_cluster = ::Clusters::CreateService + .new(current_user, create_cluster_user_params) + .execute + + if user_cluster.persisted? + present user_cluster, with: Entities::ClusterProject + else + render_validation_error!(user_cluster) + end + end + + desc 'Update an existing cluster' do + detail 'This feature was introduced in GitLab 11.7.' + success Entities::ClusterProject + end + params do + requires :cluster_id, type: Integer, desc: 'The cluster ID' + optional :name, type: String, desc: 'Cluster name' + optional :platform_kubernetes_attributes, type: Hash, desc: %q(Platform Kubernetes data) do + optional :api_url, type: String, desc: 'URL to access the Kubernetes API' + optional :token, type: String, desc: 'Token to authenticate against Kubernetes' + optional :ca_cert, type: String, desc: 'TLS certificate (needed if API is using a self-signed TLS certificate)' + optional :namespace, type: String, desc: 'Unique namespace related to Project' + end + use :update_params_ee + end + put ':id/clusters/:cluster_id' do + authorize! :update_cluster, cluster + + update_service = Clusters::UpdateService.new(current_user, update_cluster_params) + + if update_service.execute(cluster) + present cluster, with: Entities::ClusterProject + else + render_validation_error!(cluster) + end + end + + desc 'Remove a cluster' do + detail 'This feature was introduced in GitLab 11.7.' + success Entities::ClusterProject + end + params do + requires :cluster_id, type: Integer, desc: 'The Cluster ID' + end + delete ':id/clusters/:cluster_id' do + authorize! :admin_cluster, cluster + + destroy_conditionally!(cluster) + end + end + + helpers do + def clusters_for_current_user + @clusters_for_current_user ||= ClustersFinder.new(user_project, current_user, :all).execute + end + + def cluster + @cluster ||= clusters_for_current_user.find(params[:cluster_id]) + end + + def create_cluster_user_params + declared_params.merge({ + provider_type: :user, + platform_type: :kubernetes, + clusterable: user_project + }) + end + + def update_cluster_params + declared_params(include_missing: false).without(:cluster_id) + end + end + end +end diff --git a/spec/requests/api/project_clusters_spec.rb b/spec/requests/api/project_clusters_spec.rb new file mode 100644 index 00000000000..e34164aa66a --- /dev/null +++ b/spec/requests/api/project_clusters_spec.rb @@ -0,0 +1,450 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe API::ProjectClusters do + include KubernetesHelpers + + let(:current_user) { create(:user) } + let(:non_member) { create(:user) } + let(:project) { create(:project, :repository) } + + before do + project.add_maintainer(current_user) + end + + describe 'GET /projects/:id/clusters' do + let!(:extra_cluster) { create(:cluster, :provided_by_gcp, :project) } + + let!(:clusters) do + create_list(:cluster, 5, :provided_by_gcp, :project, :production_environment, + projects: [project]) + end + + context 'non-authorized user' do + it 'should respond with 404' do + get api("/projects/#{project.id}/clusters", non_member) + + expect(response).to have_gitlab_http_status(404) + end + end + + context 'authorized user' do + before do + get api("/projects/#{project.id}/clusters", current_user) + end + + it 'should respond with 200' do + expect(response).to have_gitlab_http_status(200) + end + + it 'should include pagination headers' do + expect(response).to include_pagination_headers + end + + it 'should only include authorized clusters' do + cluster_ids = json_response.map { |cluster| cluster['id'] } + + expect(cluster_ids).to match_array(clusters.pluck(:id)) + expect(cluster_ids).not_to include(extra_cluster.id) + end + end + end + + describe 'GET /projects/:id/clusters/:cluster_id' do + let(:cluster_id) { cluster.id } + + let(:platform_kubernetes) do + create(:cluster_platform_kubernetes, :configured, + namespace: 'project-namespace') + end + + let(:cluster) do + create(:cluster, :project, :provided_by_gcp, + platform_kubernetes: platform_kubernetes, + user: current_user, + projects: [project]) + end + + context 'non-authorized user' do + it 'should respond with 404' do + get api("/projects/#{project.id}/clusters/#{cluster_id}", non_member) + + expect(response).to have_gitlab_http_status(404) + end + end + + context 'authorized user' do + before do + get api("/projects/#{project.id}/clusters/#{cluster_id}", current_user) + end + + it 'returns specific cluster' do + expect(json_response['id']).to eq(cluster.id) + end + + it 'returns cluster information' do + expect(json_response['provider_type']).to eq('gcp') + expect(json_response['platform_type']).to eq('kubernetes') + expect(json_response['environment_scope']).to eq('*') + expect(json_response['cluster_type']).to eq('project_type') + end + + it 'returns project information' do + cluster_project = json_response['project'] + + expect(cluster_project['id']).to eq(project.id) + expect(cluster_project['name']).to eq(project.name) + expect(cluster_project['path']).to eq(project.path) + end + + it 'returns kubernetes platform information' do + platform = json_response['platform_kubernetes'] + + expect(platform['api_url']).to eq('https://kubernetes.example.com') + expect(platform['namespace']).to eq('project-namespace') + expect(platform['ca_cert']).to be_present + end + + it 'returns user information' do + user = json_response['user'] + + expect(user['id']).to eq(current_user.id) + expect(user['username']).to eq(current_user.username) + end + + it 'returns GCP provider information' do + gcp_provider = json_response['provider_gcp'] + + expect(gcp_provider['cluster_id']).to eq(cluster.id) + expect(gcp_provider['status_name']).to eq('created') + expect(gcp_provider['gcp_project_id']).to eq('test-gcp-project') + expect(gcp_provider['zone']).to eq('us-central1-a') + expect(gcp_provider['machine_type']).to eq('n1-standard-2') + expect(gcp_provider['num_nodes']).to eq(3) + expect(gcp_provider['endpoint']).to eq('111.111.111.111') + end + + context 'when cluster has no provider' do + let(:cluster) do + create(:cluster, :project, :provided_by_user, + projects: [project]) + end + + it 'should not include GCP provider info' do + expect(json_response['provider_gcp']).not_to be_present + end + end + + context 'with non-existing cluster' do + let(:cluster_id) { 123 } + + it 'returns 404' do + expect(response).to have_gitlab_http_status(404) + end + end + end + end + + shared_context 'kubernetes calls stubbed' do + before do + stub_kubeclient_discover(api_url) + stub_kubeclient_get_namespace(api_url, namespace: namespace) + stub_kubeclient_get_service_account(api_url, "#{namespace}-service-account", namespace: namespace) + stub_kubeclient_put_service_account(api_url, "#{namespace}-service-account", namespace: namespace) + + stub_kubeclient_get_secret( + api_url, + { + metadata_name: "#{namespace}-token", + token: Base64.encode64('sample-token'), + namespace: namespace + } + ) + + stub_kubeclient_put_secret(api_url, "#{namespace}-token", namespace: namespace) + stub_kubeclient_get_role_binding(api_url, "gitlab-#{namespace}", namespace: namespace) + stub_kubeclient_put_role_binding(api_url, "gitlab-#{namespace}", namespace: namespace) + end + end + + describe 'POST /projects/:id/clusters/user' do + include_context 'kubernetes calls stubbed' + + let(:api_url) { 'https://kubernetes.example.com' } + let(:namespace) { project.path } + let(:authorization_type) { 'rbac' } + + let(:platform_kubernetes_attributes) do + { + api_url: api_url, + token: 'sample-token', + namespace: namespace, + authorization_type: authorization_type + } + end + + let(:cluster_params) do + { + name: 'test-cluster', + platform_kubernetes_attributes: platform_kubernetes_attributes + } + end + + context 'non-authorized user' do + it 'should respond with 404' do + post api("/projects/#{project.id}/clusters/user", non_member), params: cluster_params + + expect(response).to have_gitlab_http_status(404) + end + end + + context 'authorized user' do + before do + post api("/projects/#{project.id}/clusters/user", current_user), params: cluster_params + end + + context 'with valid params' do + it 'should respond with 201' do + expect(response).to have_gitlab_http_status(201) + end + + it 'should create a new Cluster::Cluster' do + cluster_result = Clusters::Cluster.find(json_response["id"]) + platform_kubernetes = cluster_result.platform + + expect(cluster_result).to be_user + expect(cluster_result).to be_kubernetes + expect(cluster_result.project).to eq(project) + expect(cluster_result.name).to eq('test-cluster') + expect(platform_kubernetes.rbac?).to be_truthy + expect(platform_kubernetes.api_url).to eq(api_url) + expect(platform_kubernetes.namespace).to eq(namespace) + expect(platform_kubernetes.token).to eq('sample-token') + end + end + + context 'when user does not indicate authorization type' do + let(:platform_kubernetes_attributes) do + { + api_url: api_url, + token: 'sample-token', + namespace: namespace + } + end + + it 'defaults to RBAC' do + cluster_result = Clusters::Cluster.find(json_response['id']) + + expect(cluster_result.platform_kubernetes.rbac?).to be_truthy + end + end + + context 'when user sets authorization type as ABAC' do + let(:authorization_type) { 'abac' } + + it 'should create an ABAC cluster' do + cluster_result = Clusters::Cluster.find(json_response['id']) + + expect(cluster_result.platform.abac?).to be_truthy + end + end + + context 'with invalid params' do + let(:namespace) { 'invalid_namespace' } + + it 'should respond with 400' do + expect(response).to have_gitlab_http_status(400) + end + + it 'should not create a new Clusters::Cluster' do + expect(project.reload.clusters).to be_empty + end + + it 'should return validation errors' do + expect(json_response['message']['platform_kubernetes.namespace'].first).to be_present + end + end + end + end + + describe 'PUT /projects/:id/clusters/:cluster_id' do + include_context 'kubernetes calls stubbed' + + let(:api_url) { 'https://kubernetes.example.com' } + let(:namespace) { 'new-namespace' } + let(:platform_kubernetes_attributes) { { namespace: namespace } } + + let(:update_params) do + { + platform_kubernetes_attributes: platform_kubernetes_attributes + } + end + + let!(:kubernetes_namespace) do + create(:cluster_kubernetes_namespace, + cluster: cluster, + project: project) + end + + let(:cluster) do + create(:cluster, :project, :provided_by_gcp, + projects: [project]) + end + + context 'non-authorized user' do + it 'should respond with 404' do + put api("/projects/#{project.id}/clusters/#{cluster.id}", non_member), params: update_params + + expect(response).to have_gitlab_http_status(404) + end + end + + context 'authorized user' do + before do + put api("/projects/#{project.id}/clusters/#{cluster.id}", current_user), params: update_params + + cluster.reload + end + + context 'with valid params' do + it 'should respond with 200' do + expect(response).to have_gitlab_http_status(200) + end + + it 'should update cluster attributes' do + expect(cluster.platform_kubernetes.namespace).to eq('new-namespace') + expect(cluster.kubernetes_namespace.namespace).to eq('new-namespace') + end + end + + context 'with invalid params' do + let(:namespace) { 'invalid_namespace' } + + it 'should respond with 400' do + expect(response).to have_gitlab_http_status(400) + end + + it 'should not update cluster attributes' do + expect(cluster.platform_kubernetes.namespace).not_to eq('invalid_namespace') + expect(cluster.kubernetes_namespace.namespace).not_to eq('invalid_namespace') + end + + it 'should return validation errors' do + expect(json_response['message']['platform_kubernetes.namespace'].first).to match('can contain only lowercase letters') + end + end + + context 'with a GCP cluster' do + context 'when user tries to change GCP specific fields' do + let(:platform_kubernetes_attributes) do + { + api_url: 'https://new-api-url.com', + token: 'new-sample-token' + } + end + + it 'should respond with 400' do + expect(response).to have_gitlab_http_status(400) + end + + it 'should return validation error' do + expect(json_response['message']['platform_kubernetes.base'].first).to eq('Cannot modify managed Kubernetes cluster') + end + end + + context 'when user tries to change namespace' do + let(:namespace) { 'new-namespace' } + + it 'should respond with 200' do + expect(response).to have_gitlab_http_status(200) + end + end + end + + context 'with an user cluster' do + let(:api_url) { 'https://new-api-url.com' } + + let(:cluster) do + create(:cluster, :project, :provided_by_user, + projects: [project]) + end + + let(:platform_kubernetes_attributes) do + { + api_url: api_url, + namespace: 'new-namespace', + token: 'new-sample-token' + } + end + + let(:update_params) do + { + name: 'new-name', + platform_kubernetes_attributes: platform_kubernetes_attributes + } + end + + it 'should respond with 200' do + expect(response).to have_gitlab_http_status(200) + end + + it 'should update platform kubernetes attributes' do + platform_kubernetes = cluster.platform_kubernetes + + expect(cluster.name).to eq('new-name') + expect(platform_kubernetes.namespace).to eq('new-namespace') + expect(platform_kubernetes.api_url).to eq('https://new-api-url.com') + expect(platform_kubernetes.token).to eq('new-sample-token') + end + end + + context 'with a cluster that does not belong to user' do + let(:cluster) { create(:cluster, :project, :provided_by_user) } + + it 'should respond with 404' do + expect(response).to have_gitlab_http_status(404) + end + end + end + end + + describe 'DELETE /projects/:id/clusters/:cluster_id' do + let(:cluster_params) { { cluster_id: cluster.id } } + + let(:cluster) do + create(:cluster, :project, :provided_by_gcp, + projects: [project]) + end + + context 'non-authorized user' do + it 'should respond with 404' do + delete api("/projects/#{project.id}/clusters/#{cluster.id}", non_member), params: cluster_params + + expect(response).to have_gitlab_http_status(404) + end + end + + context 'authorized user' do + before do + delete api("/projects/#{project.id}/clusters/#{cluster.id}", current_user), params: cluster_params + end + + it 'should respond with 204' do + expect(response).to have_gitlab_http_status(204) + end + + it 'should delete the cluster' do + expect(Clusters::Cluster.exists?(id: cluster.id)).to be_falsy + end + + context 'with a cluster that does not belong to user' do + let(:cluster) { create(:cluster, :project, :provided_by_user) } + + it 'should respond with 404' do + expect(response).to have_gitlab_http_status(404) + end + end + end + end +end -- cgit v1.2.3