summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorShinya Maeda <shinya@gitlab.com>2018-05-24 17:03:10 +0900
committerShinya Maeda <shinya@gitlab.com>2018-05-24 17:03:10 +0900
commitbce62515b628c4c2e56511c1b0a0674150350baf (patch)
tree7fb39f45171bc08015f38b8eda56f587bc3b88a4
parente8dd1a25a5a0a9564e03c86eab5cbd06162e3feb (diff)
downloadgitlab-ce-bce62515b628c4c2e56511c1b0a0674150350baf.tar.gz
Revert removing kubernetes service logic
-rw-r--r--app/models/project_services/kubernetes_service.rb136
-rw-r--r--spec/models/project_services/kubernetes_service_spec.rb226
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) }