diff options
author | Shinya Maeda <shinya@gitlab.com> | 2018-05-24 17:03:10 +0900 |
---|---|---|
committer | Shinya Maeda <shinya@gitlab.com> | 2018-05-24 17:03:10 +0900 |
commit | bce62515b628c4c2e56511c1b0a0674150350baf (patch) | |
tree | 7fb39f45171bc08015f38b8eda56f587bc3b88a4 | |
parent | e8dd1a25a5a0a9564e03c86eab5cbd06162e3feb (diff) | |
download | gitlab-ce-bce62515b628c4c2e56511c1b0a0674150350baf.tar.gz |
Revert removing kubernetes service logic
-rw-r--r-- | app/models/project_services/kubernetes_service.rb | 136 | ||||
-rw-r--r-- | spec/models/project_services/kubernetes_service_spec.rb | 226 |
2 files changed, 362 insertions, 0 deletions
diff --git a/app/models/project_services/kubernetes_service.rb b/app/models/project_services/kubernetes_service.rb index 8c94f54d701..20fed432e55 100644 --- a/app/models/project_services/kubernetes_service.rb +++ b/app/models/project_services/kubernetes_service.rb @@ -1,4 +1,14 @@ +## +# NOTE: +# We'll move this class to Clusters::Platforms::Kubernetes, which contains exactly the same logic. +# After we've migrated data, we'll remove KubernetesService. This would happen in a few months. +# If you're modyfiyng this class, please note that you should update the same change in Clusters::Platforms::Kubernetes. class KubernetesService < DeploymentService + include Gitlab::Kubernetes + include ReactiveCaching + + self.reactive_cache_key = ->(service) { [service.class.model_name.singular, service.project_id] } + # Namespace defaults to the project path, but can be overridden in case that # is an invalid or inappropriate name prop_accessor :namespace @@ -30,6 +40,8 @@ class KubernetesService < DeploymentService message: Gitlab::Regex.kubernetes_namespace_regex_message } + after_save :clear_reactive_cache! + def initialize_properties self.properties = {} if properties.nil? end @@ -72,6 +84,67 @@ class KubernetesService < DeploymentService ] end + def actual_namespace + if namespace.present? + namespace + else + default_namespace + end + end + + # Check we can connect to the Kubernetes API + def test(*args) + kubeclient = build_kubeclient! + + kubeclient.discover + { success: kubeclient.discovered, result: "Checked API discovery endpoint" } + rescue => err + { success: false, result: err } + end + + def predefined_variables + config = YAML.dump(kubeconfig) + + Gitlab::Ci::Variables::Collection.new.tap do |variables| + variables + .append(key: 'KUBE_URL', value: api_url) + .append(key: 'KUBE_TOKEN', value: token, public: false) + .append(key: 'KUBE_NAMESPACE', value: actual_namespace) + .append(key: 'KUBECONFIG', value: config, public: false, file: true) + + if ca_pem.present? + variables + .append(key: 'KUBE_CA_PEM', value: ca_pem) + .append(key: 'KUBE_CA_PEM_FILE', value: ca_pem, file: true) + end + end + end + + # Constructs a list of terminals from the reactive cache + # + # Returns nil if the cache is empty, in which case you should try again a + # short time later + def terminals(environment) + with_reactive_cache do |data| + pods = filter_by_label(data[:pods], app: environment.slug) + terminals = pods.flat_map { |pod| terminals_for_pod(api_url, actual_namespace, pod) } + terminals.each { |terminal| add_terminal_auth(terminal, terminal_auth) } + end + end + + # Caches resources in the namespace so other calls don't need to block on + # network access + def calculate_reactive_cache + return unless active? && project && !project.pending_delete? + + # We may want to cache extra things in the future + { pods: read_pods } + end + + def kubeclient + @kubeclient ||= build_kubeclient! + end + def deprecated? !active end @@ -88,6 +161,14 @@ class KubernetesService < DeploymentService private + def kubeconfig + to_kubeconfig( + url: api_url, + namespace: actual_namespace, + token: token, + ca_pem: ca_pem) + end + def namespace_placeholder default_namespace || TEMPLATE_PLACEHOLDER end @@ -99,6 +180,61 @@ class KubernetesService < DeploymentService slug.gsub(/[^-a-z0-9]/, '-').gsub(/^-+/, '') end + def build_kubeclient!(api_path: 'api', api_version: 'v1') + raise "Incomplete settings" unless api_url && actual_namespace && token + + ::Kubeclient::Client.new( + join_api_url(api_path), + api_version, + auth_options: kubeclient_auth_options, + ssl_options: kubeclient_ssl_options, + http_proxy_uri: ENV['http_proxy'] + ) + end + + # Returns a hash of all pods in the namespace + def read_pods + kubeclient = build_kubeclient! + + kubeclient.get_pods(namespace: actual_namespace).as_json + rescue Kubeclient::HttpError => err + raise err unless err.error_code == 404 + + [] + 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 + + def kubeclient_auth_options + { 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, + ca_pem: ca_pem, + max_session_time: Gitlab::CurrentSettings.terminal_max_session_time + } + end + def enforce_namespace_to_lower_case self.namespace = self.namespace&.downcase end diff --git a/spec/models/project_services/kubernetes_service_spec.rb b/spec/models/project_services/kubernetes_service_spec.rb index b9c16178f9a..3be023a48c1 100644 --- a/spec/models/project_services/kubernetes_service_spec.rb +++ b/spec/models/project_services/kubernetes_service_spec.rb @@ -1,6 +1,9 @@ require 'spec_helper' describe KubernetesService, :use_clean_rails_memory_store_caching do + include KubernetesHelpers + include ReactiveCachingHelpers + let(:project) { create(:kubernetes_project) } let(:service) { project.deployment_platform } @@ -156,6 +159,229 @@ describe KubernetesService, :use_clean_rails_memory_store_caching do end end + describe '#actual_namespace' do + subject { service.actual_namespace } + + shared_examples 'a correctly formatted namespace' do + it 'returns a valid Kubernetes namespace name' do + expect(subject).to match(Gitlab::Regex.kubernetes_namespace_regex) + expect(subject).to eq(expected_namespace) + end + end + + it_behaves_like 'a correctly formatted namespace' do + let(:expected_namespace) { service.send(:default_namespace) } + end + + context 'when the project path contains forbidden characters' do + before do + project.path = '-a_Strange.Path--forSure' + end + + it_behaves_like 'a correctly formatted namespace' do + let(:expected_namespace) { "a-strange-path--forsure-#{project.id}" } + end + end + + context 'when namespace is specified' do + before do + service.namespace = 'my-namespace' + end + + it_behaves_like 'a correctly formatted namespace' do + let(:expected_namespace) { 'my-namespace' } + end + end + + context 'when service is not assigned to project' do + before do + service.project = nil + end + + it 'does not return namespace' do + is_expected.to be_nil + end + end + end + + describe '#test' do + let(:discovery_url) { 'https://kubernetes.example.com/api/v1' } + + before do + stub_kubeclient_discover(service.api_url) + end + + context 'with path prefix in api_url' do + let(:discovery_url) { 'https://kubernetes.example.com/prefix/api/v1' } + + it 'tests with the prefix' do + service.api_url = 'https://kubernetes.example.com/prefix' + stub_kubeclient_discover(service.api_url) + + expect(service.test[:success]).to be_truthy + expect(WebMock).to have_requested(:get, discovery_url).once + end + end + + context 'with custom CA certificate' do + it 'is added to the certificate store' do + service.ca_pem = "CA PEM DATA" + + cert = double("certificate") + expect(OpenSSL::X509::Certificate).to receive(:new).with(service.ca_pem).and_return(cert) + expect_any_instance_of(OpenSSL::X509::Store).to receive(:add_cert).with(cert) + + expect(service.test[:success]).to be_truthy + expect(WebMock).to have_requested(:get, discovery_url).once + end + end + + context 'success' do + it 'reads the discovery endpoint' do + expect(service.test[:success]).to be_truthy + expect(WebMock).to have_requested(:get, discovery_url).once + end + end + + context 'failure' do + it 'fails to read the discovery endpoint' do + WebMock.stub_request(:get, service.api_url + '/api/v1').to_return(status: 404) + + expect(service.test[:success]).to be_falsy + expect(WebMock).to have_requested(:get, discovery_url).once + end + end + end + + describe '#predefined_variables' do + let(:kubeconfig) do + config_file = expand_fixture_path('config/kubeconfig.yml') + config = YAML.load(File.read(config_file)) + config.dig('users', 0, 'user')['token'] = 'token' + config.dig('contexts', 0, 'context')['namespace'] = namespace + config.dig('clusters', 0, 'cluster')['certificate-authority-data'] = + Base64.strict_encode64('CA PEM DATA') + + YAML.dump(config) + end + + before do + subject.api_url = 'https://kube.domain.com' + subject.token = 'token' + subject.ca_pem = 'CA PEM DATA' + subject.project = project + end + + shared_examples 'setting variables' do + it 'sets the variables' do + expect(subject.predefined_variables).to include( + { key: 'KUBE_URL', value: 'https://kube.domain.com', public: true }, + { key: 'KUBE_TOKEN', value: 'token', public: false }, + { key: 'KUBE_NAMESPACE', value: namespace, public: true }, + { key: 'KUBECONFIG', value: kubeconfig, public: false, file: true }, + { key: 'KUBE_CA_PEM', value: 'CA PEM DATA', public: true }, + { key: 'KUBE_CA_PEM_FILE', value: 'CA PEM DATA', public: true, file: true } + ) + end + end + + context 'namespace is provided' do + let(:namespace) { 'my-project' } + + before do + subject.namespace = namespace + end + + it_behaves_like 'setting variables' + end + + context 'no namespace provided' do + let(:namespace) { subject.actual_namespace } + + it_behaves_like 'setting variables' + + it 'sets the KUBE_NAMESPACE' do + kube_namespace = subject.predefined_variables.find { |h| h[:key] == 'KUBE_NAMESPACE' } + + expect(kube_namespace).not_to be_nil + expect(kube_namespace[:value]).to match(/\A#{Gitlab::PathRegex::PATH_REGEX_STR}-\d+\z/) + end + end + end + + describe '#terminals' do + let(:environment) { build(:environment, project: project, name: "env", slug: "env-000000") } + + subject { service.terminals(environment) } + + context 'with invalid pods' do + it 'returns no terminals' do + stub_reactive_cache(service, pods: [{ "bad" => "pod" }]) + + is_expected.to be_empty + end + end + + context 'with valid pods' do + let(:pod) { kube_pod(app: environment.slug) } + let(:terminals) { kube_terminals(service, pod) } + + before do + stub_reactive_cache( + service, + pods: [pod, pod, kube_pod(app: "should-be-filtered-out")] + ) + end + + it 'returns terminals' do + is_expected.to eq(terminals + terminals) + end + + it 'uses max session time from settings' do + stub_application_setting(terminal_max_session_time: 600) + + times = subject.map { |terminal| terminal[:max_session_time] } + expect(times).to eq [600, 600, 600, 600] + end + end + end + + describe '#calculate_reactive_cache' do + subject { service.calculate_reactive_cache } + + context 'when service is inactive' do + before do + service.active = false + end + + it { is_expected.to be_nil } + end + + context 'when kubernetes responds with valid pods' do + before do + stub_kubeclient_pods + end + + it { is_expected.to eq(pods: [kube_pod]) } + end + + context 'when kubernetes responds with 500s' do + before do + stub_kubeclient_pods(status: 500) + end + + it { expect { subject }.to raise_error(Kubeclient::HttpError) } + end + + context 'when kubernetes responds with 404s' do + before do + stub_kubeclient_pods(status: 404) + end + + it { is_expected.to eq(pods: []) } + end + end + describe "#deprecated?" do let(:kubernetes_service) { create(:kubernetes_service) } |