diff options
author | Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com> | 2018-06-01 07:58:18 +0000 |
---|---|---|
committer | Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com> | 2018-06-01 07:58:18 +0000 |
commit | d68ded2122f899bc0f478253652c4a082c98ec60 (patch) | |
tree | 945c0cd65b78b25c2302ff7eed254367a3f5ecf9 | |
parent | 83510980497ca72eb43fa9540be7abcbb6e811fc (diff) | |
parent | 69e9e957318f8c803461dc3bec7fc04d3ad50a72 (diff) | |
download | gitlab-ce-d68ded2122f899bc0f478253652c4a082c98ec60.tar.gz |
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
27 files changed, 535 insertions, 29 deletions
diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index 01aec4f36af..e42a3632e79 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -31,6 +31,7 @@ export default class Clusters { installHelmPath, installIngressPath, installRunnerPath, + installJupyterPath, installPrometheusPath, managePrometheusPath, clusterStatus, @@ -51,6 +52,7 @@ export default class Clusters { installIngressEndpoint: installIngressPath, installRunnerEndpoint: installRunnerPath, installPrometheusEndpoint: installPrometheusPath, + installJupyterEndpoint: installJupyterPath, }); this.installApplication = this.installApplication.bind(this); @@ -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, 'requestReason', null); - this.service.installApplication(appId) + this.service.installApplication(appId, data.params) .then(() => { this.store.updateAppProperty(appId, 'requestStatus', REQUEST_SUCCESS); }) diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue index fae580c091b..30567993322 100644 --- a/app/assets/javascripts/clusters/components/application_row.vue +++ b/app/assets/javascripts/clusters/components/application_row.vue @@ -52,6 +52,11 @@ type: String, required: false, }, + installApplicationRequestParams: { + type: Object, + required: false, + default: () => ({}), + }, }, computed: { rowJsClass() { @@ -109,7 +114,10 @@ }, methods: { installClicked() { - eventHub.$emit('installApplication', this.id); + eventHub.$emit('installApplication', { + id: this.id, + params: this.installApplicationRequestParams, + }); }, }, }; diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue index bb5fcea648d..9d6be555a2c 100644 --- a/app/assets/javascripts/clusters/components/applications.vue +++ b/app/assets/javascripts/clusters/components/applications.vue @@ -121,6 +121,12 @@ export default { false, ); }, + jupyterInstalled() { + return this.applications.jupyter.status === APPLICATION_INSTALLED; + }, + jupyterHostname() { + return this.applications.jupyter.hostname; + }, }, }; </script> @@ -278,11 +284,67 @@ export default { applications to production.`) }} </div> </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` min-height for this block and uncomment `application_spec` tests --> - <!-- Add GitLab Runner row, all other plumbing is complete --> </div> </div> </section> diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js index b7179f52bb3..371f71fde44 100644 --- a/app/assets/javascripts/clusters/constants.js +++ b/app/assets/javascripts/clusters/constants.js @@ -11,3 +11,4 @@ export const REQUEST_LOADING = 'request-loading'; export const REQUEST_SUCCESS = 'request-success'; export const REQUEST_FAILURE = 'request-failure'; export const INGRESS = 'ingress'; +export const JUPYTER = 'jupyter'; diff --git a/app/assets/javascripts/clusters/services/clusters_service.js b/app/assets/javascripts/clusters/services/clusters_service.js index 13468578f4f..a7d82292ba9 100644 --- a/app/assets/javascripts/clusters/services/clusters_service.js +++ b/app/assets/javascripts/clusters/services/clusters_service.js @@ -8,6 +8,7 @@ export default class ClusterService { ingress: this.options.installIngressEndpoint, runner: this.options.installRunnerEndpoint, prometheus: this.options.installPrometheusEndpoint, + jupyter: this.options.installJupyterEndpoint, }; } @@ -15,8 +16,8 @@ export default class ClusterService { return axios.get(this.options.endpoint); } - installApplication(appId) { - return axios.post(this.appInstallEndpointMap[appId]); + installApplication(appId, params) { + return axios.post(this.appInstallEndpointMap[appId], params); } static updateCluster(endpoint, data) { diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js index 348bbec3b25..3a4ac09f67c 100644 --- a/app/assets/javascripts/clusters/stores/clusters_store.js +++ b/app/assets/javascripts/clusters/stores/clusters_store.js @@ -1,5 +1,5 @@ import { s__ } from '../../locale'; -import { INGRESS } from '../constants'; +import { INGRESS, JUPYTER } from '../constants'; export default class ClusterStore { constructor() { @@ -38,6 +38,14 @@ export default class ClusterStore { requestStatus: 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 { if (appId === INGRESS) { 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` + : ''); } }); } diff --git a/app/assets/stylesheets/pages/clusters.scss b/app/assets/stylesheets/pages/clusters.scss index 2b6d92016d5..3e4d123242c 100644 --- a/app/assets/stylesheets/pages/clusters.scss +++ b/app/assets/stylesheets/pages/clusters.scss @@ -6,7 +6,7 @@ .cluster-applications-table { // Wait for the Vue to kick-in and render the applications block - min-height: 400px; + min-height: 628px; } .clusters-dropdown-menu { diff --git a/app/controllers/projects/clusters/applications_controller.rb b/app/controllers/projects/clusters/applications_controller.rb index 35885543622..4d758402850 100644 --- a/app/controllers/projects/clusters/applications_controller.rb +++ b/app/controllers/projects/clusters/applications_controller.rb @@ -5,7 +5,17 @@ class Projects::Clusters::ApplicationsController < Projects::ApplicationControll before_action :authorize_create_cluster!, only: [: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) @@ -23,4 +33,15 @@ class Projects::Clusters::ApplicationsController < Projects::ApplicationControll def application_class @application_class ||= Clusters::Cluster::APPLICATIONS[params[:application]] || render_404 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 diff --git a/app/models/clusters/applications/jupyter.rb b/app/models/clusters/applications/jupyter.rb new file mode 100644 index 00000000000..975d434e1a4 --- /dev/null +++ b/app/models/clusters/applications/jupyter.rb @@ -0,0 +1,92 @@ +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 diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index 77947d515c1..b426b1bf8a1 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -8,7 +8,8 @@ module Clusters Applications::Helm.application_name => Applications::Helm, Applications::Ingress.application_name => Applications::Ingress, 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 DEFAULT_ENVIRONMENT = '*'.freeze @@ -26,6 +27,7 @@ module Clusters has_one :application_ingress, class_name: 'Clusters::Applications::Ingress' has_one :application_prometheus, class_name: 'Clusters::Applications::Prometheus' 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 :platform_kubernetes, update_only: true @@ -39,6 +41,7 @@ module Clusters delegate :active?, to: :platform_kubernetes, 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: { kubernetes: 1 @@ -74,7 +77,8 @@ module Clusters application_helm || build_application_helm, application_ingress || build_application_ingress, application_prometheus || build_application_prometheus, - application_runner || build_application_runner + application_runner || build_application_runner, + application_jupyter || build_application_jupyter ] end diff --git a/app/serializers/cluster_application_entity.rb b/app/serializers/cluster_application_entity.rb index b22a0b666ef..77fc3336521 100644 --- a/app/serializers/cluster_application_entity.rb +++ b/app/serializers/cluster_application_entity.rb @@ -3,4 +3,5 @@ class ClusterApplicationEntity < Grape::Entity expose :status_name, as: :status expose :status_reason expose :external_ip, if: -> (e, _) { e.respond_to?(:external_ip) } + expose :hostname, if: -> (e, _) { e.respond_to?(:hostname) } end diff --git a/app/services/clusters/applications/install_service.rb b/app/services/clusters/applications/install_service.rb index 4c25a09814b..7ec3a9baa6e 100644 --- a/app/services/clusters/applications/install_service.rb +++ b/app/services/clusters/applications/install_service.rb @@ -12,8 +12,8 @@ module Clusters ClusterWaitForAppInstallationWorker::INTERVAL, app.name, app.id) rescue Kubeclient::HttpError => ke app.make_errored!("Kubernetes error: #{ke.message}") - rescue StandardError - app.make_errored!("Can't start installation process") + rescue StandardError => e + app.make_errored!("Can't start installation process. #{e.message}") end end end diff --git a/app/views/projects/clusters/show.html.haml b/app/views/projects/clusters/show.html.haml index 4c510293204..08d2deff6f8 100644 --- a/app/views/projects/clusters/show.html.haml +++ b/app/views/projects/clusters/show.html.haml @@ -11,6 +11,7 @@ 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_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', cluster_status: @cluster.status_name, cluster_status_reason: @cluster.status_reason, diff --git a/changelogs/unreleased/46487-add-support-for-jupyter-in-gitlab-via-kubernetes.yml b/changelogs/unreleased/46487-add-support-for-jupyter-in-gitlab-via-kubernetes.yml new file mode 100644 index 00000000000..782ffd9a928 --- /dev/null +++ b/changelogs/unreleased/46487-add-support-for-jupyter-in-gitlab-via-kubernetes.yml @@ -0,0 +1,5 @@ +--- +title: Adds JupyterHub to cluster applications +merge_request: 19019 +author: +type: added diff --git a/db/migrate/20180511131058_create_clusters_applications_jupyter.rb b/db/migrate/20180511131058_create_clusters_applications_jupyter.rb new file mode 100644 index 00000000000..f3923884e37 --- /dev/null +++ b/db/migrate/20180511131058_create_clusters_applications_jupyter.rb @@ -0,0 +1,23 @@ +# 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 diff --git a/db/schema.rb b/db/schema.rb index f8663574580..97247387bc7 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -635,6 +635,17 @@ ActiveRecord::Schema.define(version: 20180529093006) do t.string "external_ip" 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| t.integer "cluster_id", null: false t.integer "status", null: false @@ -2196,6 +2207,8 @@ ActiveRecord::Schema.define(version: 20180529093006) do add_foreign_key "clusters", "users", on_delete: :nullify 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_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_runners", "ci_runners", column: "runner_id", name: "fk_02de2ded36", on_delete: :nullify add_foreign_key "clusters_applications_runners", "clusters", on_delete: :cascade diff --git a/doc/user/project/clusters/index.md b/doc/user/project/clusters/index.md index edb875bc7e6..65cdece8d3d 100644 --- a/doc/user/project/clusters/index.md +++ b/doc/user/project/clusters/index.md @@ -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. | | [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. | +| [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 diff --git a/spec/factories/clusters/applications/helm.rb b/spec/factories/clusters/applications/helm.rb index 3deca103578..3e4277e4ba6 100644 --- a/spec/factories/clusters/applications/helm.rb +++ b/spec/factories/clusters/applications/helm.rb @@ -35,5 +35,8 @@ FactoryBot.define do factory :clusters_applications_ingress, class: Clusters::Applications::Ingress factory :clusters_applications_prometheus, class: Clusters::Applications::Prometheus 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 diff --git a/spec/fixtures/api/schemas/cluster_status.json b/spec/fixtures/api/schemas/cluster_status.json index d27c12e43f2..ccef17a6615 100644 --- a/spec/fixtures/api/schemas/cluster_status.json +++ b/spec/fixtures/api/schemas/cluster_status.json @@ -31,7 +31,8 @@ } }, "status_reason": { "type": ["string", "null"] }, - "external_ip": { "type": ["string", "null"] } + "external_ip": { "type": ["string", "null"] }, + "hostname": { "type": ["string", "null"] } }, "required" : [ "name", "status" ] } diff --git a/spec/javascripts/clusters/clusters_bundle_spec.js b/spec/javascripts/clusters/clusters_bundle_spec.js index a5cd247b689..abe2954d506 100644 --- a/spec/javascripts/clusters/clusters_bundle_spec.js +++ b/spec/javascripts/clusters/clusters_bundle_spec.js @@ -207,11 +207,11 @@ describe('Clusters', () => { spyOn(cluster.service, 'installApplication').and.returnValue(Promise.resolve()); 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.requestReason).toEqual(null); - expect(cluster.service.installApplication).toHaveBeenCalledWith('helm'); + expect(cluster.service.installApplication).toHaveBeenCalledWith('helm', undefined); getSetTimeoutPromise() .then(() => { @@ -226,11 +226,11 @@ describe('Clusters', () => { spyOn(cluster.service, 'installApplication').and.returnValue(Promise.resolve()); 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.requestReason).toEqual(null); - expect(cluster.service.installApplication).toHaveBeenCalledWith('ingress'); + expect(cluster.service.installApplication).toHaveBeenCalledWith('ingress', undefined); getSetTimeoutPromise() .then(() => { @@ -245,11 +245,11 @@ describe('Clusters', () => { spyOn(cluster.service, 'installApplication').and.returnValue(Promise.resolve()); 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.requestReason).toEqual(null); - expect(cluster.service.installApplication).toHaveBeenCalledWith('runner'); + expect(cluster.service.installApplication).toHaveBeenCalledWith('runner', undefined); getSetTimeoutPromise() .then(() => { @@ -260,11 +260,29 @@ describe('Clusters', () => { .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) => { spyOn(cluster.service, 'installApplication').and.returnValue(Promise.reject(new Error('STUBBED ERROR'))); 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.requestReason).toEqual(null); diff --git a/spec/javascripts/clusters/components/application_row_spec.js b/spec/javascripts/clusters/components/application_row_spec.js index 2c4707bb856..c83cbe90a57 100644 --- a/spec/javascripts/clusters/components/application_row_spec.js +++ b/spec/javascripts/clusters/components/application_row_spec.js @@ -174,7 +174,27 @@ describe('Application Row', () => { 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', () => { diff --git a/spec/javascripts/clusters/components/applications_spec.js b/spec/javascripts/clusters/components/applications_spec.js index d546543d273..a70138c7eee 100644 --- a/spec/javascripts/clusters/components/applications_spec.js +++ b/spec/javascripts/clusters/components/applications_spec.js @@ -22,6 +22,7 @@ describe('Applications', () => { ingress: { title: 'Ingress' }, runner: { title: 'GitLab Runner' }, prometheus: { title: 'Prometheus' }, + jupyter: { title: 'JupyterHub' }, }, }); }); @@ -41,6 +42,10 @@ describe('Applications', () => { it('renders a row for GitLab Runner', () => { 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', () => { @@ -57,12 +62,11 @@ describe('Applications', () => { helm: { title: 'Helm Tiller' }, runner: { title: 'GitLab Runner' }, prometheus: { title: 'Prometheus' }, + jupyter: { title: 'JupyterHub', hostname: '' }, }, }); - expect( - vm.$el.querySelector('.js-ip-address').value, - ).toEqual('0.0.0.0'); + expect(vm.$el.querySelector('.js-ip-address').value).toEqual('0.0.0.0'); expect( vm.$el.querySelector('.js-clipboard-btn').getAttribute('data-clipboard-text'), @@ -81,12 +85,11 @@ describe('Applications', () => { helm: { title: 'Helm Tiller' }, runner: { title: 'GitLab Runner' }, prometheus: { title: 'Prometheus' }, + jupyter: { title: 'JupyterHub', hostname: '' }, }, }); - expect( - vm.$el.querySelector('.js-ip-address').value, - ).toEqual('?'); + expect(vm.$el.querySelector('.js-ip-address').value).toEqual('?'); expect(vm.$el.querySelector('.js-no-ip-message')).not.toBe(null); }); @@ -101,6 +104,7 @@ describe('Applications', () => { ingress: { title: 'Ingress' }, runner: { title: 'GitLab Runner' }, prometheus: { title: 'Prometheus' }, + jupyter: { title: 'JupyterHub', hostname: '' }, }, }); @@ -108,5 +112,83 @@ describe('Applications', () => { 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'); + }); + }); + }); }); }); diff --git a/spec/javascripts/clusters/services/mock_data.js b/spec/javascripts/clusters/services/mock_data.js index 6ae7a792329..b2b0ebf840b 100644 --- a/spec/javascripts/clusters/services/mock_data.js +++ b/spec/javascripts/clusters/services/mock_data.js @@ -1,4 +1,5 @@ import { + APPLICATION_INSTALLED, APPLICATION_INSTALLABLE, APPLICATION_INSTALLING, APPLICATION_ERROR, @@ -28,6 +29,39 @@ const CLUSTERS_MOCK_DATA = { name: 'prometheus', status: APPLICATION_ERROR, 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 = { '/gitlab-org/gitlab-shell/clusters/1/applications/ingress': { }, '/gitlab-org/gitlab-shell/clusters/1/applications/runner': { }, '/gitlab-org/gitlab-shell/clusters/1/applications/prometheus': { }, + '/gitlab-org/gitlab-shell/clusters/1/applications/jupyter': { }, }, }; diff --git a/spec/javascripts/clusters/stores/clusters_store_spec.js b/spec/javascripts/clusters/stores/clusters_store_spec.js index 8028faf2f02..6854b016852 100644 --- a/spec/javascripts/clusters/stores/clusters_store_spec.js +++ b/spec/javascripts/clusters/stores/clusters_store_spec.js @@ -91,8 +91,26 @@ describe('Clusters Store', () => { requestStatus: 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`); + }); }); }); diff --git a/spec/models/clusters/applications/jupyter_spec.rb b/spec/models/clusters/applications/jupyter_spec.rb new file mode 100644 index 00000000000..ca48a1d8072 --- /dev/null +++ b/spec/models/clusters/applications/jupyter_spec.rb @@ -0,0 +1,59 @@ +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 diff --git a/spec/models/clusters/cluster_spec.rb b/spec/models/clusters/cluster_spec.rb index b942554d67b..6f66515b45f 100644 --- a/spec/models/clusters/cluster_spec.rb +++ b/spec/models/clusters/cluster_spec.rb @@ -234,9 +234,10 @@ describe Clusters::Cluster do let!(:ingress) { create(:clusters_applications_ingress, cluster: cluster) } let!(:prometheus) { create(:clusters_applications_prometheus, 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 - is_expected.to contain_exactly(helm, ingress, prometheus, runner) + is_expected.to contain_exactly(helm, ingress, prometheus, runner, jupyter) end end end diff --git a/vendor/jupyter/values.yaml b/vendor/jupyter/values.yaml new file mode 100644 index 00000000000..90817de0f1b --- /dev/null +++ b/vendor/jupyter/values.yaml @@ -0,0 +1,19 @@ +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" |