From 6f2ad2b6041b8a007df7eb8c4f477c24cc153ac3 Mon Sep 17 00:00:00 2001 From: Thong Kuah Date: Thu, 6 Sep 2018 10:03:38 +0000 Subject: [PATCH] Enable Kubernetes RBAC for GitLab Managed Apps for existing clusters --- .../projects/clusters_controller.rb | 3 +- app/helpers/clusters_helper.rb | 4 + app/models/clusters/applications/helm.rb | 3 +- app/models/clusters/applications/ingress.rb | 1 + app/models/clusters/applications/jupyter.rb | 1 + .../clusters/applications/prometheus.rb | 3 +- app/models/clusters/applications/runner.rb | 1 + app/models/clusters/cluster.rb | 1 + app/models/clusters/platforms/kubernetes.rb | 27 +- .../project_services/kubernetes_service.rb | 26 +- .../projects/clusters/user/_form.html.haml | 9 + .../projects/clusters/user/_show.html.haml | 9 + ...ubernetes-rbac-for-gitlab-managed-apps.yml | 5 + ...on_type_to_cluster_platforms_kubernetes.rb | 11 + db/schema.rb | 1 + lib/gitlab/kubernetes/cluster_role_binding.rb | 37 +++ lib/gitlab/kubernetes/helm.rb | 3 + lib/gitlab/kubernetes/helm/api.rb | 48 ++++ lib/gitlab/kubernetes/helm/base_command.rb | 20 +- lib/gitlab/kubernetes/helm/init_command.rb | 60 ++++- lib/gitlab/kubernetes/helm/install_command.rb | 52 +++- lib/gitlab/kubernetes/helm/pod.rb | 6 +- lib/gitlab/kubernetes/kube_client.rb | 110 ++++++++ lib/gitlab/kubernetes/service_account.rb | 27 ++ locale/gitlab.pot | 9 + .../projects/clusters_controller_spec.rb | 32 +++ .../clusters/platforms/kubernetes.rb | 4 + spec/features/projects/clusters/user_spec.rb | 22 ++ .../kubernetes/cluster_role_binding_spec.rb | 35 +++ spec/lib/gitlab/kubernetes/helm/api_spec.rb | 107 +++++++- .../kubernetes/helm/base_command_spec.rb | 20 +- .../kubernetes/helm/init_command_spec.rb | 130 ++++++++- .../kubernetes/helm/install_command_spec.rb | 155 ++++++++++- spec/lib/gitlab/kubernetes/helm/pod_spec.rb | 17 +- .../lib/gitlab/kubernetes/kube_client_spec.rb | 247 ++++++++++++++++++ .../gitlab/kubernetes/service_account_spec.rb | 24 ++ .../models/clusters/applications/helm_spec.rb | 14 + .../clusters/applications/ingress_spec.rb | 9 + .../clusters/applications/jupyter_spec.rb | 9 + .../clusters/applications/prometheus_spec.rb | 35 ++- .../clusters/applications/runner_spec.rb | 9 + spec/models/clusters/cluster_spec.rb | 4 + .../clusters/platforms/kubernetes_spec.rb | 24 ++ spec/support/helpers/kubernetes_helpers.rb | 19 +- 44 files changed, 1301 insertions(+), 92 deletions(-) create mode 100644 changelogs/unreleased/29398-support-kubernetes-rbac-for-gitlab-managed-apps.yml create mode 100644 db/migrate/20180815040323_add_authorization_type_to_cluster_platforms_kubernetes.rb create mode 100644 lib/gitlab/kubernetes/cluster_role_binding.rb create mode 100644 lib/gitlab/kubernetes/kube_client.rb create mode 100644 lib/gitlab/kubernetes/service_account.rb create mode 100644 spec/lib/gitlab/kubernetes/cluster_role_binding_spec.rb create mode 100644 spec/lib/gitlab/kubernetes/kube_client_spec.rb create mode 100644 spec/lib/gitlab/kubernetes/service_account_spec.rb diff --git a/app/controllers/projects/clusters_controller.rb b/app/controllers/projects/clusters_controller.rb index 358fe59618b..b4fd09c06e5 100644 --- a/app/controllers/projects/clusters_controller.rb +++ b/app/controllers/projects/clusters_controller.rb @@ -157,7 +157,8 @@ class Projects::ClustersController < Projects::ApplicationController :namespace, :api_url, :token, - :ca_cert + :ca_cert, + :authorization_type ]).merge( provider_type: :user, platform_type: :kubernetes diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb index 8fd0b6f14c6..73049c74d80 100644 --- a/app/helpers/clusters_helper.rb +++ b/app/helpers/clusters_helper.rb @@ -11,4 +11,8 @@ module ClustersHelper render 'projects/clusters/gcp_signup_offer_banner' end end + + def rbac_clusters_feature_enabled? + Feature.enabled?(:rbac_clusters) + end end diff --git a/app/models/clusters/applications/helm.rb b/app/models/clusters/applications/helm.rb index 55bbf7cae7e..423071ec024 100644 --- a/app/models/clusters/applications/helm.rb +++ b/app/models/clusters/applications/helm.rb @@ -32,7 +32,8 @@ module Clusters def install_command Gitlab::Kubernetes::Helm::InitCommand.new( name: name, - files: files + files: files, + rbac: cluster.platform_kubernetes_rbac? ) end diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb index 93f654e0638..bd0286ee3f9 100644 --- a/app/models/clusters/applications/ingress.rb +++ b/app/models/clusters/applications/ingress.rb @@ -39,6 +39,7 @@ module Clusters Gitlab::Kubernetes::Helm::InstallCommand.new( name: name, version: VERSION, + rbac: cluster.platform_kubernetes_rbac?, chart: chart, files: files ) diff --git a/app/models/clusters/applications/jupyter.rb b/app/models/clusters/applications/jupyter.rb index ef1c76c03bd..3d84eeed5a8 100644 --- a/app/models/clusters/applications/jupyter.rb +++ b/app/models/clusters/applications/jupyter.rb @@ -40,6 +40,7 @@ module Clusters Gitlab::Kubernetes::Helm::InstallCommand.new( name: name, version: VERSION, + rbac: cluster.platform_kubernetes_rbac?, chart: chart, files: files, repository: repository diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb index 88399dbbb95..46d0388a464 100644 --- a/app/models/clusters/applications/prometheus.rb +++ b/app/models/clusters/applications/prometheus.rb @@ -48,6 +48,7 @@ module Clusters Gitlab::Kubernetes::Helm::InstallCommand.new( name: name, version: VERSION, + rbac: cluster.platform_kubernetes_rbac?, chart: chart, files: files ) @@ -71,7 +72,7 @@ module Clusters private def kube_client - cluster&.kubeclient + cluster&.kubeclient&.core_client end end end diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb index bde255723c8..a4a2e2b79a6 100644 --- a/app/models/clusters/applications/runner.rb +++ b/app/models/clusters/applications/runner.rb @@ -33,6 +33,7 @@ module Clusters Gitlab::Kubernetes::Helm::InstallCommand.new( name: name, version: VERSION, + rbac: cluster.platform_kubernetes_rbac?, chart: chart, files: files, repository: repository diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index 7cf75403ab6..d7011ef447a 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -42,6 +42,7 @@ module Clusters delegate :on_creation?, to: :provider, allow_nil: true delegate :active?, to: :platform_kubernetes, prefix: true, allow_nil: true + delegate :rbac?, to: :platform_kubernetes, prefix: true, allow_nil: true delegate :installed?, to: :application_helm, prefix: true, allow_nil: true delegate :installed?, to: :application_ingress, prefix: true, allow_nil: true diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb index e6ddca0d5d0..3a335909101 100644 --- a/app/models/clusters/platforms/kubernetes.rb +++ b/app/models/clusters/platforms/kubernetes.rb @@ -5,6 +5,7 @@ module Clusters class Kubernetes < ActiveRecord::Base include Gitlab::Kubernetes include ReactiveCaching + include EnumWithNil self.table_name = 'cluster_platforms_kubernetes' self.reactive_cache_key = ->(kubernetes) { [kubernetes.class.model_name.singular, kubernetes.id] } @@ -47,6 +48,12 @@ module Clusters alias_method :active?, :enabled? + enum_with_nil authorization_type: { + unknown_authorization: nil, + rbac: 1, + abac: 2 + } + def actual_namespace if namespace.present? namespace @@ -95,7 +102,7 @@ module Clusters end def kubeclient - @kubeclient ||= build_kubeclient! + @kubeclient ||= build_kube_client!(api_groups: ['api', 'apis/rbac.authorization.k8s.io']) end private @@ -115,15 +122,16 @@ module Clusters slug.gsub(/[^-a-z0-9]/, '-').gsub(/^-+/, '') end - def build_kubeclient!(api_path: 'api', api_version: 'v1') + def build_kube_client!(api_groups: ['api'], api_version: 'v1') raise "Incomplete settings" unless api_url && actual_namespace unless (username && password) || token raise "Either username/password or token is required to access API" end - ::Kubeclient::Client.new( - join_api_url(api_path), + Gitlab::Kubernetes::KubeClient.new( + api_url, + api_groups, api_version, auth_options: kubeclient_auth_options, ssl_options: kubeclient_ssl_options, @@ -133,7 +141,7 @@ module Clusters # Returns a hash of all pods in the namespace def read_pods - kubeclient = build_kubeclient! + kubeclient = build_kube_client! kubeclient.get_pods(namespace: actual_namespace).as_json rescue Kubeclient::HttpError => err @@ -157,15 +165,6 @@ module Clusters { bearer_token: token } 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 terminal_auth { token: token, diff --git a/app/models/project_services/kubernetes_service.rb b/app/models/project_services/kubernetes_service.rb index bda1f67b8ff..f119555f16b 100644 --- a/app/models/project_services/kubernetes_service.rb +++ b/app/models/project_services/kubernetes_service.rb @@ -96,10 +96,10 @@ class KubernetesService < DeploymentService # Check we can connect to the Kubernetes API def test(*args) - kubeclient = build_kubeclient! + kubeclient = build_kube_client! - kubeclient.discover - { success: kubeclient.discovered, result: "Checked API discovery endpoint" } + kubeclient.core_client.discover + { success: kubeclient.core_client.discovered, result: "Checked API discovery endpoint" } rescue => err { success: false, result: err } end @@ -144,7 +144,7 @@ class KubernetesService < DeploymentService end def kubeclient - @kubeclient ||= build_kubeclient! + @kubeclient ||= build_kube_client!(api_groups: ['api', 'apis/rbac.authorization.k8s.io']) end def deprecated? @@ -182,11 +182,12 @@ class KubernetesService < DeploymentService slug.gsub(/[^-a-z0-9]/, '-').gsub(/^-+/, '') end - def build_kubeclient!(api_path: 'api', api_version: 'v1') + def build_kube_client!(api_groups: ['api'], api_version: 'v1') raise "Incomplete settings" unless api_url && actual_namespace && token - ::Kubeclient::Client.new( - join_api_url(api_path), + Gitlab::Kubernetes::KubeClient.new( + api_url, + api_groups, api_version, auth_options: kubeclient_auth_options, ssl_options: kubeclient_ssl_options, @@ -196,7 +197,7 @@ class KubernetesService < DeploymentService # Returns a hash of all pods in the namespace def read_pods - kubeclient = build_kubeclient! + kubeclient = build_kube_client! kubeclient.get_pods(namespace: actual_namespace).as_json rescue Kubeclient::HttpError => err @@ -220,15 +221,6 @@ class KubernetesService < DeploymentService { bearer_token: token } 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 terminal_auth { token: token, diff --git a/app/views/projects/clusters/user/_form.html.haml b/app/views/projects/clusters/user/_form.html.haml index e8ef0008802..1f81e024ab9 100644 --- a/app/views/projects/clusters/user/_form.html.haml +++ b/app/views/projects/clusters/user/_form.html.haml @@ -25,5 +25,14 @@ = platform_kubernetes_field.label :namespace, s_('ClusterIntegration|Project namespace (optional, unique)'), class: 'label-bold' = platform_kubernetes_field.text_field :namespace, class: 'form-control', placeholder: s_('ClusterIntegration|Project namespace') + - if rbac_clusters_feature_enabled? + .form-group + .form-check + = platform_kubernetes_field.check_box :authorization_type, { class: 'form-check-input' }, 'rbac', 'abac' + = platform_kubernetes_field.label :authorization_type, s_('ClusterIntegration|RBAC-enabled cluster (experimental)'), class: 'form-check-label label-bold' + .form-text.text-muted + = s_('ClusterIntegration|Enable this setting if using role-based access control (RBAC).') + = s_('ClusterIntegration|This option will allow you to install applications on RBAC clusters.') + .form-group = field.submit s_('ClusterIntegration|Add Kubernetes cluster'), class: 'btn btn-success' diff --git a/app/views/projects/clusters/user/_show.html.haml b/app/views/projects/clusters/user/_show.html.haml index 20a07d6695e..56b597d295a 100644 --- a/app/views/projects/clusters/user/_show.html.haml +++ b/app/views/projects/clusters/user/_show.html.haml @@ -26,5 +26,14 @@ = platform_kubernetes_field.label :namespace, s_('ClusterIntegration|Project namespace (optional, unique)'), class: 'label-bold' = platform_kubernetes_field.text_field :namespace, class: 'form-control', placeholder: s_('ClusterIntegration|Project namespace') + - if rbac_clusters_feature_enabled? + .form-group + .form-check + = platform_kubernetes_field.check_box :authorization_type, { class: 'form-check-input', disabled: true }, 'rbac', 'abac' + = platform_kubernetes_field.label :authorization_type, s_('ClusterIntegration|RBAC-enabled cluster (experimental)'), class: 'form-check-label label-bold' + .form-text.text-muted + = s_('ClusterIntegration|Enable this setting if using role-based access control (RBAC).') + = s_('ClusterIntegration|This option will allow you to install applications on RBAC clusters.') + .form-group = field.submit s_('ClusterIntegration|Save changes'), class: 'btn btn-success' diff --git a/changelogs/unreleased/29398-support-kubernetes-rbac-for-gitlab-managed-apps.yml b/changelogs/unreleased/29398-support-kubernetes-rbac-for-gitlab-managed-apps.yml new file mode 100644 index 00000000000..c182946b299 --- /dev/null +++ b/changelogs/unreleased/29398-support-kubernetes-rbac-for-gitlab-managed-apps.yml @@ -0,0 +1,5 @@ +--- +title: Support Kubernetes RBAC for GitLab Managed Apps when adding a existing cluster +merge_request: 21127 +author: +type: changed diff --git a/db/migrate/20180815040323_add_authorization_type_to_cluster_platforms_kubernetes.rb b/db/migrate/20180815040323_add_authorization_type_to_cluster_platforms_kubernetes.rb new file mode 100644 index 00000000000..6397d6dd99f --- /dev/null +++ b/db/migrate/20180815040323_add_authorization_type_to_cluster_platforms_kubernetes.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddAuthorizationTypeToClusterPlatformsKubernetes < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :cluster_platforms_kubernetes, :authorization_type, :integer, limit: 2 + end +end diff --git a/db/schema.rb b/db/schema.rb index f48df68b785..1d05be0d3e8 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -588,6 +588,7 @@ ActiveRecord::Schema.define(version: 20180826111825) do t.string "encrypted_password_iv" t.text "encrypted_token" t.string "encrypted_token_iv" + t.integer "authorization_type", limit: 2 end add_index "cluster_platforms_kubernetes", ["cluster_id"], name: "index_cluster_platforms_kubernetes_on_cluster_id", unique: true, using: :btree diff --git a/lib/gitlab/kubernetes/cluster_role_binding.rb b/lib/gitlab/kubernetes/cluster_role_binding.rb new file mode 100644 index 00000000000..ebea8aff5be --- /dev/null +++ b/lib/gitlab/kubernetes/cluster_role_binding.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Gitlab + module Kubernetes + class ClusterRoleBinding + attr_reader :name, :cluster_role_name, :subjects + + def initialize(name, cluster_role_name, subjects) + @name = name + @cluster_role_name = cluster_role_name + @subjects = subjects + end + + def generate + ::Kubeclient::Resource.new.tap do |resource| + resource.metadata = metadata + resource.roleRef = role_ref + resource.subjects = subjects + end + end + + private + + def metadata + { name: name } + end + + def role_ref + { + apiGroup: 'rbac.authorization.k8s.io', + kind: 'ClusterRole', + name: cluster_role_name + } + end + end + end +end diff --git a/lib/gitlab/kubernetes/helm.rb b/lib/gitlab/kubernetes/helm.rb index 530ccf88053..4a1bdf34c3e 100644 --- a/lib/gitlab/kubernetes/helm.rb +++ b/lib/gitlab/kubernetes/helm.rb @@ -3,6 +3,9 @@ module Gitlab module Helm HELM_VERSION = '2.7.2'.freeze NAMESPACE = 'gitlab-managed-apps'.freeze + SERVICE_ACCOUNT = 'tiller'.freeze + CLUSTER_ROLE_BINDING = 'tiller-admin'.freeze + CLUSTER_ROLE = 'cluster-admin'.freeze end end end diff --git a/lib/gitlab/kubernetes/helm/api.rb b/lib/gitlab/kubernetes/helm/api.rb index d65374cc23b..2dd74c68075 100644 --- a/lib/gitlab/kubernetes/helm/api.rb +++ b/lib/gitlab/kubernetes/helm/api.rb @@ -9,7 +9,11 @@ module Gitlab def install(command) namespace.ensure_exists! + + create_service_account(command) + create_cluster_role_binding(command) create_config_map(command) + kubeclient.create_pod(command.pod_resource) end @@ -41,6 +45,50 @@ module Gitlab kubeclient.create_config_map(config_map_resource) end end + + def create_service_account(command) + command.service_account_resource.tap do |service_account_resource| + break unless service_account_resource + + if service_account_exists?(service_account_resource) + kubeclient.update_service_account(service_account_resource) + else + kubeclient.create_service_account(service_account_resource) + end + end + end + + def create_cluster_role_binding(command) + command.cluster_role_binding_resource.tap do |cluster_role_binding_resource| + break unless cluster_role_binding_resource + + if cluster_role_binding_exists?(cluster_role_binding_resource) + kubeclient.update_cluster_role_binding(cluster_role_binding_resource) + else + kubeclient.create_cluster_role_binding(cluster_role_binding_resource) + end + end + end + + def service_account_exists?(resource) + resource_exists? do + kubeclient.get_service_account(resource.metadata.name, resource.metadata.namespace) + end + end + + def cluster_role_binding_exists?(resource) + resource_exists? do + kubeclient.get_cluster_role_binding(resource.metadata.name) + end + end + + def resource_exists? + yield + rescue ::Kubeclient::HttpError => e + raise e unless e.error_code == 404 + + false + end end end end diff --git a/lib/gitlab/kubernetes/helm/base_command.rb b/lib/gitlab/kubernetes/helm/base_command.rb index afcfd109de0..6752f2cff43 100644 --- a/lib/gitlab/kubernetes/helm/base_command.rb +++ b/lib/gitlab/kubernetes/helm/base_command.rb @@ -3,7 +3,9 @@ module Gitlab module Helm module BaseCommand def pod_resource - Gitlab::Kubernetes::Helm::Pod.new(self, namespace).generate + pod_service_account_name = rbac? ? service_account_name : nil + + Gitlab::Kubernetes::Helm::Pod.new(self, namespace, service_account_name: pod_service_account_name).generate end def generate_script @@ -26,6 +28,14 @@ module Gitlab Gitlab::Kubernetes::ConfigMap.new(name, files).generate end + def service_account_resource + nil + end + + def cluster_role_binding_resource + nil + end + def file_names files.keys end @@ -34,6 +44,10 @@ module Gitlab raise "Not implemented" end + def rbac? + raise "Not implemented" + end + def files raise "Not implemented" end @@ -47,6 +61,10 @@ module Gitlab def namespace Gitlab::Kubernetes::Helm::NAMESPACE end + + def service_account_name + Gitlab::Kubernetes::Helm::SERVICE_ACCOUNT + end end end end diff --git a/lib/gitlab/kubernetes/helm/init_command.rb b/lib/gitlab/kubernetes/helm/init_command.rb index a4546509515..c7046a9ea75 100644 --- a/lib/gitlab/kubernetes/helm/init_command.rb +++ b/lib/gitlab/kubernetes/helm/init_command.rb @@ -6,9 +6,10 @@ module Gitlab attr_reader :name, :files - def initialize(name:, files:) + def initialize(name:, files:, rbac:) @name = name @files = files + @rbac = rbac end def generate_script @@ -17,15 +18,62 @@ module Gitlab ].join("\n") end + def rbac? + @rbac + end + + def service_account_resource + return unless rbac? + + Gitlab::Kubernetes::ServiceAccount.new(service_account_name, namespace).generate + end + + def cluster_role_binding_resource + return unless rbac? + + subjects = [{ kind: 'ServiceAccount', name: service_account_name, namespace: namespace }] + + Gitlab::Kubernetes::ClusterRoleBinding.new( + cluster_role_binding_name, + cluster_role_name, + subjects + ).generate + end + private def init_helm_command - tls_flags = "--tiller-tls" \ - " --tiller-tls-verify --tls-ca-cert #{files_dir}/ca.pem" \ - " --tiller-tls-cert #{files_dir}/cert.pem" \ - " --tiller-tls-key #{files_dir}/key.pem" + command = %w[helm init] + init_command_flags - "helm init #{tls_flags} >/dev/null" + command.shelljoin + " >/dev/null\n" + end + + def init_command_flags + tls_flags + optional_service_account_flag + end + + def tls_flags + [ + '--tiller-tls', + '--tiller-tls-verify', + '--tls-ca-cert', "#{files_dir}/ca.pem", + '--tiller-tls-cert', "#{files_dir}/cert.pem", + '--tiller-tls-key', "#{files_dir}/key.pem" + ] + end + + def optional_service_account_flag + return [] unless rbac? + + ['--service-account', service_account_name] + end + + def cluster_role_binding_name + Gitlab::Kubernetes::Helm::CLUSTER_ROLE_BINDING + end + + def cluster_role_name + Gitlab::Kubernetes::Helm::CLUSTER_ROLE end end end diff --git a/lib/gitlab/kubernetes/helm/install_command.rb b/lib/gitlab/kubernetes/helm/install_command.rb index 9672f80687e..1be7924d6ac 100644 --- a/lib/gitlab/kubernetes/helm/install_command.rb +++ b/lib/gitlab/kubernetes/helm/install_command.rb @@ -6,10 +6,11 @@ module Gitlab attr_reader :name, :files, :chart, :version, :repository - def initialize(name:, chart:, files:, version: nil, repository: nil) + def initialize(name:, chart:, files:, rbac:, version: nil, repository: nil) @name = name @chart = chart @version = version + @rbac = rbac @files = files @repository = repository end @@ -22,6 +23,10 @@ module Gitlab ].compact.join("\n") end + def rbac? + @rbac + end + private def init_command @@ -29,28 +34,51 @@ module Gitlab end def repository_command - "helm repo add #{name} #{repository}" if repository + ['helm', 'repo', 'add', name, repository].shelljoin if repository end def script_command - init_flags = "--name #{name}#{optional_tls_flags}#{optional_version_flag}" \ - " --namespace #{Gitlab::Kubernetes::Helm::NAMESPACE}" \ - " -f /data/helm/#{name}/config/values.yaml" + command = ['helm', 'install', chart] + install_command_flags - "helm install #{chart} #{init_flags} >/dev/null\n" + command.shelljoin + " >/dev/null\n" + end + + def install_command_flags + name_flag = ['--name', name] + namespace_flag = ['--namespace', Gitlab::Kubernetes::Helm::NAMESPACE] + value_flag = ['-f', "/data/helm/#{name}/config/values.yaml"] + + name_flag + + optional_tls_flags + + optional_version_flag + + optional_rbac_create_flag + + namespace_flag + + value_flag + end + + def optional_rbac_create_flag + return [] unless rbac? + + # jupyterhub helm chart is using rbac.enabled + # https://github.com/jupyterhub/zero-to-jupyterhub-k8s/tree/master/jupyterhub + %w[--set rbac.create=true,rbac.enabled=true] end def optional_version_flag - " --version #{version}" if version + return [] unless version + + ['--version', version] end def optional_tls_flags - return unless files.key?(:'ca.pem') + return [] unless files.key?(:'ca.pem') - " --tls" \ - " --tls-ca-cert #{files_dir}/ca.pem" \ - " --tls-cert #{files_dir}/cert.pem" \ - " --tls-key #{files_dir}/key.pem" + [ + '--tls', + '--tls-ca-cert', "#{files_dir}/ca.pem", + '--tls-cert', "#{files_dir}/cert.pem", + '--tls-key', "#{files_dir}/key.pem" + ] end end end diff --git a/lib/gitlab/kubernetes/helm/pod.rb b/lib/gitlab/kubernetes/helm/pod.rb index 6e5d3388405..95192b11c0d 100644 --- a/lib/gitlab/kubernetes/helm/pod.rb +++ b/lib/gitlab/kubernetes/helm/pod.rb @@ -2,9 +2,10 @@ module Gitlab module Kubernetes module Helm class Pod - def initialize(command, namespace_name) + def initialize(command, namespace_name, service_account_name: nil) @command = command @namespace_name = namespace_name + @service_account_name = service_account_name end def generate @@ -12,13 +13,14 @@ module Gitlab spec[:volumes] = volumes_specification spec[:containers][0][:volumeMounts] = volume_mounts_specification + spec[:serviceAccountName] = service_account_name if service_account_name ::Kubeclient::Resource.new(metadata: metadata, spec: spec) end private - attr_reader :command, :namespace_name, :kubeclient, :config_map + attr_reader :command, :namespace_name, :service_account_name def container_specification { diff --git a/lib/gitlab/kubernetes/kube_client.rb b/lib/gitlab/kubernetes/kube_client.rb new file mode 100644 index 00000000000..8312b901524 --- /dev/null +++ b/lib/gitlab/kubernetes/kube_client.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require 'uri' + +module Gitlab + module Kubernetes + # Wrapper around Kubeclient::Client to dispatch + # the right message to the client that can respond to the message. + # We must have a kubeclient for each ApiGroup as there is no + # other way to use the Kubeclient gem. + # + # See https://github.com/abonas/kubeclient/issues/348. + class KubeClient + include Gitlab::Utils::StrongMemoize + + SUPPORTED_API_GROUPS = [ + 'api', + 'apis/rbac.authorization.k8s.io', + 'apis/extensions' + ].freeze + + # Core API methods delegates to the core api group client + delegate :get_pods, + :get_secrets, + :get_config_map, + :get_namespace, + :get_pod, + :get_service, + :get_service_account, + :delete_pod, + :create_config_map, + :create_namespace, + :create_pod, + :create_service_account, + :update_config_map, + :update_service_account, + to: :core_client + + # RBAC methods delegates to the apis/rbac.authorization.k8s.io api + # group client + delegate :create_cluster_role_binding, + :get_cluster_role_binding, + :update_cluster_role_binding, + to: :rbac_client + + # Deployments resource is currently on the apis/extensions api group + delegate :get_deployments, + to: :extensions_client + + # non-entity methods that can only work with the core client + # as it uses the pods/log resource + delegate :get_pod_log, + :watch_pod_log, + to: :core_client + + def initialize(api_prefix, api_groups = ['api'], api_version = 'v1', **kubeclient_options) + raise ArgumentError unless check_api_groups_supported?(api_groups) + + @api_prefix = api_prefix + @api_groups = api_groups + @api_version = api_version + @kubeclient_options = kubeclient_options + end + + def discover! + clients.each(&:discover) + end + + def clients + hashed_clients.values + end + + def core_client + hashed_clients['api'] + end + + def rbac_client + hashed_clients['apis/rbac.authorization.k8s.io'] + end + + def extensions_client + hashed_clients['apis/extensions'] + end + + def hashed_clients + strong_memoize(:hashed_clients) do + @api_groups.map do |api_group| + api_url = join_api_url(@api_prefix, api_group) + [api_group, ::Kubeclient::Client.new(api_url, @api_version, **@kubeclient_options)] + end.to_h + end + end + + private + + def check_api_groups_supported?(api_groups) + api_groups.all? {|api_group| SUPPORTED_API_GROUPS.include?(api_group) } + end + + def join_api_url(api_prefix, api_path) + url = URI.parse(api_prefix) + prefix = url.path.sub(%r{/+\z}, '') + + url.path = [prefix, api_path].join("/") + + url.to_s + end + end + end +end diff --git a/lib/gitlab/kubernetes/service_account.rb b/lib/gitlab/kubernetes/service_account.rb new file mode 100644 index 00000000000..d58fc1c3976 --- /dev/null +++ b/lib/gitlab/kubernetes/service_account.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Gitlab + module Kubernetes + class ServiceAccount + attr_reader :name, :namespace_name + + def initialize(name, namespace_name) + @name = name + @namespace_name = namespace_name + end + + def generate + ::Kubeclient::Resource.new(metadata: metadata) + end + + private + + def metadata + { + name: name, + namespace: namespace_name + } + end + end + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 2b7fe643d95..2302bdcde8f 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -1359,6 +1359,9 @@ msgstr "" msgid "ClusterIntegration|Did you know?" msgstr "" +msgid "ClusterIntegration|Enable this setting if using role-based access control (RBAC)." +msgstr "" + msgid "ClusterIntegration|Enter the details for your Kubernetes cluster" msgstr "" @@ -1542,6 +1545,9 @@ msgstr "" msgid "ClusterIntegration|Prometheus is an open-source monitoring system with %{gitlabIntegrationLink} to monitor deployed applications." msgstr "" +msgid "ClusterIntegration|RBAC-enabled cluster (experimental)" +msgstr "" + msgid "ClusterIntegration|Read our %{link_to_help_page} on Kubernetes cluster integration." msgstr "" @@ -1620,6 +1626,9 @@ msgstr "" msgid "ClusterIntegration|This account must have permissions to create a Kubernetes cluster in the %{link_to_container_project} specified below" msgstr "" +msgid "ClusterIntegration|This option will allow you to install applications on RBAC clusters." +msgstr "" + msgid "ClusterIntegration|Toggle Kubernetes Cluster" msgstr "" diff --git a/spec/controllers/projects/clusters_controller_spec.rb b/spec/controllers/projects/clusters_controller_spec.rb index 42917d0d505..26a532ee01d 100644 --- a/spec/controllers/projects/clusters_controller_spec.rb +++ b/spec/controllers/projects/clusters_controller_spec.rb @@ -274,13 +274,45 @@ describe Projects::ClustersController do context 'when creates a cluster' do it 'creates a new cluster' do expect(ClusterProvisionWorker).to receive(:perform_async) + expect { go }.to change { Clusters::Cluster.count } .and change { Clusters::Platforms::Kubernetes.count } + expect(response).to redirect_to(project_cluster_path(project, project.clusters.first)) + expect(project.clusters.first).to be_user expect(project.clusters.first).to be_kubernetes end end + + context 'when creates a RBAC-enabled cluster' do + let(:params) do + { + cluster: { + name: 'new-cluster', + platform_kubernetes_attributes: { + api_url: 'http://my-url', + token: 'test', + namespace: 'aaa', + authorization_type: 'rbac' + } + } + } + end + + it 'creates a new cluster' do + expect(ClusterProvisionWorker).to receive(:perform_async) + + expect { go }.to change { Clusters::Cluster.count } + .and change { Clusters::Platforms::Kubernetes.count } + + expect(response).to redirect_to(project_cluster_path(project, project.clusters.first)) + + expect(project.clusters.first).to be_user + expect(project.clusters.first).to be_kubernetes + expect(project.clusters.first).to be_platform_kubernetes_rbac + end + end end describe 'security' do diff --git a/spec/factories/clusters/platforms/kubernetes.rb b/spec/factories/clusters/platforms/kubernetes.rb index 89f6ddebf6a..36ac2372204 100644 --- a/spec/factories/clusters/platforms/kubernetes.rb +++ b/spec/factories/clusters/platforms/kubernetes.rb @@ -16,5 +16,9 @@ FactoryBot.define do platform_kubernetes.ca_cert = File.read(pem_file) end end + + trait :rbac_enabled do + authorization_type :rbac + end end end diff --git a/spec/features/projects/clusters/user_spec.rb b/spec/features/projects/clusters/user_spec.rb index babf47cc341..ec968bfcf7d 100644 --- a/spec/features/projects/clusters/user_spec.rb +++ b/spec/features/projects/clusters/user_spec.rb @@ -38,6 +38,28 @@ describe 'User Cluster', :js do end end + context 'rbac_clusters feature flag is enabled' do + before do + stub_feature_flags(rbac_clusters: true) + + fill_in 'cluster_name', with: 'dev-cluster' + fill_in 'cluster_platform_kubernetes_attributes_api_url', with: 'http://example.com' + fill_in 'cluster_platform_kubernetes_attributes_token', with: 'my-token' + check 'cluster_platform_kubernetes_attributes_authorization_type' + click_button 'Add Kubernetes cluster' + end + + it 'user sees a cluster details page' do + expect(page).to have_content('Kubernetes cluster integration') + expect(page.find_field('cluster[name]').value).to eq('dev-cluster') + expect(page.find_field('cluster[platform_kubernetes_attributes][api_url]').value) + .to have_content('http://example.com') + expect(page.find_field('cluster[platform_kubernetes_attributes][token]').value) + .to have_content('my-token') + expect(page.find_field('cluster[platform_kubernetes_attributes][authorization_type]', disabled: true)).to be_checked + end + end + context 'when user filled form with invalid parameters' do before do click_button 'Add Kubernetes cluster' diff --git a/spec/lib/gitlab/kubernetes/cluster_role_binding_spec.rb b/spec/lib/gitlab/kubernetes/cluster_role_binding_spec.rb new file mode 100644 index 00000000000..4a669408025 --- /dev/null +++ b/spec/lib/gitlab/kubernetes/cluster_role_binding_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Kubernetes::ClusterRoleBinding do + let(:cluster_role_binding) { described_class.new(name, cluster_role_name, subjects) } + let(:name) { 'cluster-role-binding-name' } + let(:cluster_role_name) { 'cluster-admin' } + + let(:subjects) { [{ kind: 'ServiceAccount', name: 'sa', namespace: 'ns' }] } + + describe '#generate' do + let(:role_ref) do + { + apiGroup: 'rbac.authorization.k8s.io', + kind: 'ClusterRole', + name: cluster_role_name + } + end + + let(:resource) do + ::Kubeclient::Resource.new( + metadata: { name: name }, + roleRef: role_ref, + subjects: subjects + ) + end + + subject { cluster_role_binding.generate } + + it 'should build a Kubeclient Resource' do + is_expected.to eq(resource) + end + end +end diff --git a/spec/lib/gitlab/kubernetes/helm/api_spec.rb b/spec/lib/gitlab/kubernetes/helm/api_spec.rb index 341f71a3e49..25c3b37753d 100644 --- a/spec/lib/gitlab/kubernetes/helm/api_spec.rb +++ b/spec/lib/gitlab/kubernetes/helm/api_spec.rb @@ -5,9 +5,18 @@ describe Gitlab::Kubernetes::Helm::Api do let(:helm) { described_class.new(client) } let(:gitlab_namespace) { Gitlab::Kubernetes::Helm::NAMESPACE } let(:namespace) { Gitlab::Kubernetes::Namespace.new(gitlab_namespace, client) } - let(:application) { create(:clusters_applications_prometheus) } + let(:application_name) { 'app-name' } + let(:rbac) { false } + let(:files) { {} } - let(:command) { application.install_command } + let(:command) do + Gitlab::Kubernetes::Helm::InstallCommand.new( + name: application_name, + chart: 'chart-name', + rbac: rbac, + files: files + ) + end subject { helm } @@ -28,6 +37,8 @@ describe Gitlab::Kubernetes::Helm::Api do before do allow(client).to receive(:create_pod).and_return(nil) allow(client).to receive(:create_config_map).and_return(nil) + allow(client).to receive(:create_service_account).and_return(nil) + allow(client).to receive(:create_cluster_role_binding).and_return(nil) allow(namespace).to receive(:ensure_exists!).once end @@ -39,7 +50,7 @@ describe Gitlab::Kubernetes::Helm::Api do end context 'with a ConfigMap' do - let(:resource) { Gitlab::Kubernetes::ConfigMap.new(application.name, application.files).generate } + let(:resource) { Gitlab::Kubernetes::ConfigMap.new(application_name, files).generate } it 'creates a ConfigMap on kubeclient' do expect(client).to receive(:create_config_map).with(resource).once @@ -47,6 +58,96 @@ describe Gitlab::Kubernetes::Helm::Api do subject.install(command) end end + + context 'without a service account' do + it 'does not create a service account on kubeclient' do + expect(client).not_to receive(:create_service_account) + expect(client).not_to receive(:create_cluster_role_binding) + + subject.install(command) + end + end + + context 'with a service account' do + let(:command) { Gitlab::Kubernetes::Helm::InitCommand.new(name: application_name, files: files, rbac: rbac) } + + context 'rbac-enabled cluster' do + let(:rbac) { true } + + let(:service_account_resource) do + Kubeclient::Resource.new(metadata: { name: 'tiller', namespace: 'gitlab-managed-apps' }) + end + + let(:cluster_role_binding_resource) do + Kubeclient::Resource.new( + metadata: { name: 'tiller-admin' }, + roleRef: { apiGroup: 'rbac.authorization.k8s.io', kind: 'ClusterRole', name: 'cluster-admin' }, + subjects: [{ kind: 'ServiceAccount', name: 'tiller', namespace: 'gitlab-managed-apps' }] + ) + end + + context 'service account and cluster role binding does not exist' do + before do + expect(client).to receive('get_service_account').with('tiller', 'gitlab-managed-apps').and_raise(Kubeclient::HttpError.new(404, 'Not found', nil)) + expect(client).to receive('get_cluster_role_binding').with('tiller-admin').and_raise(Kubeclient::HttpError.new(404, 'Not found', nil)) + end + + it 'creates a service account, followed the cluster role binding on kubeclient' do + expect(client).to receive(:create_service_account).with(service_account_resource).once.ordered + expect(client).to receive(:create_cluster_role_binding).with(cluster_role_binding_resource).once.ordered + + subject.install(command) + end + end + + context 'service account already exists' do + before do + expect(client).to receive('get_service_account').with('tiller', 'gitlab-managed-apps').and_return(service_account_resource) + expect(client).to receive('get_cluster_role_binding').with('tiller-admin').and_raise(Kubeclient::HttpError.new(404, 'Not found', nil)) + end + + it 'updates the service account, followed by creating the cluster role binding' do + expect(client).to receive(:update_service_account).with(service_account_resource).once.ordered + expect(client).to receive(:create_cluster_role_binding).with(cluster_role_binding_resource).once.ordered + + subject.install(command) + end + end + + context 'service account and cluster role binding already exists' do + before do + expect(client).to receive('get_service_account').with('tiller', 'gitlab-managed-apps').and_return(service_account_resource) + expect(client).to receive('get_cluster_role_binding').with('tiller-admin').and_return(cluster_role_binding_resource) + end + + it 'updates the service account, followed by creating the cluster role binding' do + expect(client).to receive(:update_service_account).with(service_account_resource).once.ordered + expect(client).to receive(:update_cluster_role_binding).with(cluster_role_binding_resource).once.ordered + + subject.install(command) + end + end + + context 'a non-404 error is thrown' do + before do + expect(client).to receive('get_service_account').with('tiller', 'gitlab-managed-apps').and_raise(Kubeclient::HttpError.new(401, 'Unauthorized', nil)) + end + + it 'raises an error' do + expect { subject.install(command) }.to raise_error(Kubeclient::HttpError) + end + end + end + + context 'legacy abac cluster' do + it 'does not create a service account on kubeclient' do + expect(client).not_to receive(:create_service_account) + expect(client).not_to receive(:create_cluster_role_binding) + + subject.install(command) + end + end + end end describe '#status' do diff --git a/spec/lib/gitlab/kubernetes/helm/base_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/base_command_spec.rb index d50616e95e8..aacae78be43 100644 --- a/spec/lib/gitlab/kubernetes/helm/base_command_spec.rb +++ b/spec/lib/gitlab/kubernetes/helm/base_command_spec.rb @@ -2,14 +2,24 @@ require 'spec_helper' describe Gitlab::Kubernetes::Helm::BaseCommand do let(:application) { create(:clusters_applications_helm) } + let(:rbac) { false } + let(:test_class) do Class.new do include Gitlab::Kubernetes::Helm::BaseCommand + def initialize(rbac) + @rbac = rbac + end + def name "test-class-name" end + def rbac? + @rbac + end + def files { some: 'value' @@ -19,7 +29,7 @@ describe Gitlab::Kubernetes::Helm::BaseCommand do end let(:base_command) do - test_class.new + test_class.new(rbac) end subject { base_command } @@ -34,6 +44,14 @@ describe Gitlab::Kubernetes::Helm::BaseCommand do it 'should returns a kubeclient resoure with pod content for application' do is_expected.to be_an_instance_of ::Kubeclient::Resource end + + context 'when rbac is true' do + let(:rbac) { true } + + it 'also returns a kubeclient resource' do + is_expected.to be_an_instance_of ::Kubeclient::Resource + end + end end describe '#pod_name' do diff --git a/spec/lib/gitlab/kubernetes/helm/init_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/init_command_spec.rb index dcbc046cf00..72dc1817936 100644 --- a/spec/lib/gitlab/kubernetes/helm/init_command_spec.rb +++ b/spec/lib/gitlab/kubernetes/helm/init_command_spec.rb @@ -2,9 +2,135 @@ require 'spec_helper' describe Gitlab::Kubernetes::Helm::InitCommand do let(:application) { create(:clusters_applications_helm) } - let(:commands) { 'helm init --tiller-tls --tiller-tls-verify --tls-ca-cert /data/helm/helm/config/ca.pem --tiller-tls-cert /data/helm/helm/config/cert.pem --tiller-tls-key /data/helm/helm/config/key.pem >/dev/null' } + let(:rbac) { false } + let(:files) { {} } + let(:init_command) { described_class.new(name: application.name, files: files, rbac: rbac) } - subject { described_class.new(name: application.name, files: {}) } + let(:commands) do + <<~EOS + helm init --tiller-tls --tiller-tls-verify --tls-ca-cert /data/helm/helm/config/ca.pem --tiller-tls-cert /data/helm/helm/config/cert.pem --tiller-tls-key /data/helm/helm/config/key.pem >/dev/null + EOS + end + + subject { init_command } it_behaves_like 'helm commands' + + context 'on a rbac-enabled cluster' do + let(:rbac) { true } + + it_behaves_like 'helm commands' do + let(:commands) do + <<~EOS + helm init --tiller-tls --tiller-tls-verify --tls-ca-cert /data/helm/helm/config/ca.pem --tiller-tls-cert /data/helm/helm/config/cert.pem --tiller-tls-key /data/helm/helm/config/key.pem --service-account tiller >/dev/null + EOS + end + end + end + + describe '#rbac?' do + subject { init_command.rbac? } + + context 'rbac is enabled' do + let(:rbac) { true } + + it { is_expected.to be_truthy } + end + + context 'rbac is not enabled' do + let(:rbac) { false } + + it { is_expected.to be_falsey } + end + end + + describe '#config_map_resource' do + let(:metadata) do + { + name: 'values-content-configuration-helm', + namespace: 'gitlab-managed-apps', + labels: { name: 'values-content-configuration-helm' } + } + end + + let(:resource) { ::Kubeclient::Resource.new(metadata: metadata, data: files) } + + subject { init_command.config_map_resource } + + it 'returns a KubeClient resource with config map content for the application' do + is_expected.to eq(resource) + end + end + + describe '#pod_resource' do + subject { init_command.pod_resource } + + context 'rbac is enabled' do + let(:rbac) { true } + + it 'generates a pod that uses the tiller serviceAccountName' do + expect(subject.spec.serviceAccountName).to eq('tiller') + end + end + + context 'rbac is not enabled' do + let(:rbac) { false } + + it 'generates a pod that uses the default serviceAccountName' do + expect(subject.spec.serviceAcccountName).to be_nil + end + end + end + + describe '#service_account_resource' do + let(:resource) do + Kubeclient::Resource.new(metadata: { name: 'tiller', namespace: 'gitlab-managed-apps' }) + end + + subject { init_command.service_account_resource } + + context 'rbac is enabled' do + let(:rbac) { true } + + it 'generates a Kubeclient resource for the tiller ServiceAccount' do + is_expected.to eq(resource) + end + end + + context 'rbac is not enabled' do + let(:rbac) { false } + + it 'generates nothing' do + is_expected.to be_nil + end + end + end + + describe '#cluster_role_binding_resource' do + let(:resource) do + Kubeclient::Resource.new( + metadata: { name: 'tiller-admin' }, + roleRef: { apiGroup: 'rbac.authorization.k8s.io', kind: 'ClusterRole', name: 'cluster-admin' }, + subjects: [{ kind: 'ServiceAccount', name: 'tiller', namespace: 'gitlab-managed-apps' }] + ) + end + + subject { init_command.cluster_role_binding_resource } + + context 'rbac is enabled' do + let(:rbac) { true } + + it 'generates a Kubeclient resource for the ClusterRoleBinding for tiller' do + is_expected.to eq(resource) + end + end + + context 'rbac is not enabled' do + let(:rbac) { false } + + it 'generates nothing' do + is_expected.to be_nil + end + end + end end diff --git a/spec/lib/gitlab/kubernetes/helm/install_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/install_command_spec.rb index 982e2f41043..f28941ce58f 100644 --- a/spec/lib/gitlab/kubernetes/helm/install_command_spec.rb +++ b/spec/lib/gitlab/kubernetes/helm/install_command_spec.rb @@ -3,14 +3,17 @@ require 'rails_helper' describe Gitlab::Kubernetes::Helm::InstallCommand do let(:files) { { 'ca.pem': 'some file content' } } let(:repository) { 'https://repository.example.com' } + let(:rbac) { false } let(:version) { '1.2.3' } let(:install_command) do described_class.new( name: 'app-name', chart: 'chart-name', + rbac: rbac, files: files, - version: version, repository: repository + version: version, + repository: repository ) end @@ -21,9 +24,52 @@ describe Gitlab::Kubernetes::Helm::InstallCommand do <<~EOS helm init --client-only >/dev/null helm repo add app-name https://repository.example.com - helm install chart-name --name app-name --tls --tls-ca-cert /data/helm/app-name/config/ca.pem --tls-cert /data/helm/app-name/config/cert.pem --tls-key /data/helm/app-name/config/key.pem --version 1.2.3 --namespace gitlab-managed-apps -f /data/helm/app-name/config/values.yaml >/dev/null + #{helm_install_comand} EOS end + + let(:helm_install_comand) do + <<~EOS.squish + helm install chart-name + --name app-name + --tls + --tls-ca-cert /data/helm/app-name/config/ca.pem + --tls-cert /data/helm/app-name/config/cert.pem + --tls-key /data/helm/app-name/config/key.pem + --version 1.2.3 + --namespace gitlab-managed-apps + -f /data/helm/app-name/config/values.yaml >/dev/null + EOS + end + end + + context 'when rbac is true' do + let(:rbac) { true } + + it_behaves_like 'helm commands' do + let(:commands) do + <<~EOS + helm init --client-only >/dev/null + helm repo add app-name https://repository.example.com + #{helm_install_command} + EOS + end + + let(:helm_install_command) do + <<~EOS.squish + helm install chart-name + --name app-name + --tls + --tls-ca-cert /data/helm/app-name/config/ca.pem + --tls-cert /data/helm/app-name/config/cert.pem + --tls-key /data/helm/app-name/config/key.pem + --version 1.2.3 + --set rbac.create\\=true,rbac.enabled\\=true + --namespace gitlab-managed-apps + -f /data/helm/app-name/config/values.yaml >/dev/null + EOS + end + end end context 'when there is no repository' do @@ -32,8 +78,22 @@ describe Gitlab::Kubernetes::Helm::InstallCommand do it_behaves_like 'helm commands' do let(:commands) do <<~EOS - helm init --client-only >/dev/null - helm install chart-name --name app-name --tls --tls-ca-cert /data/helm/app-name/config/ca.pem --tls-cert /data/helm/app-name/config/cert.pem --tls-key /data/helm/app-name/config/key.pem --version 1.2.3 --namespace gitlab-managed-apps -f /data/helm/app-name/config/values.yaml >/dev/null + helm init --client-only >/dev/null + #{helm_install_command} + EOS + end + + let(:helm_install_command) do + <<~EOS.squish + helm install chart-name + --name app-name + --tls + --tls-ca-cert /data/helm/app-name/config/ca.pem + --tls-cert /data/helm/app-name/config/cert.pem + --tls-key /data/helm/app-name/config/key.pem + --version 1.2.3 + --namespace gitlab-managed-apps + -f /data/helm/app-name/config/values.yaml >/dev/null EOS end end @@ -45,9 +105,19 @@ describe Gitlab::Kubernetes::Helm::InstallCommand do it_behaves_like 'helm commands' do let(:commands) do <<~EOS - helm init --client-only >/dev/null - helm repo add app-name https://repository.example.com - helm install chart-name --name app-name --version 1.2.3 --namespace gitlab-managed-apps -f /data/helm/app-name/config/values.yaml >/dev/null + helm init --client-only >/dev/null + helm repo add app-name https://repository.example.com + #{helm_install_command} + EOS + end + + let(:helm_install_command) do + <<~EOS.squish + helm install chart-name + --name app-name + --version 1.2.3 + --namespace gitlab-managed-apps + -f /data/helm/app-name/config/values.yaml >/dev/null EOS end end @@ -59,11 +129,60 @@ describe Gitlab::Kubernetes::Helm::InstallCommand do it_behaves_like 'helm commands' do let(:commands) do <<~EOS - helm init --client-only >/dev/null - helm repo add app-name https://repository.example.com - helm install chart-name --name app-name --tls --tls-ca-cert /data/helm/app-name/config/ca.pem --tls-cert /data/helm/app-name/config/cert.pem --tls-key /data/helm/app-name/config/key.pem --namespace gitlab-managed-apps -f /data/helm/app-name/config/values.yaml >/dev/null + helm init --client-only >/dev/null + helm repo add app-name https://repository.example.com + #{helm_install_command} EOS end + + let(:helm_install_command) do + <<~EOS.squish + helm install chart-name + --name app-name + --tls + --tls-ca-cert /data/helm/app-name/config/ca.pem + --tls-cert /data/helm/app-name/config/cert.pem + --tls-key /data/helm/app-name/config/key.pem + --namespace gitlab-managed-apps + -f /data/helm/app-name/config/values.yaml >/dev/null + EOS + end + end + end + + describe '#rbac?' do + subject { install_command.rbac? } + + context 'rbac is enabled' do + let(:rbac) { true } + + it { is_expected.to be_truthy } + end + + context 'rbac is not enabled' do + let(:rbac) { false } + + it { is_expected.to be_falsey } + end + end + + describe '#pod_resource' do + subject { install_command.pod_resource } + + context 'rbac is enabled' do + let(:rbac) { true } + + it 'generates a pod that uses the tiller serviceAccountName' do + expect(subject.spec.serviceAccountName).to eq('tiller') + end + end + + context 'rbac is not enabled' do + let(:rbac) { false } + + it 'generates a pod that uses the default serviceAccountName' do + expect(subject.spec.serviceAcccountName).to be_nil + end end end @@ -84,4 +203,20 @@ describe Gitlab::Kubernetes::Helm::InstallCommand do is_expected.to eq(resource) end end + + describe '#service_account_resource' do + subject { install_command.service_account_resource } + + it 'returns nothing' do + is_expected.to be_nil + end + end + + describe '#cluster_role_binding_resource' do + subject { install_command.cluster_role_binding_resource } + + it 'returns nothing' do + is_expected.to be_nil + end + end end diff --git a/spec/lib/gitlab/kubernetes/helm/pod_spec.rb b/spec/lib/gitlab/kubernetes/helm/pod_spec.rb index ec64193c0b2..b333b334f36 100644 --- a/spec/lib/gitlab/kubernetes/helm/pod_spec.rb +++ b/spec/lib/gitlab/kubernetes/helm/pod_spec.rb @@ -5,8 +5,9 @@ describe Gitlab::Kubernetes::Helm::Pod do let(:app) { create(:clusters_applications_prometheus) } let(:command) { app.install_command } let(:namespace) { Gitlab::Kubernetes::Helm::NAMESPACE } + let(:service_account_name) { nil } - subject { described_class.new(command, namespace) } + subject { described_class.new(command, namespace, service_account_name: service_account_name) } context 'with a command' do it 'should generate a Kubeclient::Resource' do @@ -58,6 +59,20 @@ describe Gitlab::Kubernetes::Helm::Pod do expect(volume.configMap['items'].first['key']).to eq(:'values.yaml') expect(volume.configMap['items'].first['path']).to eq(:'values.yaml') end + + it 'should have no serviceAccountName' do + spec = subject.generate.spec + expect(spec.serviceAccountName).to be_nil + end + + context 'with a service_account_name' do + let(:service_account_name) { 'sa' } + + it 'should use the serviceAccountName provided' do + spec = subject.generate.spec + expect(spec.serviceAccountName).to eq(service_account_name) + end + end end end end diff --git a/spec/lib/gitlab/kubernetes/kube_client_spec.rb b/spec/lib/gitlab/kubernetes/kube_client_spec.rb new file mode 100644 index 00000000000..9146729d139 --- /dev/null +++ b/spec/lib/gitlab/kubernetes/kube_client_spec.rb @@ -0,0 +1,247 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Kubernetes::KubeClient do + include KubernetesHelpers + + let(:api_url) { 'https://kubernetes.example.com/prefix' } + let(:api_groups) { ['api', 'apis/rbac.authorization.k8s.io'] } + let(:api_version) { 'v1' } + let(:kubeclient_options) { { auth_options: { bearer_token: 'xyz' } } } + + let(:client) { described_class.new(api_url, api_groups, api_version, kubeclient_options) } + + before do + stub_kubeclient_discover(api_url) + end + + describe '#hashed_clients' do + subject { client.hashed_clients } + + it 'has keys from api groups' do + expect(subject.keys).to match_array api_groups + end + + it 'has values of Kubeclient::Client' do + expect(subject.values).to all(be_an_instance_of Kubeclient::Client) + end + end + + describe '#clients' do + subject { client.clients } + + it 'is not empty' do + is_expected.to be_present + end + + it 'is an array of Kubeclient::Client objects' do + is_expected.to all(be_an_instance_of Kubeclient::Client) + end + + it 'has each API group url' do + expected_urls = api_groups.map { |group| "#{api_url}/#{group}" } + + expect(subject.map(&:api_endpoint).map(&:to_s)).to match_array(expected_urls) + end + + it 'has the kubeclient options' do + subject.each do |client| + expect(client.auth_options).to eq({ bearer_token: 'xyz' }) + end + end + + it 'has the api_version' do + subject.each do |client| + expect(client.instance_variable_get(:@api_version)).to eq('v1') + end + end + end + + describe '#core_client' do + subject { client.core_client } + + it 'is a Kubeclient::Client' do + is_expected.to be_an_instance_of Kubeclient::Client + end + + it 'has the core API endpoint' do + expect(subject.api_endpoint.to_s).to match(%r{\/api\Z}) + end + end + + describe '#rbac_client' do + subject { client.rbac_client } + + it 'is a Kubeclient::Client' do + is_expected.to be_an_instance_of Kubeclient::Client + end + + it 'has the RBAC API group endpoint' do + expect(subject.api_endpoint.to_s).to match(%r{\/apis\/rbac.authorization.k8s.io\Z}) + end + end + + describe '#extensions_client' do + subject { client.extensions_client } + + let(:api_groups) { ['apis/extensions'] } + + it 'is a Kubeclient::Client' do + is_expected.to be_an_instance_of Kubeclient::Client + end + + it 'has the extensions API group endpoint' do + expect(subject.api_endpoint.to_s).to match(%r{\/apis\/extensions\Z}) + end + end + + describe '#discover!' do + it 'makes a discovery request for each API group' do + client.discover! + + api_groups.each do |api_group| + discovery_url = api_url + '/' + api_group + '/v1' + expect(WebMock).to have_requested(:get, discovery_url).once + end + end + end + + describe 'core API' do + let(:core_client) { client.core_client } + + [ + :get_pods, + :get_secrets, + :get_config_map, + :get_pod, + :get_namespace, + :get_service, + :get_service_account, + :delete_pod, + :create_config_map, + :create_namespace, + :create_pod, + :create_service_account, + :update_config_map, + :update_service_account + ].each do |method| + describe "##{method}" do + it 'delegates to the core client' do + expect(client).to delegate_method(method).to(:core_client) + end + + it 'responds to the method' do + expect(client).to respond_to method + end + end + end + end + + describe 'rbac API group' do + let(:rbac_client) { client.rbac_client } + + [ + :create_cluster_role_binding, + :get_cluster_role_binding, + :update_cluster_role_binding + ].each do |method| + describe "##{method}" do + it 'delegates to the rbac client' do + expect(client).to delegate_method(method).to(:rbac_client) + end + + it 'responds to the method' do + expect(client).to respond_to method + end + + context 'no rbac client' do + let(:api_groups) { ['api'] } + + it 'throws an error' do + expect { client.public_send(method) }.to raise_error(Module::DelegationError) + end + end + end + end + end + + describe 'extensions API group' do + let(:api_groups) { ['apis/extensions'] } + let(:api_version) { 'v1beta1' } + let(:extensions_client) { client.extensions_client } + + describe '#get_deployments' do + it 'delegates to the extensions client' do + expect(client).to delegate_method(:get_deployments).to(:extensions_client) + end + + it 'responds to the method' do + expect(client).to respond_to :get_deployments + end + + context 'no extensions client' do + let(:api_groups) { ['api'] } + let(:api_version) { 'v1' } + + it 'throws an error' do + expect { client.get_deployments }.to raise_error(Module::DelegationError) + end + end + end + end + + describe 'non-entity methods' do + it 'does not proxy for non-entity methods' do + expect(client.clients.first).to respond_to :proxy_url + + expect(client).not_to respond_to :proxy_url + end + + it 'throws an error' do + expect { client.proxy_url }.to raise_error(NoMethodError) + end + end + + describe '#get_pod_log' do + let(:core_client) { client.core_client } + + it 'is delegated to the core client' do + expect(client).to delegate_method(:get_pod_log).to(:core_client) + end + + context 'when no core client' do + let(:api_groups) { ['apis/extensions'] } + + it 'throws an error' do + expect { client.get_pod_log('pod-name') }.to raise_error(Module::DelegationError) + end + end + end + + describe '#watch_pod_log' do + let(:core_client) { client.core_client } + + it 'is delegated to the core client' do + expect(client).to delegate_method(:watch_pod_log).to(:core_client) + end + + context 'when no core client' do + let(:api_groups) { ['apis/extensions'] } + + it 'throws an error' do + expect { client.watch_pod_log('pod-name') }.to raise_error(Module::DelegationError) + end + end + end + + describe 'methods that do not exist on any client' do + it 'throws an error' do + expect { client.non_existent_method }.to raise_error(NoMethodError) + end + + it 'returns false for respond_to' do + expect(client.respond_to?(:non_existent_method)).to be_falsey + end + end +end diff --git a/spec/lib/gitlab/kubernetes/service_account_spec.rb b/spec/lib/gitlab/kubernetes/service_account_spec.rb new file mode 100644 index 00000000000..8da9e932dc3 --- /dev/null +++ b/spec/lib/gitlab/kubernetes/service_account_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Kubernetes::ServiceAccount do + let(:name) { 'a_service_account' } + let(:namespace_name) { 'a_namespace' } + let(:service_account) { described_class.new(name, namespace_name) } + + it { expect(service_account.name).to eq(name) } + it { expect(service_account.namespace_name).to eq(namespace_name) } + + describe '#generate' do + let(:resource) do + ::Kubeclient::Resource.new(metadata: { name: name, namespace: namespace_name }) + end + + subject { service_account.generate } + + it 'should build a Kubeclient Resource' do + is_expected.to eq(resource) + end + end +end diff --git a/spec/models/clusters/applications/helm_spec.rb b/spec/models/clusters/applications/helm_spec.rb index e5b2bdc8a4e..2c37cd20ecc 100644 --- a/spec/models/clusters/applications/helm_spec.rb +++ b/spec/models/clusters/applications/helm_spec.rb @@ -47,5 +47,19 @@ describe Clusters::Applications::Helm do cert = OpenSSL::X509::Certificate.new(subject.files[:'cert.pem']) expect(cert.not_after).to be > 999.years.from_now end + + describe 'rbac' do + context 'non rbac cluster' do + it { expect(subject).not_to be_rbac } + end + + context 'rbac cluster' do + before do + helm.cluster.platform_kubernetes.rbac! + end + + it { expect(subject).to be_rbac } + end + end end end diff --git a/spec/models/clusters/applications/ingress_spec.rb b/spec/models/clusters/applications/ingress_spec.rb index 21f75ced8c3..c55953c8d22 100644 --- a/spec/models/clusters/applications/ingress_spec.rb +++ b/spec/models/clusters/applications/ingress_spec.rb @@ -88,9 +88,18 @@ describe Clusters::Applications::Ingress do expect(subject.name).to eq('ingress') expect(subject.chart).to eq('stable/nginx-ingress') expect(subject.version).to eq('0.23.0') + expect(subject).not_to be_rbac expect(subject.files).to eq(ingress.files) end + context 'on a rbac enabled cluster' do + before do + ingress.cluster.platform_kubernetes.rbac! + end + + it { is_expected.to be_rbac } + end + context 'application failed to install previously' do let(:ingress) { create(:clusters_applications_ingress, :errored, version: 'nginx') } diff --git a/spec/models/clusters/applications/jupyter_spec.rb b/spec/models/clusters/applications/jupyter_spec.rb index 027b732681b..591a01d78a9 100644 --- a/spec/models/clusters/applications/jupyter_spec.rb +++ b/spec/models/clusters/applications/jupyter_spec.rb @@ -51,10 +51,19 @@ describe Clusters::Applications::Jupyter do expect(subject.name).to eq('jupyter') expect(subject.chart).to eq('jupyter/jupyterhub') expect(subject.version).to eq('v0.6') + expect(subject).not_to be_rbac expect(subject.repository).to eq('https://jupyterhub.github.io/helm-chart/') expect(subject.files).to eq(jupyter.files) end + context 'on a rbac enabled cluster' do + before do + jupyter.cluster.platform_kubernetes.rbac! + end + + it { is_expected.to be_rbac } + end + context 'application failed to install previously' do let(:jupyter) { create(:clusters_applications_jupyter, :errored, version: '0.0.1') } diff --git a/spec/models/clusters/applications/prometheus_spec.rb b/spec/models/clusters/applications/prometheus_spec.rb index 26b75c75e1d..f34b4ece8db 100644 --- a/spec/models/clusters/applications/prometheus_spec.rb +++ b/spec/models/clusters/applications/prometheus_spec.rb @@ -1,6 +1,8 @@ require 'rails_helper' describe Clusters::Applications::Prometheus do + include KubernetesHelpers + include_examples 'cluster application core specs', :clusters_applications_prometheus include_examples 'cluster application status specs', :cluster_application_prometheus @@ -107,26 +109,14 @@ describe Clusters::Applications::Prometheus do end context 'cluster has kubeclient' do - let(:kubernetes_url) { 'http://example.com' } - let(:k8s_discover_response) do - { - resources: [ - { - name: 'service', - kind: 'Service' - } - ] - } - end + let(:kubernetes_url) { subject.cluster.platform_kubernetes.api_url } + let(:kube_client) { subject.cluster.kubeclient.core_client } - let(:kube_client) { Kubeclient::Client.new(kubernetes_url) } - - let(:cluster) { create(:cluster) } - subject { create(:clusters_applications_prometheus, cluster: cluster) } + subject { create(:clusters_applications_prometheus) } before do - allow(kube_client.rest_client).to receive(:get).and_return(k8s_discover_response.to_json) - allow(subject.cluster).to receive(:kubeclient).and_return(kube_client) + subject.cluster.platform_kubernetes.namespace = 'a-namespace' + stub_kubeclient_discover(subject.cluster.platform_kubernetes.api_url) end it 'creates proxy prometheus rest client' do @@ -134,7 +124,7 @@ describe Clusters::Applications::Prometheus do end it 'creates proper url' do - expect(subject.prometheus_client.url).to eq('http://example.com/api/v1/namespaces/gitlab-managed-apps/service/prometheus-prometheus-server:80/proxy') + expect(subject.prometheus_client.url).to eq("#{kubernetes_url}/api/v1/namespaces/gitlab-managed-apps/services/prometheus-prometheus-server:80/proxy") end it 'copies options and headers from kube client to proxy client' do @@ -164,9 +154,18 @@ describe Clusters::Applications::Prometheus do expect(subject.name).to eq('prometheus') expect(subject.chart).to eq('stable/prometheus') expect(subject.version).to eq('6.7.3') + expect(subject).not_to be_rbac expect(subject.files).to eq(prometheus.files) end + context 'on a rbac enabled cluster' do + before do + prometheus.cluster.platform_kubernetes.rbac! + end + + it { is_expected.to be_rbac } + end + context 'application failed to install previously' do let(:prometheus) { create(:clusters_applications_prometheus, :errored, version: '2.0.0') } diff --git a/spec/models/clusters/applications/runner_spec.rb b/spec/models/clusters/applications/runner_spec.rb index d84f125e246..eda8d519f60 100644 --- a/spec/models/clusters/applications/runner_spec.rb +++ b/spec/models/clusters/applications/runner_spec.rb @@ -46,10 +46,19 @@ describe Clusters::Applications::Runner do expect(subject.name).to eq('runner') expect(subject.chart).to eq('runner/gitlab-runner') expect(subject.version).to eq('0.1.31') + expect(subject).not_to be_rbac expect(subject.repository).to eq('https://charts.gitlab.io') expect(subject.files).to eq(gitlab_runner.files) end + context 'on a rbac enabled cluster' do + before do + gitlab_runner.cluster.platform_kubernetes.rbac! + end + + it { is_expected.to be_rbac } + end + context 'application failed to install previously' do let(:gitlab_runner) { create(:clusters_applications_runner, :errored, runner: ci_runner, version: '0.1.13') } diff --git a/spec/models/clusters/cluster_spec.rb b/spec/models/clusters/cluster_spec.rb index 6f66515b45f..2727191eb9b 100644 --- a/spec/models/clusters/cluster_spec.rb +++ b/spec/models/clusters/cluster_spec.rb @@ -13,6 +13,10 @@ describe Clusters::Cluster do it { is_expected.to delegate_method(:status_reason).to(:provider) } it { is_expected.to delegate_method(:status_name).to(:provider) } it { is_expected.to delegate_method(:on_creation?).to(:provider) } + it { is_expected.to delegate_method(:active?).to(:platform_kubernetes).with_prefix } + it { is_expected.to delegate_method(:rbac?).to(:platform_kubernetes).with_prefix } + it { is_expected.to delegate_method(:installed?).to(:application_helm).with_prefix } + it { is_expected.to delegate_method(:installed?).to(:application_ingress).with_prefix } it { is_expected.to respond_to :project } describe '.enabled' do diff --git a/spec/models/clusters/platforms/kubernetes_spec.rb b/spec/models/clusters/platforms/kubernetes_spec.rb index ab7f89f9bf4..66198d5ee2b 100644 --- a/spec/models/clusters/platforms/kubernetes_spec.rb +++ b/spec/models/clusters/platforms/kubernetes_spec.rb @@ -92,6 +92,30 @@ describe Clusters::Platforms::Kubernetes, :use_clean_rails_memory_store_caching end end + describe '#kubeclient' do + subject { kubernetes.kubeclient } + + let(:kubernetes) { build(:cluster_platform_kubernetes, :configured, namespace: 'a-namespace') } + + it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::KubeClient) } + end + + describe '#rbac?' do + subject { kubernetes.rbac? } + + let(:kubernetes) { build(:cluster_platform_kubernetes, :configured) } + + context 'when authorization type is rbac' do + let(:kubernetes) { build(:cluster_platform_kubernetes, :rbac_enabled, :configured) } + + it { is_expected.to be_truthy } + end + + context 'when authorization type is nil' do + it { is_expected.to be_falsey } + end + end + describe '#actual_namespace' do subject { kubernetes.actual_namespace } diff --git a/spec/support/helpers/kubernetes_helpers.rb b/spec/support/helpers/kubernetes_helpers.rb index 683a64504a1..994a2aaef90 100644 --- a/spec/support/helpers/kubernetes_helpers.rb +++ b/spec/support/helpers/kubernetes_helpers.rb @@ -16,6 +16,7 @@ module KubernetesHelpers def stub_kubeclient_discover(api_url) WebMock.stub_request(:get, api_url + '/api/v1').to_return(kube_response(kube_v1_discovery_body)) WebMock.stub_request(:get, api_url + '/apis/extensions/v1beta1').to_return(kube_response(kube_v1beta1_discovery_body)) + WebMock.stub_request(:get, api_url + '/apis/rbac.authorization.k8s.io/v1').to_return(kube_response(kube_v1_rbac_authorization_discovery_body)) end def stub_kubeclient_pods(response = nil) @@ -66,7 +67,8 @@ module KubernetesHelpers "resources" => [ { "name" => "pods", "namespaced" => true, "kind" => "Pod" }, { "name" => "deployments", "namespaced" => true, "kind" => "Deployment" }, - { "name" => "secrets", "namespaced" => true, "kind" => "Secret" } + { "name" => "secrets", "namespaced" => true, "kind" => "Secret" }, + { "name" => "services", "namespaced" => true, "kind" => "Service" } ] } end @@ -77,7 +79,20 @@ module KubernetesHelpers "resources" => [ { "name" => "pods", "namespaced" => true, "kind" => "Pod" }, { "name" => "deployments", "namespaced" => true, "kind" => "Deployment" }, - { "name" => "secrets", "namespaced" => true, "kind" => "Secret" } + { "name" => "secrets", "namespaced" => true, "kind" => "Secret" }, + { "name" => "services", "namespaced" => true, "kind" => "Service" } + ] + } + end + + def kube_v1_rbac_authorization_discovery_body + { + "kind" => "APIResourceList", + "resources" => [ + { "name" => "clusterrolebindings", "namespaced" => false, "kind" => "ClusterRoleBinding" }, + { "name" => "clusterroles", "namespaced" => false, "kind" => "ClusterRole" }, + { "name" => "rolebindings", "namespaced" => true, "kind" => "RoleBinding" }, + { "name" => "roles", "namespaced" => true, "kind" => "Role" } ] } end