diff options
26 files changed, 617 insertions, 298 deletions
diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue index f9b4e789563..94341050b86 100644 --- a/app/assets/javascripts/serverless/components/functions.vue +++ b/app/assets/javascripts/serverless/components/functions.vue @@ -4,6 +4,7 @@ import { GlLoadingIcon } from '@gitlab/ui'; import FunctionRow from './function_row.vue'; import EnvironmentRow from './environment_row.vue'; import EmptyState from './empty_state.vue'; +import { CHECKING_INSTALLED } from '../constants'; export default { components: { @@ -13,10 +14,6 @@ export default { GlLoadingIcon, }, props: { - installed: { - type: Boolean, - required: true, - }, clustersPath: { type: String, required: true, @@ -31,8 +28,15 @@ export default { }, }, computed: { - ...mapState(['isLoading', 'hasFunctionData']), + ...mapState(['installed', 'isLoading', 'hasFunctionData']), ...mapGetters(['getFunctions']), + + checkingInstalled() { + return this.installed === CHECKING_INSTALLED; + }, + isInstalled() { + return this.installed === true; + }, }, created() { this.fetchFunctions({ @@ -47,15 +51,16 @@ export default { <template> <section id="serverless-functions"> - <div v-if="installed"> + <gl-loading-icon + v-if="checkingInstalled" + :size="2" + class="prepend-top-default append-bottom-default" + /> + + <div v-else-if="isInstalled"> <div v-if="hasFunctionData"> - <gl-loading-icon - v-if="isLoading" - :size="2" - class="prepend-top-default append-bottom-default" - /> - <template v-else> - <div class="groups-list-tree-container"> + <template> + <div class="groups-list-tree-container js-functions-wrapper"> <ul class="content-list group-list-tree"> <environment-row v-for="(env, index) in getFunctions" @@ -66,6 +71,11 @@ export default { </ul> </div> </template> + <gl-loading-icon + v-if="isLoading" + :size="2" + class="prepend-top-default append-bottom-default js-functions-loader" + /> </div> <div v-else class="empty-state js-empty-state"> <div class="text-content"> diff --git a/app/assets/javascripts/serverless/constants.js b/app/assets/javascripts/serverless/constants.js index 35f77205f2c..2fa15e56ccb 100644 --- a/app/assets/javascripts/serverless/constants.js +++ b/app/assets/javascripts/serverless/constants.js @@ -1,3 +1,7 @@ export const MAX_REQUESTS = 3; // max number of times to retry export const X_INTERVAL = 5; // Reflects the number of verticle bars on the x-axis + +export const CHECKING_INSTALLED = 'checking'; // The backend is still determining whether or not Knative is installed + +export const TIMEOUT = 'timeout'; diff --git a/app/assets/javascripts/serverless/serverless_bundle.js b/app/assets/javascripts/serverless/serverless_bundle.js index 2d3f086ffee..ed3b633d766 100644 --- a/app/assets/javascripts/serverless/serverless_bundle.js +++ b/app/assets/javascripts/serverless/serverless_bundle.js @@ -45,7 +45,7 @@ export default class Serverless { }, }); } else { - const { statusPath, clustersPath, helpPath, installed } = document.querySelector( + const { statusPath, clustersPath, helpPath } = document.querySelector( '.js-serverless-functions-page', ).dataset; @@ -56,7 +56,6 @@ export default class Serverless { render(createElement) { return createElement(Functions, { props: { - installed: installed !== undefined, clustersPath, helpPath, statusPath, diff --git a/app/assets/javascripts/serverless/store/actions.js b/app/assets/javascripts/serverless/store/actions.js index 826501c9022..a0a9fdf7ace 100644 --- a/app/assets/javascripts/serverless/store/actions.js +++ b/app/assets/javascripts/serverless/store/actions.js @@ -3,13 +3,18 @@ import axios from '~/lib/utils/axios_utils'; import statusCodes from '~/lib/utils/http_status'; import { backOff } from '~/lib/utils/common_utils'; import createFlash from '~/flash'; -import { MAX_REQUESTS } from '../constants'; +import { __ } from '~/locale'; +import { MAX_REQUESTS, CHECKING_INSTALLED, TIMEOUT } from '../constants'; export const requestFunctionsLoading = ({ commit }) => commit(types.REQUEST_FUNCTIONS_LOADING); export const receiveFunctionsSuccess = ({ commit }, data) => commit(types.RECEIVE_FUNCTIONS_SUCCESS, data); -export const receiveFunctionsNoDataSuccess = ({ commit }) => - commit(types.RECEIVE_FUNCTIONS_NODATA_SUCCESS); +export const receiveFunctionsPartial = ({ commit }, data) => + commit(types.RECEIVE_FUNCTIONS_PARTIAL, data); +export const receiveFunctionsTimeout = ({ commit }, data) => + commit(types.RECEIVE_FUNCTIONS_TIMEOUT, data); +export const receiveFunctionsNoDataSuccess = ({ commit }, data) => + commit(types.RECEIVE_FUNCTIONS_NODATA_SUCCESS, data); export const receiveFunctionsError = ({ commit }, error) => commit(types.RECEIVE_FUNCTIONS_ERROR, error); @@ -25,18 +30,25 @@ export const receiveMetricsError = ({ commit }, error) => export const fetchFunctions = ({ dispatch }, { functionsPath }) => { let retryCount = 0; + const functionsPartiallyFetched = data => { + if (data.functions !== null && data.functions.length) { + dispatch('receiveFunctionsPartial', data); + } + }; + dispatch('requestFunctionsLoading'); backOff((next, stop) => { axios .get(functionsPath) .then(response => { - if (response.status === statusCodes.NO_CONTENT) { + if (response.data.knative_installed === CHECKING_INSTALLED) { retryCount += 1; if (retryCount < MAX_REQUESTS) { + functionsPartiallyFetched(response.data); next(); } else { - stop(null); + stop(TIMEOUT); } } else { stop(response.data); @@ -45,10 +57,13 @@ export const fetchFunctions = ({ dispatch }, { functionsPath }) => { .catch(stop); }) .then(data => { - if (data !== null) { + if (data === TIMEOUT) { + dispatch('receiveFunctionsTimeout'); + createFlash(__('Loading functions timed out. Please reload the page to try again.')); + } else if (data.functions !== null && data.functions.length) { dispatch('receiveFunctionsSuccess', data); } else { - dispatch('receiveFunctionsNoDataSuccess'); + dispatch('receiveFunctionsNoDataSuccess', data); } }) .catch(error => { diff --git a/app/assets/javascripts/serverless/store/mutation_types.js b/app/assets/javascripts/serverless/store/mutation_types.js index 25b2f7ac38a..b8fa9ea1a01 100644 --- a/app/assets/javascripts/serverless/store/mutation_types.js +++ b/app/assets/javascripts/serverless/store/mutation_types.js @@ -1,5 +1,7 @@ export const REQUEST_FUNCTIONS_LOADING = 'REQUEST_FUNCTIONS_LOADING'; export const RECEIVE_FUNCTIONS_SUCCESS = 'RECEIVE_FUNCTIONS_SUCCESS'; +export const RECEIVE_FUNCTIONS_PARTIAL = 'RECEIVE_FUNCTIONS_PARTIAL'; +export const RECEIVE_FUNCTIONS_TIMEOUT = 'RECEIVE_FUNCTIONS_TIMEOUT'; export const RECEIVE_FUNCTIONS_NODATA_SUCCESS = 'RECEIVE_FUNCTIONS_NODATA_SUCCESS'; export const RECEIVE_FUNCTIONS_ERROR = 'RECEIVE_FUNCTIONS_ERROR'; diff --git a/app/assets/javascripts/serverless/store/mutations.js b/app/assets/javascripts/serverless/store/mutations.js index 991f32a275d..2685a5b11ff 100644 --- a/app/assets/javascripts/serverless/store/mutations.js +++ b/app/assets/javascripts/serverless/store/mutations.js @@ -5,12 +5,23 @@ export default { state.isLoading = true; }, [types.RECEIVE_FUNCTIONS_SUCCESS](state, data) { - state.functions = data; + state.functions = data.functions; + state.installed = data.knative_installed; state.isLoading = false; state.hasFunctionData = true; }, - [types.RECEIVE_FUNCTIONS_NODATA_SUCCESS](state) { + [types.RECEIVE_FUNCTIONS_PARTIAL](state, data) { + state.functions = data.functions; + state.installed = true; + state.isLoading = true; + state.hasFunctionData = true; + }, + [types.RECEIVE_FUNCTIONS_TIMEOUT](state) { + state.isLoading = false; + }, + [types.RECEIVE_FUNCTIONS_NODATA_SUCCESS](state, data) { state.isLoading = false; + state.installed = data.knative_installed; state.hasFunctionData = false; }, [types.RECEIVE_FUNCTIONS_ERROR](state, error) { diff --git a/app/assets/javascripts/serverless/store/state.js b/app/assets/javascripts/serverless/store/state.js index afc3f37d7ba..fdd29299749 100644 --- a/app/assets/javascripts/serverless/store/state.js +++ b/app/assets/javascripts/serverless/store/state.js @@ -1,5 +1,6 @@ export default () => ({ error: null, + installed: 'checking', isLoading: true, // functions diff --git a/app/controllers/projects/serverless/functions_controller.rb b/app/controllers/projects/serverless/functions_controller.rb index 79030da64d3..4b0d001fca6 100644 --- a/app/controllers/projects/serverless/functions_controller.rb +++ b/app/controllers/projects/serverless/functions_controller.rb @@ -10,15 +10,13 @@ module Projects format.json do functions = finder.execute - if functions.any? - render json: serialize_function(functions) - else - head :no_content - end + render json: { + knative_installed: finder.knative_installed, + functions: serialize_function(functions) + }.to_json end format.html do - @installed = finder.installed? render end end diff --git a/app/finders/clusters/cluster/knative_services_finder.rb b/app/finders/clusters/cluster/knative_services_finder.rb new file mode 100644 index 00000000000..85b0967e935 --- /dev/null +++ b/app/finders/clusters/cluster/knative_services_finder.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true +module Clusters + class Cluster + class KnativeServicesFinder + include ReactiveCaching + include Gitlab::Utils::StrongMemoize + + KNATIVE_STATES = { + 'checking' => 'checking', + 'installed' => 'installed', + 'not_found' => 'not_found' + }.freeze + + self.reactive_cache_key = ->(finder) { finder.model_name } + self.reactive_cache_worker_finder = ->(_id, *cache_args) { from_cache(*cache_args) } + + attr_reader :cluster, :project + + def initialize(cluster, project) + @cluster = cluster + @project = project + end + + def with_reactive_cache_memoized(*cache_args, &block) + strong_memoize(:reactive_cache) do + with_reactive_cache(*cache_args, &block) + end + end + + def clear_cache! + clear_reactive_cache!(*cache_args) + end + + def self.from_cache(cluster_id, project_id) + cluster = Clusters::Cluster.find(cluster_id) + project = ::Project.find(project_id) + + new(cluster, project) + end + + def calculate_reactive_cache(*) + # read_services calls knative_client.discover implicitily. If we stop + # detecting services but still want to detect knative, we'll need to + # explicitily call: knative_client.discover + # + # We didn't create it separately to avoid 2 cluster requests. + ksvc = read_services + pods = knative_client.discovered ? read_pods : [] + { services: ksvc, pods: pods, knative_detected: knative_client.discovered } + end + + def services + return [] unless search_namespace + + cached_data = with_reactive_cache_memoized(*cache_args) { |data| data } + cached_data.to_h.fetch(:services, []) + end + + def cache_args + [cluster.id, project.id] + end + + def service_pod_details(service) + cached_data = with_reactive_cache_memoized(*cache_args) { |data| data } + cached_data.to_h.fetch(:pods, []).select do |pod| + filter_pods(pod, service) + end + end + + def knative_detected + cached_data = with_reactive_cache_memoized(*cache_args) { |data| data } + + knative_state = cached_data.to_h[:knative_detected] + + return KNATIVE_STATES['checking'] if knative_state.nil? + return KNATIVE_STATES['installed'] if knative_state + + KNATIVE_STATES['uninstalled'] + end + + def model_name + self.class.name.underscore.tr('/', '_') + end + + private + + def search_namespace + @search_namespace ||= cluster.kubernetes_namespace_for(project) + end + + def knative_client + cluster.kubeclient.knative_client + end + + def filter_pods(pod, service) + pod["metadata"]["labels"]["serving.knative.dev/service"] == service + end + + def read_services + knative_client.get_services(namespace: search_namespace).as_json + rescue Kubeclient::ResourceNotFoundError + [] + end + + def read_pods + cluster.kubeclient.core_client.get_pods(namespace: search_namespace).as_json + end + + def id + nil + end + end + end +end diff --git a/app/finders/projects/serverless/functions_finder.rb b/app/finders/projects/serverless/functions_finder.rb index e5bffccabfe..9b8d7ed5a58 100644 --- a/app/finders/projects/serverless/functions_finder.rb +++ b/app/finders/projects/serverless/functions_finder.rb @@ -14,8 +14,15 @@ module Projects knative_services.flatten.compact end - def installed? - clusters_with_knative_installed.exists? + # Possible return values: Clusters::Cluster::KnativeServicesFinder::KNATIVE_STATE + def knative_installed + states = @clusters.map do |cluster| + cluster.knative_services_finder(project).knative_detected.tap do |state| + return state if state == ::Clusters::Cluster::KnativeServicesFinder::KNATIVE_STATES['checking'] # rubocop:disable Cop/AvoidReturnFromBlocks + end + end + + states.any? { |state| state == ::Clusters::Cluster::KnativeServicesFinder::KNATIVE_STATES['installed'] } end def service(environment_scope, name) @@ -25,7 +32,7 @@ module Projects def invocation_metrics(environment_scope, name) return unless prometheus_adapter&.can_query? - cluster = clusters_with_knative_installed.preload_knative.find do |c| + cluster = @clusters.find do |c| environment_scope == c.environment_scope end @@ -34,7 +41,7 @@ module Projects end def has_prometheus?(environment_scope) - clusters_with_knative_installed.preload_knative.to_a.any? do |cluster| + @clusters.any? do |cluster| environment_scope == cluster.environment_scope && cluster.application_prometheus_available? end end @@ -42,10 +49,12 @@ module Projects private def knative_service(environment_scope, name) - clusters_with_knative_installed.preload_knative.map do |cluster| + @clusters.map do |cluster| next if environment_scope != cluster.environment_scope - services = cluster.application_knative.services_for(ns: cluster.kubernetes_namespace_for(project)) + services = cluster + .knative_services_finder(project) + .services .select { |svc| svc["metadata"]["name"] == name } add_metadata(cluster, services).first unless services.nil? @@ -53,8 +62,11 @@ module Projects end def knative_services - clusters_with_knative_installed.preload_knative.map do |cluster| - services = cluster.application_knative.services_for(ns: cluster.kubernetes_namespace_for(project)) + @clusters.map do |cluster| + services = cluster + .knative_services_finder(project) + .services + add_metadata(cluster, services) unless services.nil? end end @@ -65,17 +77,14 @@ module Projects s["cluster_id"] = cluster.id if services.length == 1 - s["podcount"] = cluster.application_knative.service_pod_details( - cluster.kubernetes_namespace_for(project), - s["metadata"]["name"]).length + s["podcount"] = cluster + .knative_services_finder(project) + .service_pod_details(s["metadata"]["name"]) + .length end end end - def clusters_with_knative_installed - @clusters.with_knative_installed - end - # rubocop: disable CodeReuse/ServiceClass def prometheus_adapter @prometheus_adapter ||= ::Prometheus::AdapterService.new(project).prometheus_adapter diff --git a/app/models/clusters/applications/knative.rb b/app/models/clusters/applications/knative.rb index 9fbf5d8af04..d5a3bd62e3d 100644 --- a/app/models/clusters/applications/knative.rb +++ b/app/models/clusters/applications/knative.rb @@ -15,9 +15,6 @@ module Clusters include ::Clusters::Concerns::ApplicationVersion include ::Clusters::Concerns::ApplicationData include AfterCommitQueue - include ReactiveCaching - - self.reactive_cache_key = ->(knative) { [knative.class.model_name.singular, knative.id] } def set_initial_status return unless not_installable? @@ -41,8 +38,6 @@ module Clusters scope :for_cluster, -> (cluster) { where(cluster: cluster) } - after_save :clear_reactive_cache! - def chart 'knative/knative' end @@ -77,55 +72,12 @@ module Clusters ClusterWaitForIngressIpAddressWorker.perform_async(name, id) end - def client - cluster.kubeclient.knative_client - end - - def services - with_reactive_cache do |data| - data[:services] - end - end - - def calculate_reactive_cache - { services: read_services, pods: read_pods } - end - def ingress_service cluster.kubeclient.get_service('istio-ingressgateway', 'istio-system') end - def services_for(ns: namespace) - return [] unless services - return [] unless ns - - services.select do |service| - service.dig('metadata', 'namespace') == ns - end - end - - def service_pod_details(ns, service) - with_reactive_cache do |data| - data[:pods].select { |pod| filter_pods(pod, ns, service) } - end - end - private - def read_pods - cluster.kubeclient.core_client.get_pods.as_json - end - - def filter_pods(pod, namespace, service) - pod["metadata"]["namespace"] == namespace && pod["metadata"]["labels"]["serving.knative.dev/service"] == service - end - - def read_services - client.get_services.as_json - rescue Kubeclient::ResourceNotFoundError - [] - end - def install_knative_metrics ["kubectl apply -f #{METRICS_CONFIG}"] if cluster.application_prometheus_available? end diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index 57a1e461b2d..e1d6b2a802b 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -223,6 +223,10 @@ module Clusters end end + def knative_services_finder(project) + @knative_services_finder ||= KnativeServicesFinder.new(self, project) + end + private def instance_domain diff --git a/changelogs/unreleased/58941-use-gitlab-serverless-with-existing-knative-installation.yml b/changelogs/unreleased/58941-use-gitlab-serverless-with-existing-knative-installation.yml new file mode 100644 index 00000000000..53be008816d --- /dev/null +++ b/changelogs/unreleased/58941-use-gitlab-serverless-with-existing-knative-installation.yml @@ -0,0 +1,5 @@ +--- +title: Enable function features for external Knative installations +merge_request: 27173 +author: +type: changed diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 4fbcab95420..c1e405716e1 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -5742,6 +5742,9 @@ msgstr "" msgid "Live preview" msgstr "" +msgid "Loading functions timed out. Please reload the page to try again." +msgstr "" + msgid "Loading the GitLab IDE..." msgstr "" diff --git a/spec/controllers/projects/serverless/functions_controller_spec.rb b/spec/controllers/projects/serverless/functions_controller_spec.rb index 782f5f272d9..18c594acae0 100644 --- a/spec/controllers/projects/serverless/functions_controller_spec.rb +++ b/spec/controllers/projects/serverless/functions_controller_spec.rb @@ -8,9 +8,8 @@ describe Projects::Serverless::FunctionsController do let(:user) { create(:user) } let(:cluster) { create(:cluster, :project, :provided_by_gcp) } - let(:knative) { create(:clusters_applications_knative, :installed, cluster: cluster) } let(:service) { cluster.platform_kubernetes } - let(:project) { cluster.project} + let(:project) { cluster.project } let(:namespace) do create(:cluster_kubernetes_namespace, @@ -30,17 +29,69 @@ describe Projects::Serverless::FunctionsController do end describe 'GET #index' do - context 'empty cache' do - it 'has no data' do + let(:expected_json) { { 'knative_installed' => knative_state, 'functions' => functions } } + + context 'when cache is being read' do + let(:knative_state) { 'checking' } + let(:functions) { [] } + + before do get :index, params: params({ format: :json }) + end - expect(response).to have_gitlab_http_status(204) + it 'returns checking' do + expect(json_response).to eq expected_json end - it 'renders an html page' do - get :index, params: params + it { expect(response).to have_gitlab_http_status(200) } + end + + context 'when cache is ready' do + let(:knative_services_finder) { project.clusters.first.knative_services_finder(project) } + let(:knative_state) { true } - expect(response).to have_gitlab_http_status(200) + before do + allow_any_instance_of(Clusters::Cluster) + .to receive(:knative_services_finder) + .and_return(knative_services_finder) + synchronous_reactive_cache(knative_services_finder) + stub_kubeclient_service_pods( + kube_response({ "kind" => "PodList", "items" => [] }), + namespace: namespace.namespace + ) + end + + context 'when no functions were found' do + let(:functions) { [] } + + before do + stub_kubeclient_knative_services( + namespace: namespace.namespace, + response: kube_response({ "kind" => "ServiceList", "items" => [] }) + ) + get :index, params: params({ format: :json }) + end + + it 'returns checking' do + expect(json_response).to eq expected_json + end + + it { expect(response).to have_gitlab_http_status(200) } + end + + context 'when functions were found' do + let(:functions) { ["asdf"] } + + before do + stub_kubeclient_knative_services(namespace: namespace.namespace) + get :index, params: params({ format: :json }) + end + + it 'returns functions' do + expect(json_response["functions"]).not_to be_empty + end + + it { expect(response).to have_gitlab_http_status(200) } end end end @@ -56,11 +107,12 @@ describe Projects::Serverless::FunctionsController do context 'valid data', :use_clean_rails_memory_store_caching do before do stub_kubeclient_service_pods - stub_reactive_cache(knative, + stub_reactive_cache(cluster.knative_services_finder(project), { services: kube_knative_services_body(namespace: namespace.namespace, name: cluster.project.name)["items"], pods: kube_knative_pods_body(cluster.project.name, namespace.namespace)["items"] - }) + }, + *cluster.knative_services_finder(project).cache_args) end it 'has a valid function name' do @@ -88,11 +140,12 @@ describe Projects::Serverless::FunctionsController do describe 'GET #index with data', :use_clean_rails_memory_store_caching do before do stub_kubeclient_service_pods - stub_reactive_cache(knative, + stub_reactive_cache(cluster.knative_services_finder(project), { services: kube_knative_services_body(namespace: namespace.namespace, name: cluster.project.name)["items"], pods: kube_knative_pods_body(cluster.project.name, namespace.namespace)["items"] - }) + }, + *cluster.knative_services_finder(project).cache_args) end it 'has data' do @@ -100,11 +153,16 @@ describe Projects::Serverless::FunctionsController do expect(response).to have_gitlab_http_status(200) - expect(json_response).to contain_exactly( - a_hash_including( - "name" => project.name, - "url" => "http://#{project.name}.#{namespace.namespace}.example.com" - ) + expect(json_response).to match( + { + "knative_installed" => "checking", + "functions" => [ + a_hash_including( + "name" => project.name, + "url" => "http://#{project.name}.#{namespace.namespace}.example.com" + ) + ] + } ) end diff --git a/spec/features/projects/serverless/functions_spec.rb b/spec/features/projects/serverless/functions_spec.rb index e14934b1672..9865dbbfb3c 100644 --- a/spec/features/projects/serverless/functions_spec.rb +++ b/spec/features/projects/serverless/functions_spec.rb @@ -4,6 +4,7 @@ require 'spec_helper' describe 'Functions', :js do include KubernetesHelpers + include ReactiveCachingHelpers let(:project) { create(:project) } let(:user) { create(:user) } @@ -13,44 +14,70 @@ describe 'Functions', :js do gitlab_sign_in(user) end - context 'when user does not have a cluster and visits the serverless page' do + shared_examples "it's missing knative installation" do before do visit project_serverless_functions_path(project) end - it 'sees an empty state' do + it 'sees an empty state require Knative installation' do expect(page).to have_link('Install Knative') expect(page).to have_selector('.empty-state') end end + context 'when user does not have a cluster and visits the serverless page' do + it_behaves_like "it's missing knative installation" + end + context 'when the user does have a cluster and visits the serverless page' do let(:cluster) { create(:cluster, :project, :provided_by_gcp) } - before do - visit project_serverless_functions_path(project) - end - - it 'sees an empty state' do - expect(page).to have_link('Install Knative') - expect(page).to have_selector('.empty-state') - end + it_behaves_like "it's missing knative installation" end context 'when the user has a cluster and knative installed and visits the serverless page' do let(:cluster) { create(:cluster, :project, :provided_by_gcp) } let(:service) { cluster.platform_kubernetes } - let(:knative) { create(:clusters_applications_knative, :installed, cluster: cluster) } - let(:project) { knative.cluster.project } + let(:project) { cluster.project } + let(:knative_services_finder) { project.clusters.first.knative_services_finder(project) } + let(:namespace) do + create(:cluster_kubernetes_namespace, + cluster: cluster, + cluster_project: cluster.cluster_project, + project: cluster.cluster_project.project) + end before do - stub_kubeclient_knative_services - stub_kubeclient_service_pods + allow_any_instance_of(Clusters::Cluster) + .to receive(:knative_services_finder) + .and_return(knative_services_finder) + synchronous_reactive_cache(knative_services_finder) + stub_kubeclient_knative_services(stub_get_services_options) + stub_kubeclient_service_pods(nil, namespace: namespace.namespace) visit project_serverless_functions_path(project) end - it 'sees an empty listing of serverless functions' do - expect(page).to have_selector('.empty-state') + context 'when there are no functions' do + let(:stub_get_services_options) do + { + namespace: namespace.namespace, + response: kube_response({ "kind" => "ServiceList", "items" => [] }) + } + end + + it 'sees an empty listing of serverless functions' do + expect(page).to have_selector('.empty-state') + expect(page).not_to have_selector('.content-list') + end + end + + context 'when there are functions' do + let(:stub_get_services_options) { { namespace: namespace.namespace } } + + it 'does not see an empty listing of serverless functions' do + expect(page).not_to have_selector('.empty-state') + expect(page).to have_selector('.content-list') + end end end end diff --git a/spec/finders/clusters/cluster/knative_services_finder_spec.rb b/spec/finders/clusters/cluster/knative_services_finder_spec.rb new file mode 100644 index 00000000000..277200d06f4 --- /dev/null +++ b/spec/finders/clusters/cluster/knative_services_finder_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Clusters::Cluster::KnativeServicesFinder do + include KubernetesHelpers + include ReactiveCachingHelpers + + let(:cluster) { create(:cluster, :project, :provided_by_gcp) } + let(:service) { cluster.platform_kubernetes } + let(:project) { cluster.cluster_project.project } + let(:namespace) do + create(:cluster_kubernetes_namespace, + cluster: cluster, + cluster_project: cluster.cluster_project, + project: project) + end + + before do + stub_kubeclient_knative_services(namespace: namespace.namespace) + stub_kubeclient_service_pods( + kube_response( + kube_knative_pods_body( + project.name, namespace.namespace + ) + ), + namespace: namespace.namespace + ) + end + + shared_examples 'a cached data' do + it 'has an unintialized cache' do + is_expected.to be_blank + end + + context 'when using synchronous reactive cache' do + before do + synchronous_reactive_cache(cluster.knative_services_finder(project)) + end + + context 'when there are functions for cluster namespace' do + it { is_expected.not_to be_blank } + end + + context 'when there are no functions for cluster namespace' do + before do + stub_kubeclient_knative_services( + namespace: namespace.namespace, + response: kube_response({ "kind" => "ServiceList", "items" => [] }) + ) + stub_kubeclient_service_pods( + kube_response({ "kind" => "PodList", "items" => [] }), + namespace: namespace.namespace + ) + end + + it { is_expected.to be_blank } + end + end + end + + describe '#service_pod_details' do + subject { cluster.knative_services_finder(project).service_pod_details(project.name) } + + it_behaves_like 'a cached data' + end + + describe '#services' do + subject { cluster.knative_services_finder(project).services } + + it_behaves_like 'a cached data' + end + + describe '#knative_detected' do + subject { cluster.knative_services_finder(project).knative_detected } + before do + synchronous_reactive_cache(cluster.knative_services_finder(project)) + end + + context 'when knative is installed' do + before do + stub_kubeclient_discover(service.api_url) + end + + it { is_expected.to be_truthy } + it "discovers knative installation" do + expect { subject } + .to change { cluster.kubeclient.knative_client.discovered } + .from(false) + .to(true) + end + end + + context 'when knative is not installed' do + before do + stub_kubeclient_discover_knative_not_found(service.api_url) + end + + it { is_expected.to be_falsy } + it "does not discover knative installation" do + expect { subject }.not_to change { cluster.kubeclient.knative_client.discovered } + end + end + end +end diff --git a/spec/finders/projects/serverless/functions_finder_spec.rb b/spec/finders/projects/serverless/functions_finder_spec.rb index 3ad38207da4..8aea45b457c 100644 --- a/spec/finders/projects/serverless/functions_finder_spec.rb +++ b/spec/finders/projects/serverless/functions_finder_spec.rb @@ -10,7 +10,7 @@ describe Projects::Serverless::FunctionsFinder do let(:user) { create(:user) } let(:cluster) { create(:cluster, :project, :provided_by_gcp) } let(:service) { cluster.platform_kubernetes } - let(:project) { cluster.project} + let(:project) { cluster.project } let(:namespace) do create(:cluster_kubernetes_namespace, @@ -23,9 +23,45 @@ describe Projects::Serverless::FunctionsFinder do project.add_maintainer(user) end + describe '#installed' do + it 'when reactive_caching is still fetching data' do + expect(described_class.new(project).knative_installed).to eq 'checking' + end + + context 'when reactive_caching has finished' do + let(:knative_services_finder) { project.clusters.first.knative_services_finder(project) } + + before do + allow_any_instance_of(Clusters::Cluster) + .to receive(:knative_services_finder) + .and_return(knative_services_finder) + synchronous_reactive_cache(knative_services_finder) + end + + context 'when knative is not installed' do + it 'returns false' do + stub_kubeclient_discover_knative_not_found(service.api_url) + + expect(described_class.new(project).knative_installed).to eq false + end + end + + context 'reactive_caching is finished and knative is installed' do + let(:knative_services_finder) { project.clusters.first.knative_services_finder(project) } + + it 'returns true' do + stub_kubeclient_knative_services(namespace: namespace.namespace) + stub_kubeclient_service_pods(nil, namespace: namespace.namespace) + + expect(described_class.new(project).knative_installed).to be true + end + end + end + end + describe 'retrieve data from knative' do - it 'does not have knative installed' do - expect(described_class.new(project).execute).to be_empty + context 'does not have knative installed' do + it { expect(described_class.new(project).execute).to be_empty } end context 'has knative installed' do @@ -38,22 +74,24 @@ describe Projects::Serverless::FunctionsFinder do it 'there are functions', :use_clean_rails_memory_store_caching do stub_kubeclient_service_pods - stub_reactive_cache(knative, + stub_reactive_cache(cluster.knative_services_finder(project), { services: kube_knative_services_body(namespace: namespace.namespace, name: cluster.project.name)["items"], pods: kube_knative_pods_body(cluster.project.name, namespace.namespace)["items"] - }) + }, + *cluster.knative_services_finder(project).cache_args) expect(finder.execute).not_to be_empty end it 'has a function', :use_clean_rails_memory_store_caching do stub_kubeclient_service_pods - stub_reactive_cache(knative, + stub_reactive_cache(cluster.knative_services_finder(project), { services: kube_knative_services_body(namespace: namespace.namespace, name: cluster.project.name)["items"], pods: kube_knative_pods_body(cluster.project.name, namespace.namespace)["items"] - }) + }, + *cluster.knative_services_finder(project).cache_args) result = finder.service(cluster.environment_scope, cluster.project.name) expect(result).not_to be_empty @@ -84,20 +122,4 @@ describe Projects::Serverless::FunctionsFinder do end end end - - describe 'verify if knative is installed' do - context 'knative is not installed' do - it 'does not have knative installed' do - expect(described_class.new(project).installed?).to be false - end - end - - context 'knative is installed' do - let!(:knative) { create(:clusters_applications_knative, :installed, cluster: cluster) } - - it 'does have knative installed' do - expect(described_class.new(project).installed?).to be true - end - end - end end diff --git a/spec/frontend/serverless/components/environment_row_spec.js b/spec/frontend/serverless/components/environment_row_spec.js index 161a637dd75..0ad85e218dc 100644 --- a/spec/frontend/serverless/components/environment_row_spec.js +++ b/spec/frontend/serverless/components/environment_row_spec.js @@ -14,7 +14,7 @@ describe('environment row component', () => { beforeEach(() => { localVue = createLocalVue(); - vm = createComponent(localVue, translate(mockServerlessFunctions)['*'], '*'); + vm = createComponent(localVue, translate(mockServerlessFunctions.functions)['*'], '*'); }); afterEach(() => vm.$destroy()); @@ -48,7 +48,11 @@ describe('environment row component', () => { beforeEach(() => { localVue = createLocalVue(); - vm = createComponent(localVue, translate(mockServerlessFunctionsDiffEnv).test, 'test'); + vm = createComponent( + localVue, + translate(mockServerlessFunctionsDiffEnv.functions).test, + 'test', + ); }); afterEach(() => vm.$destroy()); diff --git a/spec/frontend/serverless/components/functions_spec.js b/spec/frontend/serverless/components/functions_spec.js index 6924fb9e91f..d8a80f8031e 100644 --- a/spec/frontend/serverless/components/functions_spec.js +++ b/spec/frontend/serverless/components/functions_spec.js @@ -34,11 +34,11 @@ describe('functionsComponent', () => { }); it('should render empty state when Knative is not installed', () => { + store.dispatch('receiveFunctionsSuccess', { knative_installed: false }); component = shallowMount(functionsComponent, { localVue, store, propsData: { - installed: false, clustersPath: '', helpPath: '', statusPath: '', @@ -55,7 +55,6 @@ describe('functionsComponent', () => { localVue, store, propsData: { - installed: true, clustersPath: '', helpPath: '', statusPath: '', @@ -67,12 +66,11 @@ describe('functionsComponent', () => { }); it('should render empty state when there is no function data', () => { - store.dispatch('receiveFunctionsNoDataSuccess'); + store.dispatch('receiveFunctionsNoDataSuccess', { knative_installed: true }); component = shallowMount(functionsComponent, { localVue, store, propsData: { - installed: true, clustersPath: '', helpPath: '', statusPath: '', @@ -91,12 +89,31 @@ describe('functionsComponent', () => { ); }); + it('should render functions and a loader when functions are partially fetched', () => { + store.dispatch('receiveFunctionsPartial', { + ...mockServerlessFunctions, + knative_installed: 'checking', + }); + component = shallowMount(functionsComponent, { + localVue, + store, + propsData: { + clustersPath: '', + helpPath: '', + statusPath: '', + }, + sync: false, + }); + + expect(component.find('.js-functions-wrapper').exists()).toBe(true); + expect(component.find('.js-functions-loader').exists()).toBe(true); + }); + it('should render the functions list', () => { component = shallowMount(functionsComponent, { localVue, store, propsData: { - installed: true, clustersPath: 'clustersPath', helpPath: 'helpPath', statusPath, diff --git a/spec/frontend/serverless/mock_data.js b/spec/frontend/serverless/mock_data.js index a2c18616324..ef616ceb37f 100644 --- a/spec/frontend/serverless/mock_data.js +++ b/spec/frontend/serverless/mock_data.js @@ -1,56 +1,62 @@ -export const mockServerlessFunctions = [ - { - name: 'testfunc1', - namespace: 'tm-example', - environment_scope: '*', - cluster_id: 46, - detail_url: '/testuser/testproj/serverless/functions/*/testfunc1', - podcount: null, - created_at: '2019-02-05T01:01:23Z', - url: 'http://testfunc1.tm-example.apps.example.com', - description: 'A test service', - image: 'knative-test-container-buildtemplate', - }, - { - name: 'testfunc2', - namespace: 'tm-example', - environment_scope: '*', - cluster_id: 46, - detail_url: '/testuser/testproj/serverless/functions/*/testfunc2', - podcount: null, - created_at: '2019-02-05T01:01:23Z', - url: 'http://testfunc2.tm-example.apps.example.com', - description: 'A second test service\nThis one with additional descriptions', - image: 'knative-test-echo-buildtemplate', - }, -]; +export const mockServerlessFunctions = { + knative_installed: true, + functions: [ + { + name: 'testfunc1', + namespace: 'tm-example', + environment_scope: '*', + cluster_id: 46, + detail_url: '/testuser/testproj/serverless/functions/*/testfunc1', + podcount: null, + created_at: '2019-02-05T01:01:23Z', + url: 'http://testfunc1.tm-example.apps.example.com', + description: 'A test service', + image: 'knative-test-container-buildtemplate', + }, + { + name: 'testfunc2', + namespace: 'tm-example', + environment_scope: '*', + cluster_id: 46, + detail_url: '/testuser/testproj/serverless/functions/*/testfunc2', + podcount: null, + created_at: '2019-02-05T01:01:23Z', + url: 'http://testfunc2.tm-example.apps.example.com', + description: 'A second test service\nThis one with additional descriptions', + image: 'knative-test-echo-buildtemplate', + }, + ], +}; -export const mockServerlessFunctionsDiffEnv = [ - { - name: 'testfunc1', - namespace: 'tm-example', - environment_scope: '*', - cluster_id: 46, - detail_url: '/testuser/testproj/serverless/functions/*/testfunc1', - podcount: null, - created_at: '2019-02-05T01:01:23Z', - url: 'http://testfunc1.tm-example.apps.example.com', - description: 'A test service', - image: 'knative-test-container-buildtemplate', - }, - { - name: 'testfunc2', - namespace: 'tm-example', - environment_scope: 'test', - cluster_id: 46, - detail_url: '/testuser/testproj/serverless/functions/*/testfunc2', - podcount: null, - created_at: '2019-02-05T01:01:23Z', - url: 'http://testfunc2.tm-example.apps.example.com', - description: 'A second test service\nThis one with additional descriptions', - image: 'knative-test-echo-buildtemplate', - }, -]; +export const mockServerlessFunctionsDiffEnv = { + knative_installed: true, + functions: [ + { + name: 'testfunc1', + namespace: 'tm-example', + environment_scope: '*', + cluster_id: 46, + detail_url: '/testuser/testproj/serverless/functions/*/testfunc1', + podcount: null, + created_at: '2019-02-05T01:01:23Z', + url: 'http://testfunc1.tm-example.apps.example.com', + description: 'A test service', + image: 'knative-test-container-buildtemplate', + }, + { + name: 'testfunc2', + namespace: 'tm-example', + environment_scope: 'test', + cluster_id: 46, + detail_url: '/testuser/testproj/serverless/functions/*/testfunc2', + podcount: null, + created_at: '2019-02-05T01:01:23Z', + url: 'http://testfunc2.tm-example.apps.example.com', + description: 'A second test service\nThis one with additional descriptions', + image: 'knative-test-echo-buildtemplate', + }, + ], +}; export const mockServerlessFunction = { name: 'testfunc1', diff --git a/spec/frontend/serverless/store/getters_spec.js b/spec/frontend/serverless/store/getters_spec.js index fb549c8f153..92853fda37c 100644 --- a/spec/frontend/serverless/store/getters_spec.js +++ b/spec/frontend/serverless/store/getters_spec.js @@ -32,7 +32,7 @@ describe('Serverless Store Getters', () => { describe('getFunctions', () => { it('should translate the raw function array to group the functions per environment scope', () => { - state.functions = mockServerlessFunctions; + state.functions = mockServerlessFunctions.functions; const funcs = getters.getFunctions(state); diff --git a/spec/frontend/serverless/store/mutations_spec.js b/spec/frontend/serverless/store/mutations_spec.js index ca3053e5c38..e2771c7e5fd 100644 --- a/spec/frontend/serverless/store/mutations_spec.js +++ b/spec/frontend/serverless/store/mutations_spec.js @@ -19,13 +19,13 @@ describe('ServerlessMutations', () => { expect(state.isLoading).toEqual(false); expect(state.hasFunctionData).toEqual(true); - expect(state.functions).toEqual(mockServerlessFunctions); + expect(state.functions).toEqual(mockServerlessFunctions.functions); }); it('should ensure loading has stopped and hasFunctionData is false when there are no functions available', () => { const state = {}; - mutations[types.RECEIVE_FUNCTIONS_NODATA_SUCCESS](state); + mutations[types.RECEIVE_FUNCTIONS_NODATA_SUCCESS](state, { knative_installed: true }); expect(state.isLoading).toEqual(false); expect(state.hasFunctionData).toEqual(false); diff --git a/spec/models/clusters/applications/knative_spec.rb b/spec/models/clusters/applications/knative_spec.rb index d5974f47190..b38cf96de7e 100644 --- a/spec/models/clusters/applications/knative_spec.rb +++ b/spec/models/clusters/applications/knative_spec.rb @@ -3,9 +3,6 @@ require 'rails_helper' describe Clusters::Applications::Knative do - include KubernetesHelpers - include ReactiveCachingHelpers - let(:knative) { create(:clusters_applications_knative) } include_examples 'cluster application core specs', :clusters_applications_knative @@ -146,77 +143,4 @@ describe Clusters::Applications::Knative do describe 'validations' do it { is_expected.to validate_presence_of(:hostname) } end - - describe '#service_pod_details' do - let(:cluster) { create(:cluster, :project, :provided_by_gcp) } - let(:service) { cluster.platform_kubernetes } - let(:knative) { create(:clusters_applications_knative, cluster: cluster) } - - let(:namespace) do - create(:cluster_kubernetes_namespace, - cluster: cluster, - cluster_project: cluster.cluster_project, - project: cluster.cluster_project.project) - end - - before do - stub_kubeclient_discover(service.api_url) - stub_kubeclient_knative_services - stub_kubeclient_service_pods - stub_reactive_cache(knative, - { - services: kube_response(kube_knative_services_body), - pods: kube_response(kube_knative_pods_body(cluster.cluster_project.project.name, namespace.namespace)) - }) - synchronous_reactive_cache(knative) - end - - it 'is able k8s core for pod details' do - expect(knative.service_pod_details(namespace.namespace, cluster.cluster_project.project.name)).not_to be_nil - end - end - - describe '#services' do - let(:cluster) { create(:cluster, :project, :provided_by_gcp) } - let(:service) { cluster.platform_kubernetes } - let(:knative) { create(:clusters_applications_knative, cluster: cluster) } - - let(:namespace) do - create(:cluster_kubernetes_namespace, - cluster: cluster, - cluster_project: cluster.cluster_project, - project: cluster.cluster_project.project) - end - - subject { knative.services } - - before do - stub_kubeclient_discover(service.api_url) - stub_kubeclient_knative_services - stub_kubeclient_service_pods - end - - it 'has an unintialized cache' do - is_expected.to be_nil - end - - context 'when using synchronous reactive cache' do - before do - stub_reactive_cache(knative, - { - services: kube_response(kube_knative_services_body), - pods: kube_response(kube_knative_pods_body(cluster.cluster_project.project.name, namespace.namespace)) - }) - synchronous_reactive_cache(knative) - end - - it 'has cached services' do - is_expected.not_to be_nil - end - - it 'matches our namespace' do - expect(knative.services_for(ns: namespace)).not_to be_nil - end - end - end end diff --git a/spec/models/clusters/cluster_spec.rb b/spec/models/clusters/cluster_spec.rb index 4739e62289a..60a19ccd48a 100644 --- a/spec/models/clusters/cluster_spec.rb +++ b/spec/models/clusters/cluster_spec.rb @@ -38,6 +38,11 @@ describe Clusters::Cluster, :use_clean_rails_memory_store_caching do it { is_expected.to respond_to :project } + it do + expect(subject.knative_services_finder(subject.project)) + .to be_instance_of(Clusters::Cluster::KnativeServicesFinder) + end + describe '.enabled' do subject { described_class.enabled } diff --git a/spec/support/helpers/kubernetes_helpers.rb b/spec/support/helpers/kubernetes_helpers.rb index 78b7ae9c00c..011c4df0fe5 100644 --- a/spec/support/helpers/kubernetes_helpers.rb +++ b/spec/support/helpers/kubernetes_helpers.rb @@ -17,17 +17,38 @@ module KubernetesHelpers kube_response(kube_deployments_body) end - def stub_kubeclient_discover(api_url) + def stub_kubeclient_discover_base(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)) - WebMock.stub_request(:get, api_url + '/apis/serving.knative.dev/v1alpha1').to_return(kube_response(kube_v1alpha1_serving_knative_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_discover(api_url) + stub_kubeclient_discover_base(api_url) + + WebMock + .stub_request(:get, api_url + '/apis/serving.knative.dev/v1alpha1') + .to_return(kube_response(kube_v1alpha1_serving_knative_discovery_body)) + end + + def stub_kubeclient_discover_knative_not_found(api_url) + stub_kubeclient_discover_base(api_url) + + WebMock + .stub_request(:get, api_url + '/apis/serving.knative.dev/v1alpha1') + .to_return(status: [404, "Resource Not Found"]) end - def stub_kubeclient_service_pods(status: nil) + def stub_kubeclient_service_pods(response = nil, options = {}) stub_kubeclient_discover(service.api_url) - pods_url = service.api_url + "/api/v1/pods" - response = { status: status } if status + + namespace_path = options[:namespace].present? ? "namespaces/#{options[:namespace]}/" : "" + + pods_url = service.api_url + "/api/v1/#{namespace_path}pods" WebMock.stub_request(:get, pods_url).to_return(response || kube_pods_response) end @@ -56,15 +77,18 @@ module KubernetesHelpers WebMock.stub_request(:get, deployments_url).to_return(response || kube_deployments_response) end - def stub_kubeclient_knative_services(**options) + def stub_kubeclient_knative_services(options = {}) + namespace_path = options[:namespace].present? ? "namespaces/#{options[:namespace]}/" : "" + options[:name] ||= "kubetest" - options[:namespace] ||= "default" options[:domain] ||= "example.com" + options[:response] ||= kube_response(kube_knative_services_body(options)) stub_kubeclient_discover(service.api_url) - knative_url = service.api_url + "/apis/serving.knative.dev/v1alpha1/services" - WebMock.stub_request(:get, knative_url).to_return(kube_response(kube_knative_services_body(options))) + knative_url = service.api_url + "/apis/serving.knative.dev/v1alpha1/#{namespace_path}services" + + WebMock.stub_request(:get, knative_url).to_return(options[:response]) end def stub_kubeclient_get_secret(api_url, **options) |