Commit d68ded21 authored by Dmitriy Zaporozhets's avatar Dmitriy Zaporozhets

Merge branch '46487-add-support-for-jupyter-in-gitlab-via-kubernetes' into 'master'

Resolve "Add support for Jupyter in GitLab via Kubernetes"

Closes #46487

See merge request gitlab-org/gitlab-ce!19019
parents 83510980 69e9e957
...@@ -31,6 +31,7 @@ export default class Clusters { ...@@ -31,6 +31,7 @@ export default class Clusters {
installHelmPath, installHelmPath,
installIngressPath, installIngressPath,
installRunnerPath, installRunnerPath,
installJupyterPath,
installPrometheusPath, installPrometheusPath,
managePrometheusPath, managePrometheusPath,
clusterStatus, clusterStatus,
...@@ -51,6 +52,7 @@ export default class Clusters { ...@@ -51,6 +52,7 @@ export default class Clusters {
installIngressEndpoint: installIngressPath, installIngressEndpoint: installIngressPath,
installRunnerEndpoint: installRunnerPath, installRunnerEndpoint: installRunnerPath,
installPrometheusEndpoint: installPrometheusPath, installPrometheusEndpoint: installPrometheusPath,
installJupyterEndpoint: installJupyterPath,
}); });
this.installApplication = this.installApplication.bind(this); this.installApplication = this.installApplication.bind(this);
...@@ -209,11 +211,12 @@ export default class Clusters { ...@@ -209,11 +211,12 @@ export default class Clusters {
} }
} }
installApplication(appId) { installApplication(data) {
const appId = data.id;
this.store.updateAppProperty(appId, 'requestStatus', REQUEST_LOADING); this.store.updateAppProperty(appId, 'requestStatus', REQUEST_LOADING);
this.store.updateAppProperty(appId, 'requestReason', null); this.store.updateAppProperty(appId, 'requestReason', null);
this.service.installApplication(appId) this.service.installApplication(appId, data.params)
.then(() => { .then(() => {
this.store.updateAppProperty(appId, 'requestStatus', REQUEST_SUCCESS); this.store.updateAppProperty(appId, 'requestStatus', REQUEST_SUCCESS);
}) })
......
...@@ -52,6 +52,11 @@ ...@@ -52,6 +52,11 @@
type: String, type: String,
required: false, required: false,
}, },
installApplicationRequestParams: {
type: Object,
required: false,
default: () => ({}),
},
}, },
computed: { computed: {
rowJsClass() { rowJsClass() {
...@@ -109,7 +114,10 @@ ...@@ -109,7 +114,10 @@
}, },
methods: { methods: {
installClicked() { installClicked() {
eventHub.$emit('installApplication', this.id); eventHub.$emit('installApplication', {
id: this.id,
params: this.installApplicationRequestParams,
});
}, },
}, },
}; };
......
...@@ -121,6 +121,12 @@ export default { ...@@ -121,6 +121,12 @@ export default {
false, false,
); );
}, },
jupyterInstalled() {
return this.applications.jupyter.status === APPLICATION_INSTALLED;
},
jupyterHostname() {
return this.applications.jupyter.hostname;
},
}, },
}; };
</script> </script>
...@@ -278,11 +284,67 @@ export default { ...@@ -278,11 +284,67 @@ export default {
applications to production.`) }} applications to production.`) }}
</div> </div>
</application-row> </application-row>
<application-row
id="jupyter"
:title="applications.jupyter.title"
title-link="https://jupyterhub.readthedocs.io/en/stable/"
:status="applications.jupyter.status"
:status-reason="applications.jupyter.statusReason"
:request-status="applications.jupyter.requestStatus"
:request-reason="applications.jupyter.requestReason"
:install-application-request-params="{ hostname: applications.jupyter.hostname }"
>
<div slot="description">
<p>
{{ s__(`ClusterIntegration|JupyterHub, a multi-user Hub, spawns,
manages, and proxies multiple instances of the single-user
Jupyter notebook server. JupyterHub can be used to serve
notebooks to a class of students, a corporate data science group,
or a scientific research group.`) }}
</p>
<template v-if="ingressExternalIp">
<div class="form-group">
<label for="jupyter-hostname">
{{ s__('ClusterIntegration|Jupyter Hostname') }}
</label>
<div class="input-group">
<input
type="text"
class="form-control js-hostname"
v-model="applications.jupyter.hostname"
:readonly="jupyterInstalled"
/>
<span
class="input-group-btn"
>
<clipboard-button
:text="jupyterHostname"
:title="s__('ClusterIntegration|Copy Jupyter Hostname to clipboard')"
class="js-clipboard-btn"
/>
</span>
</div>
</div>
<p v-if="ingressInstalled">
{{ s__(`ClusterIntegration|Replace this with your own hostname if you want.
If you do so, point hostname to Ingress IP Address from above.`) }}
<a
:href="ingressDnsHelpPath"
target="_blank"
rel="noopener noreferrer"
>
{{ __('More information') }}
</a>
</p>
</template>
</div>
</application-row>
<!-- <!--
NOTE: Don't forget to update `clusters.scss` NOTE: Don't forget to update `clusters.scss`
min-height for this block and uncomment `application_spec` tests min-height for this block and uncomment `application_spec` tests
--> -->
<!-- Add GitLab Runner row, all other plumbing is complete -->
</div> </div>
</div> </div>
</section> </section>
......
...@@ -11,3 +11,4 @@ export const REQUEST_LOADING = 'request-loading'; ...@@ -11,3 +11,4 @@ export const REQUEST_LOADING = 'request-loading';
export const REQUEST_SUCCESS = 'request-success'; export const REQUEST_SUCCESS = 'request-success';
export const REQUEST_FAILURE = 'request-failure'; export const REQUEST_FAILURE = 'request-failure';
export const INGRESS = 'ingress'; export const INGRESS = 'ingress';
export const JUPYTER = 'jupyter';
...@@ -8,6 +8,7 @@ export default class ClusterService { ...@@ -8,6 +8,7 @@ export default class ClusterService {
ingress: this.options.installIngressEndpoint, ingress: this.options.installIngressEndpoint,
runner: this.options.installRunnerEndpoint, runner: this.options.installRunnerEndpoint,
prometheus: this.options.installPrometheusEndpoint, prometheus: this.options.installPrometheusEndpoint,
jupyter: this.options.installJupyterEndpoint,
}; };
} }
...@@ -15,8 +16,8 @@ export default class ClusterService { ...@@ -15,8 +16,8 @@ export default class ClusterService {
return axios.get(this.options.endpoint); return axios.get(this.options.endpoint);
} }
installApplication(appId) { installApplication(appId, params) {
return axios.post(this.appInstallEndpointMap[appId]); return axios.post(this.appInstallEndpointMap[appId], params);
} }
static updateCluster(endpoint, data) { static updateCluster(endpoint, data) {
......
import { s__ } from '../../locale'; import { s__ } from '../../locale';
import { INGRESS } from '../constants'; import { INGRESS, JUPYTER } from '../constants';
export default class ClusterStore { export default class ClusterStore {
constructor() { constructor() {
...@@ -38,6 +38,14 @@ export default class ClusterStore { ...@@ -38,6 +38,14 @@ export default class ClusterStore {
requestStatus: null, requestStatus: null,
requestReason: null, requestReason: null,
}, },
jupyter: {
title: s__('ClusterIntegration|JupyterHub'),
status: null,
statusReason: null,
requestStatus: null,
requestReason: null,
hostname: null,
},
}, },
}; };
} }
...@@ -83,6 +91,12 @@ export default class ClusterStore { ...@@ -83,6 +91,12 @@ export default class ClusterStore {
if (appId === INGRESS) { if (appId === INGRESS) {
this.state.applications.ingress.externalIp = serverAppEntry.external_ip; this.state.applications.ingress.externalIp = serverAppEntry.external_ip;
} else if (appId === JUPYTER) {
this.state.applications.jupyter.hostname =
serverAppEntry.hostname ||
(this.state.applications.ingress.externalIp
? `jupyter.${this.state.applications.ingress.externalIp}.xip.io`
: '');
} }
}); });
} }
......
...@@ -6,7 +6,7 @@ ...@@ -6,7 +6,7 @@
.cluster-applications-table { .cluster-applications-table {
// Wait for the Vue to kick-in and render the applications block // Wait for the Vue to kick-in and render the applications block
min-height: 400px; min-height: 628px;
} }
.clusters-dropdown-menu { .clusters-dropdown-menu {
......
...@@ -5,7 +5,17 @@ class Projects::Clusters::ApplicationsController < Projects::ApplicationControll ...@@ -5,7 +5,17 @@ class Projects::Clusters::ApplicationsController < Projects::ApplicationControll
before_action :authorize_create_cluster!, only: [:create] before_action :authorize_create_cluster!, only: [:create]
def create def create
application = @application_class.find_or_create_by!(cluster: @cluster) application = @application_class.find_or_initialize_by(cluster: @cluster)
if application.has_attribute?(:hostname)
application.hostname = params[:hostname]
end
if application.respond_to?(:oauth_application)
application.oauth_application = create_oauth_application(application)
end
application.save!
Clusters::Applications::ScheduleInstallationService.new(project, current_user).execute(application) Clusters::Applications::ScheduleInstallationService.new(project, current_user).execute(application)
...@@ -23,4 +33,15 @@ class Projects::Clusters::ApplicationsController < Projects::ApplicationControll ...@@ -23,4 +33,15 @@ class Projects::Clusters::ApplicationsController < Projects::ApplicationControll
def application_class def application_class
@application_class ||= Clusters::Cluster::APPLICATIONS[params[:application]] || render_404 @application_class ||= Clusters::Cluster::APPLICATIONS[params[:application]] || render_404
end end
def create_oauth_application(application)
oauth_application_params = {
name: params[:application],
redirect_uri: application.callback_url,
scopes: 'api read_user openid',
owner: current_user
}
Applications::CreateService.new(current_user, oauth_application_params).execute
end
end end
module Clusters
module Applications
class Jupyter < ActiveRecord::Base
VERSION = '0.0.1'.freeze
self.table_name = 'clusters_applications_jupyter'
include ::Clusters::Concerns::ApplicationCore
include ::Clusters::Concerns::ApplicationStatus
include ::Clusters::Concerns::ApplicationData
belongs_to :oauth_application, class_name: 'Doorkeeper::Application'
default_value_for :version, VERSION
def set_initial_status
return unless not_installable?
if cluster&.application_ingress_installed? && cluster.application_ingress.external_ip
self.status = 'installable'
end
end
def chart
"#{name}/jupyterhub"
end
def repository
'https://jupyterhub.github.io/helm-chart/'
end
def values
content_values.to_yaml
end
def install_command
Gitlab::Kubernetes::Helm::InstallCommand.new(
name,
chart: chart,
values: values,
repository: repository
)
end
def callback_url
"http://#{hostname}/hub/oauth_callback"
end
private
def specification
{
"ingress" => {
"hosts" => [hostname]
},
"hub" => {
"extraEnv" => {
"GITLAB_HOST" => gitlab_url
},
"cookieSecret" => cookie_secret
},
"proxy" => {
"secretToken" => secret_token
},
"auth" => {
"gitlab" => {
"clientId" => oauth_application.uid,
"clientSecret" => oauth_application.secret,
"callbackUrl" => callback_url
}
}
}
end
def gitlab_url
Gitlab.config.gitlab.url
end
def content_values
YAML.load_file(chart_values_file).deep_merge!(specification)
end
def secret_token
@secret_token ||= SecureRandom.hex(32)
end
def cookie_secret
@cookie_secret ||= SecureRandom.hex(32)
end
end
end
end
...@@ -8,7 +8,8 @@ module Clusters ...@@ -8,7 +8,8 @@ module Clusters
Applications::Helm.application_name => Applications::Helm, Applications::Helm.application_name => Applications::Helm,
Applications::Ingress.application_name => Applications::Ingress, Applications::Ingress.application_name => Applications::Ingress,
Applications::Prometheus.application_name => Applications::Prometheus, Applications::Prometheus.application_name => Applications::Prometheus,
Applications::Runner.application_name => Applications::Runner Applications::Runner.application_name => Applications::Runner,
Applications::Jupyter.application_name => Applications::Jupyter
}.freeze }.freeze
DEFAULT_ENVIRONMENT = '*'.freeze DEFAULT_ENVIRONMENT = '*'.freeze
...@@ -26,6 +27,7 @@ module Clusters ...@@ -26,6 +27,7 @@ module Clusters
has_one :application_ingress, class_name: 'Clusters::Applications::Ingress' has_one :application_ingress, class_name: 'Clusters::Applications::Ingress'
has_one :application_prometheus, class_name: 'Clusters::Applications::Prometheus' has_one :application_prometheus, class_name: 'Clusters::Applications::Prometheus'
has_one :application_runner, class_name: 'Clusters::Applications::Runner' has_one :application_runner, class_name: 'Clusters::Applications::Runner'
has_one :application_jupyter, class_name: 'Clusters::Applications::Jupyter'
accepts_nested_attributes_for :provider_gcp, update_only: true accepts_nested_attributes_for :provider_gcp, update_only: true
accepts_nested_attributes_for :platform_kubernetes, update_only: true accepts_nested_attributes_for :platform_kubernetes, update_only: true
...@@ -39,6 +41,7 @@ module Clusters ...@@ -39,6 +41,7 @@ module Clusters
delegate :active?, to: :platform_kubernetes, prefix: true, allow_nil: true delegate :active?, to: :platform_kubernetes, prefix: true, allow_nil: true
delegate :installed?, to: :application_helm, prefix: true, allow_nil: true delegate :installed?, to: :application_helm, prefix: true, allow_nil: true
delegate :installed?, to: :application_ingress, prefix: true, allow_nil: true
enum platform_type: { enum platform_type: {
kubernetes: 1 kubernetes: 1
...@@ -74,7 +77,8 @@ module Clusters ...@@ -74,7 +77,8 @@ module Clusters
application_helm || build_application_helm, application_helm || build_application_helm,
application_ingress || build_application_ingress, application_ingress || build_application_ingress,
application_prometheus || build_application_prometheus, application_prometheus || build_application_prometheus,
application_runner || build_application_runner application_runner || build_application_runner,
application_jupyter || build_application_jupyter
] ]
end end
......
...@@ -3,4 +3,5 @@ class ClusterApplicationEntity < Grape::Entity ...@@ -3,4 +3,5 @@ class ClusterApplicationEntity < Grape::Entity
expose :status_name, as: :status expose :status_name, as: :status
expose :status_reason expose :status_reason
expose :external_ip, if: -> (e, _) { e.respond_to?(:external_ip) } expose :external_ip, if: -> (e, _) { e.respond_to?(:external_ip) }
expose :hostname, if: -> (e, _) { e.respond_to?(:hostname) }
end end
...@@ -12,8 +12,8 @@ module Clusters ...@@ -12,8 +12,8 @@ module Clusters
ClusterWaitForAppInstallationWorker::INTERVAL, app.name, app.id) ClusterWaitForAppInstallationWorker::INTERVAL, app.name, app.id)
rescue Kubeclient::HttpError => ke rescue Kubeclient::HttpError => ke
app.make_errored!("Kubernetes error: #{ke.message}") app.make_errored!("Kubernetes error: #{ke.message}")
rescue StandardError rescue StandardError => e
app.make_errored!("Can't start installation process") app.make_errored!("Can't start installation process. #{e.message}")
end end
end end
end end
......
...@@ -11,6 +11,7 @@ ...@@ -11,6 +11,7 @@
install_ingress_path: install_applications_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster, :ingress), install_ingress_path: install_applications_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster, :ingress),
install_prometheus_path: install_applications_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster, :prometheus), install_prometheus_path: install_applications_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster, :prometheus),
install_runner_path: install_applications_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster, :runner), install_runner_path: install_applications_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster, :runner),
install_jupyter_path: install_applications_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster, :jupyter),
toggle_status: @cluster.enabled? ? 'true': 'false', toggle_status: @cluster.enabled? ? 'true': 'false',
cluster_status: @cluster.status_name, cluster_status: @cluster.status_name,
cluster_status_reason: @cluster.status_reason, cluster_status_reason: @cluster.status_reason,
......
---
title: Adds JupyterHub to cluster applications
merge_request: 19019
author:
type: added
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class CreateClustersApplicationsJupyter < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def change
create_table :clusters_applications_jupyter do |t|
t.references :cluster, null: false, unique: true, foreign_key: { on_delete: :cascade }
t.references :oauth_application, foreign_key: { on_delete: :nullify }
t.integer :status, null: false
t.string :version, null: false
t.string :hostname
t.timestamps_with_timezone null: false
t.text :status_reason
end
end
end
...@@ -635,6 +635,17 @@ ActiveRecord::Schema.define(version: 20180529093006) do ...@@ -635,6 +635,17 @@ ActiveRecord::Schema.define(version: 20180529093006) do
t.string "external_ip" t.string "external_ip"
end end
create_table "clusters_applications_jupyter", force: :cascade do |t|
t.integer "cluster_id", null: false
t.integer "oauth_application_id"
t.integer "status", null: false
t.string "version", null: false
t.string "hostname"
t.datetime_with_timezone "created_at", null: false
t.datetime_with_timezone "updated_at", null: false
t.text "status_reason"
end
create_table "clusters_applications_prometheus", force: :cascade do |t| create_table "clusters_applications_prometheus", force: :cascade do |t|
t.integer "cluster_id", null: false t.integer "cluster_id", null: false
t.integer "status", null: false t.integer "status", null: false
...@@ -2196,6 +2207,8 @@ ActiveRecord::Schema.define(version: 20180529093006) do ...@@ -2196,6 +2207,8 @@ ActiveRecord::Schema.define(version: 20180529093006) do
add_foreign_key "clusters", "users", on_delete: :nullify add_foreign_key "clusters", "users", on_delete: :nullify
add_foreign_key "clusters_applications_helm", "clusters", on_delete: :cascade add_foreign_key "clusters_applications_helm", "clusters", on_delete: :cascade
add_foreign_key "clusters_applications_ingress", "clusters", name: "fk_753a7b41c1", on_delete: :cascade add_foreign_key "clusters_applications_ingress", "clusters", name: "fk_753a7b41c1", on_delete: :cascade
add_foreign_key "clusters_applications_jupyter", "clusters", on_delete: :cascade
add_foreign_key "clusters_applications_jupyter", "oauth_applications", on_delete: :nullify
add_foreign_key "clusters_applications_prometheus", "clusters", name: "fk_557e773639", on_delete: :cascade add_foreign_key "clusters_applications_prometheus", "clusters", name: "fk_557e773639", on_delete: :cascade
add_foreign_key "clusters_applications_runners", "ci_runners", column: "runner_id", name: "fk_02de2ded36", on_delete: :nullify add_foreign_key "clusters_applications_runners", "ci_runners", column: "runner_id", name: "fk_02de2ded36", on_delete: :nullify
add_foreign_key "clusters_applications_runners", "clusters", on_delete: :cascade add_foreign_key "clusters_applications_runners", "clusters", on_delete: :cascade
......
...@@ -156,6 +156,7 @@ added directly to your configured cluster. Those applications are needed for ...@@ -156,6 +156,7 @@ added directly to your configured cluster. Those applications are needed for
| [Ingress](https://kubernetes.io/docs/concepts/services-networking/ingress/) | 10.2+ | Ingress can provide load balancing, SSL termination, and name-based virtual hosting. It acts as a web proxy for your applications and is useful if you want to use [Auto DevOps] or deploy your own web apps. | | [Ingress](https://kubernetes.io/docs/concepts/services-networking/ingress/) | 10.2+ | Ingress can provide load balancing, SSL termination, and name-based virtual hosting. It acts as a web proxy for your applications and is useful if you want to use [Auto DevOps] or deploy your own web apps. |
| [Prometheus](https://prometheus.io/docs/introduction/overview/) | 10.4+ | Prometheus is an open-source monitoring and alerting system useful to supervise your deployed applications | | [Prometheus](https://prometheus.io/docs/introduction/overview/) | 10.4+ | Prometheus is an open-source monitoring and alerting system useful to supervise your deployed applications |
| [GitLab Runner](https://docs.gitlab.com/runner/) | 10.6+ | GitLab Runner is the open source project that is used to run your jobs and send the results back to GitLab. It is used in conjunction with [GitLab CI/CD](https://about.gitlab.com/features/gitlab-ci-cd/), the open-source continuous integration service included with GitLab that coordinates the jobs. When installing the GitLab Runner via the applications, it will run in **privileged mode** by default. Make sure you read the [security implications](#security-implications) before doing so. | | [GitLab Runner](https://docs.gitlab.com/runner/) | 10.6+ | GitLab Runner is the open source project that is used to run your jobs and send the results back to GitLab. It is used in conjunction with [GitLab CI/CD](https://about.gitlab.com/features/gitlab-ci-cd/), the open-source continuous integration service included with GitLab that coordinates the jobs. When installing the GitLab Runner via the applications, it will run in **privileged mode** by default. Make sure you read the [security implications](#security-implications) before doing so. |
| [JupyterHub](http://jupyter.org/) | 11.0+ | The Jupyter Notebook is an open-source web application that allows you to create and share documents that contain live code, equations, visualizations and narrative text. |
## Getting the external IP address ## Getting the external IP address
......
...@@ -35,5 +35,8 @@ FactoryBot.define do ...@@ -35,5 +35,8 @@ FactoryBot.define do
factory :clusters_applications_ingress, class: Clusters::Applications::Ingress factory :clusters_applications_ingress, class: Clusters::Applications::Ingress
factory :clusters_applications_prometheus, class: Clusters::Applications::Prometheus factory :clusters_applications_prometheus, class: Clusters::Applications::Prometheus
factory :clusters_applications_runner, class: Clusters::Applications::Runner factory :clusters_applications_runner, class: Clusters::Applications::Runner
factory :clusters_applications_jupyter, class: Clusters::Applications::Jupyter do
oauth_application factory: :oauth_application
end
end end
end end
...@@ -31,7 +31,8 @@ ...@@ -31,7 +31,8 @@
} }
}, },
"status_reason": { "type": ["string", "null"] }, "status_reason": { "type": ["string", "null"] },
"external_ip": { "type": ["string", "null"] } "external_ip": { "type": ["string", "null"] },
"hostname": { "type": ["string", "null"] }
}, },
"required" : [ "name", "status" ] "required" : [ "name", "status" ]
} }
......
...@@ -207,11 +207,11 @@ describe('Clusters', () => { ...@@ -207,11 +207,11 @@ describe('Clusters', () => {
spyOn(cluster.service, 'installApplication').and.returnValue(Promise.resolve()); spyOn(cluster.service, 'installApplication').and.returnValue(Promise.resolve());
expect(cluster.store.state.applications.helm.requestStatus).toEqual(null); expect(cluster.store.state.applications.helm.requestStatus).toEqual(null);
cluster.installApplication('helm'); cluster.installApplication({ id: 'helm' });
expect(cluster.store.state.applications.helm.requestStatus).toEqual(REQUEST_LOADING); expect(cluster.store.state.applications.helm.requestStatus).toEqual(REQUEST_LOADING);
expect(cluster.store.state.applications.helm.requestReason).toEqual(null); expect(cluster.store.state.applications.helm.requestReason).toEqual(null);
expect(cluster.service.installApplication).toHaveBeenCalledWith('helm'); expect(cluster.service.installApplication).toHaveBeenCalledWith('helm', undefined);
getSetTimeoutPromise() getSetTimeoutPromise()
.then(() => { .then(() => {
...@@ -226,11 +226,11 @@ describe('Clusters', () => { ...@@ -226,11 +226,11 @@ describe('Clusters', () => {
spyOn(cluster.service, 'installApplication').and.returnValue(Promise.resolve()); spyOn(cluster.service, 'installApplication').and.returnValue(Promise.resolve());
expect(cluster.store.state.applications.ingress.requestStatus).toEqual(null); expect(cluster.store.state.applications.ingress.requestStatus).toEqual(null);
cluster.installApplication('ingress'); cluster.installApplication({ id: 'ingress' });
expect(cluster.store.state.applications.ingress.requestStatus).toEqual(REQUEST_LOADING); expect(cluster.store.state.applications.ingress.requestStatus).toEqual(REQUEST_LOADING);
expect(cluster.store.state.applications.ingress.requestReason).toEqual(null); expect(cluster.store.state.applications.ingress.requestReason).toEqual(null);
expect(cluster.service.installApplication).toHaveBeenCalledWith('ingress'); expect(cluster.service.installApplication).toHaveBeenCalledWith('ingress', undefined);
getSetTimeoutPromise() getSetTimeoutPromise()
.then(() => { .then(() => {
...@@ -245,11 +245,11 @@ describe('Clusters', () => { ...@@ -245,11 +245,11 @@ describe('Clusters', () => {
spyOn(cluster.service, 'installApplication').and.returnValue(Promise.resolve()); spyOn(cluster.service, 'installApplication').and.returnValue(Promise.resolve());
expect(cluster.store.state.applications.runner.requestStatus).toEqual(null); expect(cluster.store.state.applications.runner.requestStatus).toEqual(null);
cluster.installApplication('runner'); cluster.installApplication({ id: 'runner' });
expect(cluster.store.state.applications.runner.requestStatus).toEqual(REQUEST_LOADING); expect(cluster.store.state.applications.runner.requestStatus).toEqual(REQUEST_LOADING);
expect(cluster.store.state.applications.runner.requestReason).toEqual(null); expect(cluster.store.state.applications.runner.requestReason).toEqual(null);
expect(cluster.service.installApplication).toHaveBeenCalledWith('runner'); expect(cluster.service.installApplication).toHaveBeenCalledWith('runner', undefined);
getSetTimeoutPromise() getSetTimeoutPromise()
.then(() => { .then(() => {
...@@ -260,11 +260,29 @@ describe('Clusters', () => { ...@@ -260,11 +260,29 @@ describe('Clusters', () => {
.catch(done.fail); .catch(done.fail);
}); });
it('tries to install jupyter', (done) => {
spyOn(cluster.service, 'installApplication').and.returnValue(Promise.resolve());
expect(cluster.store.state.applications.jupyter.requestStatus).toEqual(null);
cluster.installApplication({ id: 'jupyter', params: { hostname: cluster.store.state.applications.jupyter.hostname } });
expect(cluster.store.state.applications.jupyter.requestStatus).toEqual(REQUEST_LOADING);
expect(cluster.store.state.applications.jupyter.requestReason).toEqual(null);
expect(cluster.service.installApplication).toHaveBeenCalledWith('jupyter', { hostname: cluster.store.state.applications.jupyter.hostname });
getSetTimeoutPromise()
.then(() => {
expect(cluster.store.state.applications.jupyter.requestStatus).toEqual(REQUEST_SUCCESS);
expect(cluster.store.state.applications.jupyter.requestReason).toEqual(null);
})
.then(done)
.catch(done.fail);
});
it('sets error request status when the request fails', (done) => { it('sets error request status when the request fails', (done) => {
spyOn(cluster.service, 'installApplication').and.returnValue(Promise.reject(new Error('STUBBED ERROR'))); spyOn(cluster.service, 'installApplication').and.returnValue(Promise.reject(new Error('STUBBED ERROR')));
expect(cluster.store.state.applications.helm.requestStatus).toEqual(null); expect(cluster.store.state.applications.helm.requestStatus).toEqual(null);
cluster.installApplication('helm'); cluster.installApplication({ id: 'helm' });
expect(cluster.store.state.applications.helm.requestStatus).toEqual(REQUEST_LOADING); expect(cluster.store.state.applications.helm.requestStatus).toEqual(REQUEST_LOADING);
expect(cluster.store.state.applications.helm.requestReason).toEqual(null); expect(cluster.store.state.applications.helm.requestReason).toEqual(null);
......
...@@ -174,7 +174,27 @@ describe('Application Row', () => { ...@@ -174,7 +174,27 @@ describe('Application Row', () => {
installButton.click(); installButton.click();
expect(eventHub.$emit).toHaveBeenCalledWith('installApplication', DEFAULT_APPLICATION_STATE.id); expect(eventHub.$emit).toHaveBeenCalledWith('installApplication', {
id: DEFAULT_APPLICATION_STATE.id,
params: {},
});
});
it('clicking install button when installApplicationRequestParams are provided emits event', () => {
spyOn(eventHub, '$emit');
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
status: APPLICATION_INSTALLABLE,
installApplicationRequestParams: { hostname: 'jupyter' },
});
const installButton = vm.$el.querySelector('.js-cluster-application-install-button');
installButton.click();
expect(eventHub.$emit).toHaveBeenCalledWith('installApplication', {
id: DEFAULT_APPLICATION_STATE.id,
params: { hostname: 'jupyter' },
});
}); });
it('clicking disabled install button emits nothing', () => { it('clicking disabled install button emits nothing', () => {
......
...@@ -22,6 +22,7 @@ describe('Applications', () => { ...@@ -22,6 +22,7 @@ describe('Applications', () => {
ingress: { title: 'Ingress' }, ingress: { title: 'Ingress' },
runner: { title: 'GitLab Runner' }, runner: { title: 'GitLab Runner' },
prometheus: { title: 'Prometheus' }, prometheus: { title: 'Prometheus' },
jupyter: { title: 'JupyterHub' },
}, },
}); });
}); });
...@@ -41,6 +42,10 @@ describe('Applications', () => { ...@@ -41,6 +42,10 @@ describe('Applications', () => {
it('renders a row for GitLab Runner', () => { it('renders a row for GitLab Runner', () => {
expect(vm.$el.querySelector('.js-cluster-application-row-runner')).toBeDefined(); expect(vm.$el.querySelector('.js-cluster-application-row-runner')).toBeDefined();
}); });
it('renders a row for Jupyter', () => {
expect(vm.$el.querySelector('.js-cluster-application-row-jupyter')).not.toBe(null);
});
}); });
describe('Ingress application', () => { describe('Ingress application', () => {
...@@ -57,12 +62,11 @@ describe('Applications', () => { ...@@ -57,12 +62,11 @@ describe('Applications', () => {
helm: { title: 'Helm Tiller' }, helm: { title: 'Helm Tiller' },
runner: { title: 'GitLab Runner' }, runner: { title: 'GitLab Runner' },
prometheus: { title: 'Prometheus' }, prometheus: { title: 'Prometheus' },
jupyter: { title: 'JupyterHub', hostname: '' },
}, },
}); });
expect( expect(vm.$el.querySelector('.js-ip-address').value).toEqual('0.0.0.0');
vm.$el.querySelector('.js-ip-address').value,
).toEqual('0.0.0.0');
expect( expect(
vm.$el.querySelector('.js-clipboard-btn').getAttribute('data-clipboard-text'), vm.$el.querySelector('.js-clipboard-btn').getAttribute('data-clipboard-text'),
...@@ -81,12 +85,11 @@ describe('Applications', () => { ...@@ -81,12 +85,11 @@ describe('Applications', () => {
helm: { title: 'Helm Tiller' }, helm: { title: 'Helm Tiller' },
runner: { title: 'GitLab Runner' }, runner: { title: 'GitLab Runner' },
prometheus: { title: 'Prometheus' }, prometheus: { title: 'Prometheus' },
jupyter: { title: 'JupyterHub', hostname: '' },
}, },
}); });
expect( expect(vm.$el.querySelector('.js-ip-address').value).toEqual('?');
vm.$el.querySelector('.js-ip-address').value,
).toEqual('?');
expect(vm.$el.querySelector('.js-no-ip-message')).not.toBe(null); expect(vm.$el.querySelector('.js-no-ip-message')).not.toBe(null);
}); });
...@@ -101,6 +104,7 @@ describe('Applications', () => { ...@@ -101,6 +104,7 @@ describe('Applications', () => {
ingress: { title: 'Ingress' }, ingress: { title: 'Ingress' },
runner: { title: 'GitLab Runner' }, runner: { title: 'GitLab Runner' },
prometheus: { title: 'Prometheus' }, prometheus: { title: 'Prometheus' },
jupyter: { title: 'JupyterHub', hostname: '' },
}, },
}); });
...@@ -108,5 +112,83 @@ describe('Applications', () => { ...@@ -108,5 +112,83 @@ describe('Applications', () => {
expect(vm.$el.querySelector('.js-ip-address')).toBe(null); expect(vm.$el.querySelector('.js-ip-address')).toBe(null);
}); });
}); });
describe('Jupyter application', () => {
describe('with ingress installed with ip & jupyter installable', () => {
it('renders hostname active input', () => {
vm = mountComponent(Applications, {
applications: {
helm: { title: 'Helm Tiller', status: 'installed' },
ingress: { title: 'Ingress', status: 'installed', externalIp: '1.1.1.1' },
runner: { title: 'GitLab Runner' },
prometheus: { title: 'Prometheus' },
jupyter: { title: 'JupyterHub', hostname: '', status: 'installable' },
},
});
expect(vm.$el.querySelector('.js-hostname').getAttribute('readonly')).toEqual(null);
});
});
describe('with ingress installed without external ip', () => {
it('does not render hostname input', () => {
vm = mountComponent(Applications, {
applications: {
helm: { title: 'Helm Tiller', status: 'installed' },
ingress: { title: 'Ingress', status: 'installed' },
runner: { title: 'GitLab Runner' },
prometheus: { title: 'Prometheus' },
jupyter: { title: 'JupyterHub', hostname: '', status: 'installable' },
},
});
expect(vm.$el.querySelector('.js-hostname')).toBe(null);
});
});
describe('with ingress & jupyter installed', () => {
it('renders readonly input', () => {
vm = mountComponent(Applications, {
applications: {
helm: { title: 'Helm Tiller', status: 'installed' },
ingress: { title: 'Ingress', status: 'installed', externalIp: '1.1.1.1' },
runner: { title: 'GitLab Runner' },
prometheus: { title: 'Prometheus' },
jupyter: { title: 'JupyterHub', status: 'installed', hostname: '' },
},
});
expect(vm.$el.querySelector('.js-hostname').getAttribute('readonly')).toEqual('readonly');
});
});
describe('without ingress installed', () => {
beforeEach(() => {
vm = mountComponent(Applications, {
applications: {
helm: { title: 'Helm Tiller' },
ingress: { title: 'Ingress' },
runner: { title: 'GitLab Runner' },
prometheus: { title: 'Prometheus' },
jupyter: { title: 'JupyterHub', status: 'not_installable' },
},
});
});
it('does not render input', () => {
expect(vm.$el.querySelector('.js-hostname')).toBe(null);
});
it('renders disabled install button', () => {
expect(
vm.$el
.querySelector(
'.js-cluster-application-row-jupyter .js-cluster-application-install-button',
)
.getAttribute('disabled'),
).toEqual('disabled');
});
});
});
}); });
}); });
import { import {
APPLICATION_INSTALLED,
APPLICATION_INSTALLABLE, APPLICATION_INSTALLABLE,
APPLICATION_INSTALLING, APPLICATION_INSTALLING,
APPLICATION_ERROR, APPLICATION_ERROR,
...@@ -28,6 +29,39 @@ const CLUSTERS_MOCK_DATA = { ...@@ -28,6 +29,39 @@ const CLUSTERS_MOCK_DATA = {
name: 'prometheus', name: 'prometheus',
status: APPLICATION_ERROR, status: APPLICATION_ERROR,
status_reason: 'Cannot connect', status_reason: 'Cannot connect',
}, {
name: 'jupyter',
status: APPLICATION_INSTALLING,
status_reason: 'Cannot connect',
}],
},
},
'/gitlab-org/gitlab-shell/clusters/2/status.json': {
data: {
status: 'errored',
status_reason: 'Failed to request to CloudPlatform.',
applications: [{
name: 'helm',
status: APPLICATION_INSTALLED,
status_reason: null,
}, {
name: 'ingress',
status: APPLICATION_INSTALLED,
status_reason: 'Cannot connect',
external_ip: '1.1.1.1',
}, {
name: 'runner',
status: APPLICATION_INSTALLING,
status_reason: null,
},
{
name: 'prometheus',
status: APPLICATION_ERROR,
status_reason: 'Cannot connect',
}, {
name: 'jupyter',
status: APPLICATION_INSTALLABLE,
status_reason: 'Cannot connect',
}], }],
}, },
}, },
...@@ -37,6 +71,7 @@ const CLUSTERS_MOCK_DATA = { ...@@ -37,6 +71,7 @@ const CLUSTERS_MOCK_DATA = {
'/gitlab-org/gitlab-shell/clusters/1/applications/ingress': { }, '/gitlab-org/gitlab-shell/clusters/1/applications/ingress': { },
'/gitlab-org/gitlab-shell/clusters/1/applications/runner': { }, '/gitlab-org/gitlab-shell/clusters/1/applications/runner': { },
'/gitlab-org/gitlab-shell/clusters/1/applications/prometheus': { }, '/gitlab-org/gitlab-shell/clusters/1/applications/prometheus': { },
'/gitlab-org/gitlab-shell/clusters/1/applications/jupyter': { },
}, },
}; };
......
...@@ -91,8 +91,26 @@ describe('Clusters Store', () => { ...@@ -91,8 +91,26 @@ describe('Clusters Store', () => {
requestStatus: null, requestStatus: null,
requestReason: null, requestReason: null,
}, },
jupyter: {
title: 'JupyterHub',
status: mockResponseData.applications[4].status,
statusReason: mockResponseData.applications[4].status_reason,
requestStatus: null,
requestReason: null,
hostname: '',
}, },
},
});
}); });
it('sets default hostname for jupyter when ingress has a ip address', () => {
const mockResponseData = CLUSTERS_MOCK_DATA.GET['/gitlab-org/gitlab-shell/clusters/2/status.json'].data;
store.updateStateFromServer(mockResponseData);
expect(
store.state.applications.jupyter.hostname,
).toEqual(`jupyter.${store.state.applications.ingress.externalIp}.xip.io`);
}); });
}); });
}); });
require 'rails_helper'
describe Clusters::Applications::Jupyter do
include_examples 'cluster application core specs', :clusters_applications_jupyter
it { is_expected.to belong_to(:oauth_application) }
describe '#set_initial_status' do
before do
jupyter.set_initial_status
end
context 'when ingress is not installed' do
let(:cluster) { create(:cluster, :provided_by_gcp) }
let(:jupyter) { create(:clusters_applications_jupyter, cluster: cluster) }
it { expect(jupyter).to be_not_installable }
end
context 'when ingress is installed and external_ip is assigned' do
let(:ingress) { create(:clusters_applications_ingress, :installed, external_ip: '127.0.0.1') }
let(:jupyter) { create(:clusters_applications_jupyter, cluster: ingress.cluster) }
it { expect(jupyter).to be_installable }
end
end
describe '#install_command' do
let!(:ingress) { create(:clusters_applications_ingress, :installed, external_ip: '127.0.0.1') }
let!(:jupyter) { create(:clusters_applications_jupyter, cluster: ingress.cluster) }
subject { jupyter.install_command }
it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::InstallCommand) }
it 'should be initialized with 4 arguments' do
expect(subject.name).to eq('jupyter')
expect(subject.chart).to eq('jupyter/jupyterhub')
expect(subject.repository).to eq('https://jupyterhub.github.io/helm-chart/')
expect(subject.values).to eq(jupyter.values)
end
end
describe '#values' do
let(:jupyter) { create(:clusters_applications_jupyter) }
subject { jupyter.values }
it 'should include valid values' do
is_expected.to include('ingress')
is_expected.to include('hub')
is_expected.to include('rbac')
is_expected.to include('proxy')
is_expected.to include('auth')
is_expected.to include("clientId: #{jupyter.oauth_application.uid}")
is_expected.to include("callbackUrl: #{jupyter.callback_url}")
end
end
end
...@@ -234,9 +234,10 @@ describe Clusters::Cluster do ...@@ -234,9 +234,10 @@ describe Clusters::Cluster do
let!(:ingress) { create(:clusters_applications_ingress, cluster: cluster) } let!(:ingress) { create(:clusters_applications_ingress, cluster: cluster) }
let!(:prometheus) { create(:clusters_applications_prometheus, cluster: cluster) } let!(:prometheus) { create(:clusters_applications_prometheus, cluster: cluster) }
let!(:runner) { create(:clusters_applications_runner, cluster: cluster) } let!(:runner) { create(:clusters_applications_runner, cluster: cluster) }
let!(:jupyter) { create(:clusters_applications_jupyter, cluster: cluster) }
it 'returns a list of created applications' do it 'returns a list of created applications' do
is_expected.to contain_exactly(helm, ingress, prometheus, runner) is_expected.to contain_exactly(helm, ingress, prometheus, runner, jupyter)
end end
end end
end end
......
rbac:
enabled: false
hub:
extraEnv:
JUPYTER_ENABLE_LAB: 1
extraConfig: |
c.KubeSpawner.cmd = ['jupyter-labhub']
auth:
type: gitlab
singleuser:
defaultUrl: "/lab"
ingress:
enabled: true
annotations:
kubernetes.io/ingress.class: "nginx"
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