From 805808854bca4ca7288b69a02c2a878a3c70bba1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franti=C5=A1ek=20Dvo=C5=99=C3=A1k?= <valtri@civ.zcu.cz> Date: Mon, 29 Jan 2024 17:52:08 +0000 Subject: [PATCH] Experiments with JupyterHub - central hub using gateways and full fledged hub (WIP) --- cesnet-central/deployments/central.yaml | 102 ++++++++ cesnet-central/deployments/fullhub.yaml | 321 ++++++++++++++++++++++++ cesnet-central/playbooks/notebooks.yaml | 98 ++++++++ 3 files changed, 521 insertions(+) create mode 100644 cesnet-central/deployments/central.yaml create mode 100644 cesnet-central/deployments/fullhub.yaml create mode 100644 cesnet-central/playbooks/notebooks.yaml diff --git a/cesnet-central/deployments/central.yaml b/cesnet-central/deployments/central.yaml new file mode 100644 index 0000000..f801479 --- /dev/null +++ b/cesnet-central/deployments/central.yaml @@ -0,0 +1,102 @@ +--- +proxy: + service: + type: NodePort + +ingress: + enabled: true + annotations: + kubernetes.io/ingress.class: "nginx" + kubernetes.io/tls-acme: "true" + hosts: + - eosc.zcu.cz + tls: + - hosts: + - eosc.zcu.cz + secretName: acme-tls-central + +singleuser: + # storage: + # capacity: 20Gi + # dynamic: + # pvcNameTemplate: claim-{userid}{servername} + # volumeNameTemplate: vol-{userid}{servername} + # storageAccessModes: ["ReadWriteMany"] + lifecycleHooks: + postStart: + exec: { "command": ["/bin/sh", "-c", "mkdir -p /home/jovyan/.notebookCheckpoints"] } + memory: + limit: 6G + guarantee: 128M + cpu: + limit: 2 + guarantee: .02 + defaultUrl: "/lab" + image: + name: elyra/nb2kg + tag: dev + extraEnv: + # KERNEL_USERNAME: jovyan + KG_AUTH_TOKEN: "{{ gateways_token['cesnet-mcc'] }}" + KG_URL: "https://gateway-cesnet.eosc.zcu.cz" + # KG_HTTP_USER: jovyan + KG_REQUEST_TIMEOUT: 60 + +hub: + # services: + # status: + # url: "http://status-web/" + # admin: true + image: + name: valtri/hub + tag: "sha-0800b46" # jupyter-3.x + config: + Authenticator: + enable_auth_state: true + admin_users: + - 529a87e5ce04cd5ddd7161734d02df0e2199a11452430803e714cb1309cc3907@egi.eu + - 025166931789a0f57793a6092726c2ad89387a4cc167e7c63c5d85fc91021d18@egi.eu + - 7ce47695f1e7fc91a1156e672f4a47576559938cdbe840355e2429e3a05b4ff8@egi.eu + # fdvorak2 @ aai.egi.eu + - 52cc7599bd1553c9d63e34e4c90b7e84d44967490c28bb4c53fe97b0c881d677@egi.eu + # fdvorak2 @ aai-dev.egi.eu + - c481e0a85e1ae0a5a1480a63e62295ca2f9ac652244947995bd4a0210fbcb77c@egi.eu + # jhradil3 @ aai-dev.egi.eu + - 240c0594fe34ac26cffd82fd0ad85f29d9ad9dfbb46febb05ed42db0bff594d1@egi.eu + # keep in sync with: + # - cesnet/playbooks/templates/binder.yaml + # - documentation/content/en/users/dev-env/notebooks/_index.md + allowed_groups: + - urn:mace:egi.eu:group:vo.access.egi.eu:role=member#aai.egi.eu + - urn:mace:egi.eu:group:vo.notebooks.egi.eu:role=member#aai.egi.eu + - urn:mace:egi.eu:www.egi.eu:fedcloud-users:member@egi.eu + - urn:mace:egi.eu:www.egi.eu:techsolutions:member@egi.eu + # changed 2022-10 + - urn:mace:egi.eu:group:fedcloud-users#sso.egi.eu + - urn:mace:egi.eu:group:supplier-notebooks#sso.egi.eu + - urn:mace:egi.eu:group:techsolutions#sso.egi.eu + - urn:mace:egi.eu:group:notebooks-support#sso.egi.eu + auto_login: true + claim_groups_key: "eduperson_entitlement" + EGICheckinAuthenticator: + checkin_host: "{{ secret['checkin_host'] }}" + authorize_url: "https://{{ secret['checkin_host'] }}/auth/realms/egi/protocol/openid-connect/auth" + token_url: "https://{{ secret['checkin_host'] }}/auth/realms/egi/protocol/openid-connect/token" + userdata_url: "https://{{ secret['checkin_host'] }}/auth/realms/egi/protocol/openid-connect/userinfo" + client_id: "{{ secret['client_id'] }}" + client_secret: "{{ secret['client_secret'] }}" + oauth_callback_url: "https://eosc.zcu.cz/hub/oauth_callback" + scope: ["openid", "profile", "email", "offline_access", "eduperson_scoped_affiliation", "eduperson_entitlement"] + username_key: "preferred_username" # 'sub' too long for persistent volumes + JupyterHub: + admin_access: true + authenticate_prometheus: false + authenticator_class: egi_notebooks_hub.egiauthenticator.EGICheckinAuthenticator + # spawner_class + # c.B2DropSpawner.args = ["--FileCheckpoints.checkpoint_dir='/home/jovyan/.notebookCheckpoints'"] + extraConfig: + nb2kg: |- + config = '/etc/jupyter/jupyter_notebook_config.py' + c.Spawner.cmd = ['jupyter-labhub'] + templatePaths: + - /egi-notebooks-hub/templates diff --git a/cesnet-central/deployments/fullhub.yaml b/cesnet-central/deployments/fullhub.yaml new file mode 100644 index 0000000..a567d1e --- /dev/null +++ b/cesnet-central/deployments/fullhub.yaml @@ -0,0 +1,321 @@ +--- +proxy: + service: + type: NodePort + +ingress: + enabled: true + annotations: + kubernetes.io/ingress.class: "nginx" + kubernetes.io/tls-acme: "true" + hosts: + - fullhub.eosc.zcu.cz + tls: + - hosts: + - fullhub.eosc.zcu.cz + secretName: acme-tls-fullhub + +singleuser: + # keep resource limits in sync with: + # - documentation/content/en/users/dev-env/notebooks/_index.md + # - documentation/content/en/users/dev-env/notebooks/data/_index.md + # - profileList + storage: + capacity: 20Gi + dynamic: + pvcNameTemplate: claim-{userid}{servername} + volumeNameTemplate: vol-{userid}{servername} + storageAccessModes: ["ReadWriteMany"] + extraVolumes: + - name: b2drop + # sizeLimit problematic in this environment, + # not needed for remote mounts + empty_dir: + lifecycleHooks: + postStart: + exec: { "command": ["/bin/sh", "-c", "ln -snf /mnt/b2drop $HOME/b2drop; mkdir -p /home/jovyan/.notebookCheckpoints"] } + memory: + limit: 6G + guarantee: 128M + cpu: + limit: 2 + guarantee: .02 + defaultUrl: "/lab" + image: + name: valtri/single-user + tag: "jupyter-4" + profileList: + - display_name: Default EGI environment - 6 GB RAM / 2 core + description: > + The Default notebook environment includes Python, R, Julia and Octave kernels + default: true + kubespawner_override: + args: + - "--CondaKernelSpecManager.env_filter='/opt/conda$'" + +hub: + # services: + # status: + # url: "http://status-web/" + # admin: true + image: + name: valtri/hub + tag: "sha-0800b46" # jupyter-3.x + config: + Authenticator: + enable_auth_state: true + admin_users: + - 529a87e5ce04cd5ddd7161734d02df0e2199a11452430803e714cb1309cc3907@egi.eu + - 025166931789a0f57793a6092726c2ad89387a4cc167e7c63c5d85fc91021d18@egi.eu + - 7ce47695f1e7fc91a1156e672f4a47576559938cdbe840355e2429e3a05b4ff8@egi.eu + # fdvorak2 @ aai.egi.eu + - 52cc7599bd1553c9d63e34e4c90b7e84d44967490c28bb4c53fe97b0c881d677@egi.eu + # fdvorak2 @ aai-dev.egi.eu + - c481e0a85e1ae0a5a1480a63e62295ca2f9ac652244947995bd4a0210fbcb77c@egi.eu + # jhradil3 @ aai-dev.egi.eu + - 240c0594fe34ac26cffd82fd0ad85f29d9ad9dfbb46febb05ed42db0bff594d1@egi.eu + # keep in sync with: + # - cesnet/playbooks/templates/binder.yaml + # - documentation/content/en/users/dev-env/notebooks/_index.md + allowed_groups: + - urn:mace:egi.eu:group:vo.access.egi.eu:role=member#aai.egi.eu + - urn:mace:egi.eu:group:vo.notebooks.egi.eu:role=member#aai.egi.eu + - urn:mace:egi.eu:www.egi.eu:fedcloud-users:member@egi.eu + - urn:mace:egi.eu:www.egi.eu:techsolutions:member@egi.eu + # changed 2022-10 + - urn:mace:egi.eu:group:fedcloud-users#sso.egi.eu + - urn:mace:egi.eu:group:supplier-notebooks#sso.egi.eu + - urn:mace:egi.eu:group:techsolutions#sso.egi.eu + - urn:mace:egi.eu:group:notebooks-support#sso.egi.eu + auto_login: true + claim_groups_key: "eduperson_entitlement" + EGICheckinAuthenticator: + checkin_host: "{{ secret['checkin_host'] }}" + authorize_url: "https://{{ secret['checkin_host'] }}/auth/realms/egi/protocol/openid-connect/auth" + token_url: "https://{{ secret['checkin_host'] }}/auth/realms/egi/protocol/openid-connect/token" + userdata_url: "https://{{ secret['checkin_host'] }}/auth/realms/egi/protocol/openid-connect/userinfo" + client_id: "{{ secret['client_id'] }}" + client_secret: "{{ secret['client_secret'] }}" + oauth_callback_url: "https://fullhub.eosc.zcu.cz/hub/oauth_callback" + scope: ["openid", "profile", "email", "offline_access", "eduperson_scoped_affiliation", "eduperson_entitlement"] + username_key: "sub" + JupyterHub: + admin_access: true + authenticate_prometheus: false + authenticator_class: egi_notebooks_hub.egiauthenticator.EGICheckinAuthenticator + # spawner_class: (in egi-notebooks-b2drop) + extraConfig: + egi-notebooks-welcome: |- + from egi_notebooks_hub.welcome import WelcomeHandler + c.JupyterHub.default_url = "/welcome" + c.JupyterHub.extra_handlers = [(r'/welcome', WelcomeHandler)] + egi-notebooks-b2drop: |- +{% raw %} + import base64 + from jinja2 import BaseLoader + from jinja2 import Environment + from egi_notebooks_hub.onedata import OnedataSpawner + from kubernetes_asyncio.client.rest import ApiException + + class B2DropSpawner(OnedataSpawner): + async def auth_state_hook(self, spawner, auth_state): + await super().auth_state_hook(spawner, auth_state) + self.b2drop_ready = False + self.b2drop_user = "" + self.b2drop_pwd = "" + try: + secret = await self.api.read_namespaced_secret(self.token_secret_name, self.namespace) + except ApiException: + return + if secret and secret.data: + self.b2drop_user = base64.b64decode(secret.data.get("b2drop-user", "")).decode() + self.b2drop_pwd = base64.b64decode(secret.data.get("b2drop-pwd", "")).decode() + self.b2drop_ready = (self.b2drop_user and self.b2drop_pwd) + + def _render_options_form(self, profile_list): + # old:self._profile_list = self._init_profile_list(profile_list) + self._profile_list = self._get_initialized_profile_list(profile_list) + + profile_form_template = Environment(loader=BaseLoader).from_string( + self.profile_form_template + ) + return profile_form_template.render(profile_list=self._profile_list, b2drop_ready=self.b2drop_ready, b2drop_user=self.b2drop_user, b2drop_pwd=self.b2drop_pwd) + + async def pre_spawn_hook(self, spawner): + await super(B2DropSpawner, self).pre_spawn_hook(spawner) + b2drop_user = self.user_options.get("b2drop-user", "") + b2drop_pwd = self.user_options.get("b2drop-pwd", "") + b2drop_remember = self.user_options.get("b2drop-remember", None) + if not (b2drop_user and b2drop_pwd): + secret = await self.api.read_namespaced_secret(self.token_secret_name, self.namespace) + if secret and secret.data: + b2drop_user = base64.b64decode(secret.data.get("b2drop-user", "")).decode() + b2drop_pwd = base64.b64decode(secret.data.get("b2drop-pwd", "")).decode() + if b2drop_user and b2drop_pwd: + volume_mounts = [ + {"mountPath": "/b2drop:shared", "name": "b2drop"}, + ] + spawner.extra_containers.append( + { + "name": "b2drop", + "image": "eginotebooks/webdav-sidecar:sha-e5e8df2", + "env": [ + {"name": "WEBDAV_URL", "value": "https://b2drop.eudat.eu/remote.php/webdav"}, + {"name": "WEBDAV_PWD", "value": b2drop_pwd}, + {"name": "WEBDAV_USER", "value": b2drop_user}, + {"name": "MOUNT_PATH", "value": "/b2drop"}, + ], + "resources": self.sidecar_resources, + # "command": cmd, + "securityContext": { + "runAsUser": 0, + "privileged": True, + "capabilities": {"add": ["SYS_ADMIN"]}, + }, + "volumeMounts": volume_mounts, + "lifecycle": { + "preStop": { + "exec": {"command": ["umount", "-l", "/b2drop"]} + }, + }, + } + ) + if b2drop_remember: + await self._update_secret({"b2drop-user": b2drop_user, + "b2drop-pwd": b2drop_pwd}) + else: + await self._update_secret({"b2drop-user": "", "b2drop-pwd": ""}) + + def options_from_form(self, formdata): + data = super(B2DropSpawner, self)._options_from_form(formdata) + data.update({'b2drop-user': formdata.get('b2drop-user', [None])[0], + 'b2drop-remember': formdata.get('b2drop-remember', [None])[0], + 'b2drop-pwd': formdata.get('b2drop-pwd', [None])[0]}) + return data + + c.JupyterHub.spawner_class = B2DropSpawner + c.B2DropSpawner.http_timeout = 60 + c.B2DropSpawner.args = ["--FileCheckpoints.checkpoint_dir='/home/jovyan/.notebookCheckpoints'"] + c.B2DropSpawner.profile_form_template = """ + <style> + /* + .profile divs holds two div tags: one for a radio button, and one + for the profile's content. + */ + #kubespawner-profiles-list .profile { + display: flex; + flex-direction: row; + font-weight: normal; + border-bottom: 1px solid #ccc; + padding-bottom: 12px; + } + + #kubespawner-profiles-list .profile .radio { + padding: 12px; + } + + /* .option divs holds a label and a select tag */ + #kubespawner-profiles-list .profile .option { + display: flex; + flex-direction: row; + align-items: center; + padding-bottom: 12px; + } + + #kubespawner-profiles-list .profile .option label { + font-weight: normal; + margin-right: 8px; + min-width: 96px; + } + </style> + + <div class='form-group' id='kubespawner-profiles-list'> + {%- for profile in profile_list %} + {#- Wrap everything in a <label> so clicking anywhere selects the option #} + <label for='profile-item-{{ profile.slug }}' class='profile'> + <div class='radio'> + <input type='radio' name='profile' id='profile-item-{{ profile.slug }}' value='{{ profile.slug }}' {% if profile.default %}checked{% endif %} /> + </div> + <div> + <h3>{{ profile.display_name }}</h3> + + {%- if profile.description %} + <p>{{ profile.description }}</p> + {%- endif %} + + {%- if profile.profile_options %} + <div> + {%- for k, option in profile.profile_options.items() %} + <div class='option'> + <label for='profile-option-{{profile.slug}}-{{k}}'>{{option.display_name}}</label> + <select name="profile-option-{{profile.slug}}-{{k}}" class="form-control"> + {%- for k, choice in option['choices'].items() %} + <option value="{{ k }}" {% if choice.default %}selected{%endif %}>{{ choice.display_name }}</option> + {%- endfor %} + </select> + </div> + {%- endfor %} + </div> + {%- endif %} + </div> + </label> + {%- endfor %} + <div class="panel-group" id="accordion" role="tablist" aria-multiselectable="true"> + <div class="panel panel-default"> + <div class="panel-heading" role="tab" id="headingOne"> + <h4 class="panel-title"> + <a class="collabpsed" role="button" data-toggle="collapse" data-parent="#accordion" href="#collapseOne" aria-expanded="false" aria-controls="collapseOne"> + B2DROP connection + </a> + {%if b2drop_ready %}<span class="label label-success">Already configured!</span>{% endif %} + </h4> + </div> + <div id="collapseOne" class="panel-collapse collapse" role="tabpanel" aria-labelledby="headingOne"> + <div class="panel-body"> + <div class='form-group'> + <label for="b2drop-user" class="form-label">B2DROP app Username</label> + <input type="text" class="form-control" name="b2drop-user" id="b2drop-user" aria-describedby="b2drop-user-help" value="{{ b2drop_user }}"> + <div id="b2drop-user-help" class="form-text">Create new app password at <a href="https://b2drop.eudat.eu/settings/user/security">B2DROP security configuration</a></div> + </div> + <div class='form-group'> + <label for="b2drop-pwd" class="form-label">B2DROP app Password</label> + <input type="password" class="form-control" name="b2drop-pwd" id="b2drop-pwd" value="{{ b2drop_pwd }}"> + </div> + <div class='form-group'> + <input type="checkbox" id="b2drop-remember" name="b2drop-remember" {%if b2drop_ready %}checked{% endif %}> + <label class="form-check-label" for="from-check-input">Remember B2DROP credentials</label> + </div> + </div> + </div> + </div> + </div> + """ +{% endraw %} + templatePaths: + - /egi-notebooks-hub/templates + extraFiles: + login.html: + mountPath: /egi-notebooks-hub/templates/login.html + stringData: |- +{% raw %} + {% extends "egi-login.html" %} + {% block main_intro %} + <h1><img alt="Notebooks Logo" src="{{ static_url('images/egi-icon-notebooks.svg') }}" + height="100">Notebooks</h1> + <p> + Notebooks is an environment based on <a href="http://jupyter.org/">Jupyter</a> and + the <a href="https://www.egi.eu/services/cloud-compute/">EGI cloud service</a> that + offers a browser-based, scalable tool for interactive data analysis. The Notebooks + environment provides users with notebooks where they can combine text, mathematics, + computations and rich media output. + </p> + <p> + Access requires a valid <a href="https://docs.egi.eu/users/check-in/signup">EGI account</a> + and <a href="https://docs.egi.eu/users/dev-env/notebooks/#notebooks-for-researchers"> + enrolling to one of the supported VOs</a>. + </p> + <p> + Default environment provides 2 CPU cores, 6 GB RAM and 20GB of personal storage space per user + </p> + {% endblock main_intro %} +{% endraw %} diff --git a/cesnet-central/playbooks/notebooks.yaml b/cesnet-central/playbooks/notebooks.yaml new file mode 100644 index 0000000..cf25529 --- /dev/null +++ b/cesnet-central/playbooks/notebooks.yaml @@ -0,0 +1,98 @@ +--- +- name: Notebooks deployments + hosts: master + become: true + tasks: + - name: Configure helm repo + shell: |- + helm repo add jupyterhub https://jupyterhub.github.io/helm-chart/ + helm repo update + when: "'jupyterhub' not in ansible_local.helm_repos | map(attribute='name') | list" + - name: Get Secrets from Vault for notebooks + vars: + name: "{{ item | basename | splitext | first }}" + set_fact: + secrets: "{{ secrets|default({}) | combine({name: lookup('community.hashi_vault.hashi_vault', vault_mount_point + '/deployment-' + name, + token_validate=false)}) }}" + with_fileglob: + - "../deployments/*.yaml" + - name: Get Secrets from Vault for gateway + set_fact: + gateways_token: "{{ {'cesnet-mcc': lookup('community.hashi_vault.hashi_vault', vault_mount_point + '/gateway_authtoken:value', + token_validate=false)} }}" + # - name: Debug Deployments Secrets + # debug: + # msg: "{{ item.key }} = {{ item.value }}" + # loop: "{{ secrets | dict2items }}" + # - name: Debug Gateway Secrets + # debug: + # msg: "{{ item.key }} = {{ item.value }}" + # loop: "{{ gateways_token | dict2items }}" + - name: Copy config file to master + vars: + name: "{{ item | basename | splitext | first }}" + secret: "{{ secrets['central'] }}" + gateways_token: "{{ gateways_token }}" + template: + src: "{{ item }}" + dest: "/tmp/{{ item | basename }}" + mode: 0600 + with_fileglob: + - "../deployments/*.yaml" + - name: Deploy/upgrade notebook instance + vars: + name: "{{ item | basename | splitext | first }}" + version: "3.2.1" # app 4.0.2 (2023-11-27) + shell: |- + helm status --namespace {{ name }} {{ name }} + if [ $? -ne 0 ]; then + helm install --create-namespace --namespace {{ name }} \ + -f /tmp/{{ item | basename }} --version {{ version }} --timeout 2h \ + {{ name }} jupyterhub/jupyterhub + else + helm upgrade --version {{ version }} -f /tmp/{{ item | basename }} --timeout 2h \ + --namespace {{ name }} {{ name }} jupyterhub/jupyterhub + fi + environment: + KUBECONFIG: /etc/kubernetes/admin.conf + PATH: /sbin:/bin:/usr/sbin:/usr/bin:/usr/local/bin + when: true + with_fileglob: + - "../deployments/*.yaml" + + - name: Configure secrets management for the hub + vars: + name: "{{ item | basename | splitext | first }}" + shell: |- + kubectl apply -f - << EOF + --- + kind: Role + apiVersion: rbac.authorization.k8s.io/v1 + metadata: + name: hub-secrets + namespace: {{ name }} + rules: + - apiGroups: [""] # "" indicates the core API group + resources: ["secrets"] + verbs: ["get", "watch", "list", "create", "delete", "patch", "update"] + --- + kind: RoleBinding + apiVersion: rbac.authorization.k8s.io/v1 + metadata: + name: hub-secrets + namespace: {{ name }} + subjects: + - kind: ServiceAccount + name: hub + namespace: {{ name }} + roleRef: + kind: Role + name: hub-secrets + apiGroup: rbac.authorization.k8s.io + EOF + environment: + KUBECONFIG: /etc/kubernetes/admin.conf + PATH: /sbin:/bin:/usr/sbin:/usr/bin:/usr/local/bin + when: true + with_fileglob: + - "../deployments/*.yaml" -- GitLab