Commit 2c80a1c0 authored by Dylan Griffith's avatar Dylan Griffith Committed by Mike Greiling

Introduce Knative Serverless Tab

parent e80f8933
import ServerlessBundle from '~/serverless/serverless_bundle';
document.addEventListener('DOMContentLoaded', () => {
new ServerlessBundle(); // eslint-disable-line no-new
});
<script>
export default {
props: {
clustersPath: {
type: String,
required: true,
},
helpPath: {
type: String,
required: true,
},
},
};
</script>
<template>
<div class="row empty-state js-empty-state">
<div class="col-12">
<div class="text-content">
<h4 class="state-title text-center">
{{ s__('Serverless|Getting started with serverless') }}
</h4>
<p class="state-description">
{{
s__(`Serverless| In order to start using functions as a service,
you must first install Knative on your Kubernetes cluster.`)
}}
<a :href="helpPath"> {{ __('More information') }} </a>
</p>
<div class="text-center">
<a :href="clustersPath" class="btn btn-success">
{{ s__('Serverless|Install Knative') }}
</a>
</div>
</div>
</div>
</div>
</template>
<script>
import Timeago from '~/vue_shared/components/time_ago_tooltip.vue';
export default {
components: {
Timeago,
},
props: {
func: {
type: Object,
required: true,
},
},
computed: {
name() {
return this.func.name;
},
url() {
return this.func.url;
},
image() {
return this.func.image;
},
timestamp() {
return this.func.created_at;
},
},
};
</script>
<template>
<div class="gl-responsive-table-row">
<div class="table-section section-20">{{ name }}</div>
<div class="table-section section-50">
<a :href="url">{{ url }}</a>
</div>
<div class="table-section section-20">{{ image }}</div>
<div class="table-section section-10"><timeago :time="timestamp" /></div>
</div>
</template>
<script>
import { GlSkeletonLoading } from '@gitlab/ui';
import FunctionRow from './function_row.vue';
import EmptyState from './empty_state.vue';
export default {
components: {
FunctionRow,
EmptyState,
GlSkeletonLoading,
},
props: {
functions: {
type: Array,
required: true,
default: () => [],
},
installed: {
type: Boolean,
required: true,
},
clustersPath: {
type: String,
required: true,
},
helpPath: {
type: String,
required: true,
},
loadingData: {
type: Boolean,
required: false,
default: true,
},
hasFunctionData: {
type: Boolean,
required: false,
default: true,
},
},
};
</script>
<template>
<section id="serverless-functions">
<div v-if="installed">
<div v-if="hasFunctionData">
<div class="ci-table js-services-list function-element">
<div class="gl-responsive-table-row table-row-header" role="row">
<div class="table-section section-20" role="rowheader">
{{ s__('Serverless|Function') }}
</div>
<div class="table-section section-50" role="rowheader">
{{ s__('Serverless|Domain') }}
</div>
<div class="table-section section-20" role="rowheader">
{{ s__('Serverless|Runtime') }}
</div>
<div class="table-section section-10" role="rowheader">
{{ s__('Serverless|Last Update') }}
</div>
</div>
<template v-if="loadingData">
<div v-for="j in 3" :key="j" class="gl-responsive-table-row">
<gl-skeleton-loading />
</div>
</template>
<template v-else>
<function-row v-for="f in functions" :key="f.name" :func="f" />
</template>
</div>
</div>
<div v-else class="empty-state js-empty-state">
<div class="text-content">
<h4 class="state-title text-center">{{ s__('Serverless|No functions available') }}</h4>
<p class="state-description">
{{
s__(`Serverless|There is currently no function data available from Knative.
This could be for a variety of reasons including:`)
}}
</p>
<ul>
<li>Your repository does not have a corresponding <code>serverless.yml</code> file.</li>
<li>Your <code>gitlab-ci.yml</code> file is not properly configured.</li>
<li>
The functions listed in the <code>serverless.yml</code> file don't match the namespace
of your cluster.
</li>
<li>The deploy job has not finished.</li>
</ul>
<p>
{{
s__(`Serverless|If you believe none of these apply, please check
back later as the function data may be in the process of becoming
available.`)
}}
</p>
<div class="text-center">
<a :href="helpPath" class="btn btn-success">
{{ s__('Serverless|Learn more about Serverless') }}
</a>
</div>
</div>
</div>
</div>
<empty-state v-else :clusters-path="clustersPath" :help-path="helpPath" />
</section>
</template>
<style>
.top-area {
border-bottom: 0;
}
.function-element {
border-bottom: 1px solid #e5e5e5;
border-bottom-color: rgb(229, 229, 229);
border-bottom-style: solid;
border-bottom-width: 1px;
}
</style>
import Vue from 'vue';
export default new Vue();
import Visibility from 'visibilityjs';
import Vue from 'vue';
import { s__ } from '../locale';
import Flash from '../flash';
import Poll from '../lib/utils/poll';
import ServerlessStore from './stores/serverless_store';
import GetFunctionsService from './services/get_functions_service';
import Functions from './components/functions.vue';
export default class Serverless {
constructor() {
const { statusPath, clustersPath, helpPath, installed } = document.querySelector(
'.js-serverless-functions-page',
).dataset;
this.service = new GetFunctionsService(statusPath);
this.knativeInstalled = installed !== undefined;
this.store = new ServerlessStore(this.knativeInstalled, clustersPath, helpPath);
this.initServerless();
this.functionLoadCount = 0;
if (statusPath && this.knativeInstalled) {
this.initPolling();
}
}
initServerless() {
const { store } = this;
const el = document.querySelector('#js-serverless-functions');
this.functions = new Vue({
el,
data() {
return {
state: store.state,
};
},
render(createElement) {
return createElement(Functions, {
props: {
functions: this.state.functions,
installed: this.state.installed,
clustersPath: this.state.clustersPath,
helpPath: this.state.helpPath,
loadingData: this.state.loadingData,
hasFunctionData: this.state.hasFunctionData,
},
});
},
});
}
initPolling() {
this.poll = new Poll({
resource: this.service,
method: 'fetchData',
successCallback: data => this.handleSuccess(data),
errorCallback: () => this.handleError(),
});
if (!Visibility.hidden()) {
this.poll.makeRequest();
} else {
this.service
.fetchData()
.then(data => this.handleSuccess(data))
.catch(() => this.handleError());
}
Visibility.change(() => {
if (!Visibility.hidden() && !this.destroyed) {
this.poll.restart();
} else {
this.poll.stop();
}
});
}
handleSuccess(data) {
if (data.status === 200) {
this.store.updateFunctionsFromServer(data.data);
this.store.updateLoadingState(false);
} else if (data.status === 204) {
/* Time out after 3 attempts to retrieve data */
this.functionLoadCount += 1;
if (this.functionLoadCount === 3) {
this.poll.stop();
this.store.toggleNoFunctionData();
}
}
}
static handleError() {
Flash(s__('Serverless|An error occurred while retrieving serverless components'));
}
destroy() {
this.destroyed = true;
if (this.poll) {
this.poll.stop();
}
this.functions.$destroy();
}
}
import axios from '~/lib/utils/axios_utils';
export default class GetFunctionsService {
constructor(endpoint) {
this.endpoint = endpoint;
}
fetchData() {
return axios.get(this.endpoint);
}
}
export default class ServerlessStore {
constructor(knativeInstalled = false, clustersPath, helpPath) {
this.state = {
functions: [],
hasFunctionData: true,
loadingData: true,
installed: knativeInstalled,
clustersPath,
helpPath,
};
}
updateFunctionsFromServer(functions = []) {
this.state.functions = functions;
}
updateLoadingState(loadingData) {
this.state.loadingData = loadingData;
}
toggleNoFunctionData() {
this.state.hasFunctionData = false;
}
}
# frozen_string_literal: true
module Projects
module Serverless
class FunctionsController < Projects::ApplicationController
include ProjectUnauthorized
before_action :authorize_read_cluster!
INDEX_PRIMING_INTERVAL = 10_000
INDEX_POLLING_INTERVAL = 30_000
def index
finder = Projects::Serverless::FunctionsFinder.new(project.clusters)
respond_to do |format|
format.json do
functions = finder.execute
if functions.any?
Gitlab::PollingInterval.set_header(response, interval: INDEX_POLLING_INTERVAL)
render json: Projects::Serverless::ServiceSerializer.new(current_user: @current_user).represent(functions)
else
Gitlab::PollingInterval.set_header(response, interval: INDEX_PRIMING_INTERVAL)
head :no_content
end
end
format.html do
@installed = finder.installed?
render
end
end
end
end
end
end
# frozen_string_literal: true
module Projects
module Serverless
class FunctionsFinder
def initialize(clusters)
@clusters = clusters
end
def execute
knative_services.flatten.compact
end
def installed?
clusters_with_knative_installed.exists?
end
private
def knative_services
clusters_with_knative_installed.preload_knative.map do |cluster|
cluster.application_knative.services_for(ns: cluster.platform_kubernetes&.actual_namespace)
end
end
def clusters_with_knative_installed
@clusters.with_knative_installed
end
end
end
end
......@@ -307,6 +307,7 @@ module ProjectsHelper
settings: :admin_project,
builds: :read_build,
clusters: :read_cluster,
serverless: :read_cluster,
labels: :read_label,
issues: :read_issue,
project_members: :read_project_member,
......@@ -545,6 +546,7 @@ module ProjectsHelper
%w[
environments
clusters
functions
user
gcp
]
......
......@@ -15,6 +15,9 @@ 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] }
state_machine :status do
before_transition any => [:installed] do |application|
......@@ -29,6 +32,8 @@ module Clusters
validates :hostname, presence: true, hostname: true
scope :for_cluster, -> (cluster) { where(cluster: cluster) }
def chart
'knative/knative'
end
......@@ -55,12 +60,39 @@ 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 }
end
def ingress_service
cluster.kubeclient.get_service('knative-ingressgateway', 'istio-system')
end
def client
cluster.platform_kubernetes.kubeclient.knative_client
def services_for(ns: namespace)
return unless services
return [] unless ns
services.select do |service|
service.dig('metadata', 'namespace') == ns
end
end
private
def read_services
client.get_services.as_json
rescue Kubeclient::ResourceNotFoundError
[]
end
end
end
......
......@@ -93,6 +93,16 @@ module Clusters
where('NOT EXISTS (?)', subquery)
end
scope :with_knative_installed, -> { joins(:application_knative).merge(Clusters::Applications::Knative.installed) }
scope :preload_knative, -> {
preload(
:kubernetes_namespace,
:platform_kubernetes,
:application_knative
)
}
def self.ancestor_clusters_for_clusterable(clusterable, hierarchy_order: :asc)
hierarchy_groups = clusterable.ancestors_upto(hierarchy_order: hierarchy_order).eager_load(:clusters)
hierarchy_groups = hierarchy_groups.merge(current_scope) if current_scope
......
# frozen_string_literal: true
module Projects
module Serverless
class ServiceEntity < Grape::Entity
include RequestAwareEntity
expose :name do |service|
service.dig('metadata', 'name')
end
expose :namespace do |service|
service.dig('metadata', 'namespace')
end
expose :created_at do |service|
service.dig('metadata', 'creationTimestamp')
end
expose :url do |service|
"http://#{service.dig('status', 'domain')}"
end
expose :description do |service|
service.dig('spec', 'runLatest', 'configuration', 'revisionTemplate', 'metadata', 'annotations', 'Description')
end
expose :image do |service|
service.dig('spec', 'runLatest', 'configuration', 'build', 'template', 'name')
end
end
end
end
# frozen_string_literal: true
module Projects
module Serverless
class ServiceSerializer < BaseSerializer
entity Projects::Serverless::ServiceEntity
end
end
end
......@@ -222,6 +222,12 @@
%span
= _('Environments')
- if project_nav_tab? :serverless
= nav_link(controller: :functions) do
= link_to project_serverless_functions_path(@project), title: _('Serverless') do
%span
= _('Serverless')
- if project_nav_tab? :clusters
- show_cluster_hint = show_gke_cluster_integration_callout?(@project)
= nav_link(controller: [:clusters, :user, :gcp]) do
......
- @no_container = true
- @content_class = "limit-container-width" unless fluid_layout
- breadcrumb_title 'Serverless'
- page_title 'Serverless'
- status_path = project_serverless_functions_path(@project, format: :json)
- clusters_path = project_clusters_path(@project)
.serverless-functions-page.js-serverless-functions-page{ data: { status_path: status_path, installed: @installed, clusters_path: clusters_path, help_path: help_page_path('user/project/clusters/serverless/index') } }
%div{ class: [container_class, ('limit-container-width' unless fluid_layout)] }
.js-serverless-functions-notice
.flash-container
.top-area.adjust
.serverless-functions-table#js-serverless-functions
---
title: Introduce Knative and Serverless Components
merge_request: 23174
author: Chris Baumbauer
type: added
......@@ -245,6 +245,10 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
end
end
namespace :serverless do
resources :functions, only: [:index]
end
scope '-' do
get 'archive/*id', constraints: { format: Gitlab::PathRegex.archive_formats_regex, id: /.+?/ }, to: 'repositories#archive', as: 'archive'
......
......@@ -5815,6 +5815,45 @@ msgstr ""
msgid "Server version"
msgstr ""
msgid "Serverless"
msgstr ""
msgid "Serverless| In order to start using functions as a service, you must first install Knative on your Kubernetes cluster."
msgstr ""
msgid "Serverless|An error occurred while retrieving serverless components"
msgstr ""
msgid "Serverless|Domain"
msgstr ""
msgid "Serverless|Function"
msgstr ""
msgid "Serverless|Getting started with serverless"
msgstr ""
msgid "Serverless|If you believe none of these apply, please check back later as the function data may be in the process of becoming available."
msgstr ""
msgid "Serverless|Install Knative"
msgstr ""
msgid "Serverless|Last Update"
msgstr ""
msgid "Serverless|Learn more about Serverless"
msgstr ""
msgid "Serverless|No functions available"
msgstr ""
msgid "Serverless|Runtime"
msgstr ""
msgid "Serverless|There is currently no function data available from Knative. This could be for a variety of reasons including:"
msgstr ""
msgid "Service Templates"
msgstr ""
......
# frozen_string_literal: true
require 'spec_helper'
describe Projects::Serverless::FunctionsController do
include KubernetesHelpers
include ReactiveCachingHelpers
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(:namespace) do
create(:cluster_kubernetes_namespace,
cluster: cluster,
cluster_project: cluster.cluster_project,
project: cluster.cluster_project.project)
end
before do
project.add_maintainer(user)
sign_in(user)
end
def params(opts = {})
opts.reverse_merge(namespace_id: project.namespace.to_param,
project_id: project.to_param)
end
describe 'GET #index' do
context 'empty cache' do
it 'has no data' do
get :index, params({ format: :json })
expect(response).to have_gitlab_http_status(204)
end
it 'renders an html page' do
get :index, params
expect(response).to have_gitlab_http_status(200)
end
end
end
describe 'GET #index with data', :use_clean_rails_memory_store_caching do
before do
stub_reactive_cache(knative, services: kube_knative_services_body(namespace: namespace.namespace, name: cluster.project.name)["items"])
end
it 'has data' do
get :index, params({ format: :json })
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"
)
)
end
it 'has data in html' do
get :index, params
expect(response).to have_gitlab_http_status(200)
end
end
end
require 'spec_helper'
describe 'Functions', :js do
let(:project) { create(:project) }
let(:user) { create(:user) }
before do
project.add_maintainer(user)
gitlab_sign_in(user)
end
context 'when user does not have a cluster and visits the serverless page' do
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
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
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(:knative) { create(:clusters_applications_knative, :installed, cluster: cluster) }
let(:project) { knative.cluster.project }
before do
visit project_serverless_functions_path(project)
end
it 'sees an empty listing of serverless functions' do
expect(page).to have_selector('.gl-responsive-table-row')
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Projects::Serverless::FunctionsFinder do
include KubernetesHelpers
include ReactiveCachingHelpers
let(:user) { create(:user) }
let(:cluster) { create(:cluster, :project, :provided_by_gcp) }
let(:service) { cluster.platform_kubernetes }
let(:project) { cluster.project}
let(:namespace) do
create(:cluster_kubernetes_namespace,
cluster: cluster,
cluster_project: cluster.cluster_project,
project: cluster.cluster_project.project)
end
before do
project.add_maintainer(user)
end
describe 'retrieve data from knative' do
it 'does not have knative installed' do
expect(described_class.new(project.clusters).execute).to be_empty
end
context 'has knative installed' do
let!(:knative) { create(:clusters_applications_knative, :installed, cluster: cluster) }
it 'there are no functions' do
expect(described_class.new(project.clusters).execute).to be_empty
end
it 'there are functions', :use_clean_rails_memory_store_caching do
stub_reactive_cache(knative, services: kube_knative_services_body(namespace: namespace.namespace, name: cluster.project.name)["items"])
expect(described_class.new(project.clusters).execute).not_to be_empty
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.clusters).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.clusters).installed?).to be true
end
end
end
end
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
......@@ -121,4 +124,43 @@ describe Clusters::Applications::Knative do
describe 'validations' do
it { is_expected.to validate_presence_of(:hostname) }
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
end
it 'should have 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))
synchronous_reactive_cache(knative)
end
it 'should have cached services' do
is_expected.not_to be_nil
end
it 'should match our namespace' do
expect(knative.services_for(ns: namespace)).not_to be_nil
end
end
end
end
......@@ -34,6 +34,17 @@ module KubernetesHelpers
WebMock.stub_request(:get, deployments_url).to_return(response || kube_deployments_response)
end
def stub_kubeclient_knative_services(**options)
options[:name] ||= "kubetest"
options[:namespace] ||= "default"
options[:domain] ||= "example.com"
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)))
end
def stub_kubeclient_get_secret(api_url, **options)
options[:metadata_name] ||= "default-token-1"
options[:namespace] ||= "default"
......@@ -181,6 +192,13 @@ module KubernetesHelpers
}
end
def kube_knative_services_body(**options)
{
"kind" => "List",
"items" => [kube_service(options)]
}
end
# This is a partial response, it will have many more elements in reality but
# these are the ones we care about at the moment
def kube_pod(name: "kube-pod", app: "valid-pod-label", status: "Running", track: nil)
......@@ -224,6 +242,54 @@ module KubernetesHelpers
}
end
def kube_service(name: "kubetest", namespace: "default", domain: "example.com")
{
"metadata" => {
"creationTimestamp" => "2018-11-21T06:16:33Z",
"name" => name,
"namespace" => namespace,
"selfLink" => "/apis/serving.knative.dev/v1alpha1/namespaces/#{namespace}/services/#{name}"
},
"spec" => {
"generation" => 2
},
"status" => {
"domain" => "#{name}.#{namespace}.#{domain}",
"domainInternal" => "#{name}.#{namespace}.svc.cluster.local",
"latestCreatedRevisionName" => "#{name}-00002",
"latestReadyRevisionName" => "#{name}-00002",
"observedGeneration" => 2
}
}
end
def kube_service_full(name: "kubetest", namespace: "kube-ns", domain: "example.com")
{
"metadata" => {
"creationTimestamp" => "2018-11-21T06:16:33Z",
"name" => name,
"namespace" => namespace,
"selfLink" => "/apis/serving.knative.dev/v1alpha1/namespaces/#{namespace}/services/#{name}",
"annotation" => {
"description" => "This is a test description"
}
},
"spec" => {
"generation" => 2,
"build" => {
"template" => "go-1.10.3"
}
},
"status" => {
"domain" => "#{name}.#{namespace}.#{domain}",
"domainInternal" => "#{name}.#{namespace}.svc.cluster.local",
"latestCreatedRevisionName" => "#{name}-00002",
"latestReadyRevisionName" => "#{name}-00002",
"observedGeneration" => 2
}
}
end
def kube_terminals(service, pod)
pod_name = pod['metadata']['name']
containers = pod['spec']['containers']
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment