Skip to content
Snippets Groups Projects
fullhub.yaml 19.5 KiB
Newer Older
---
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:
  # - profileList
  storage:
      - name: cvmfs-host
        hostPath:
          path: /cvmfs
          type: Directory
      - name: b2drop
        # sizeLimit problematic in this environment,
        # not needed for remote mounts
        empty_dir:
      - name: owncloud-home
        empty_dir:
      - name: owncloud-shared
        empty_dir:
      - name: owncloud-spaces
      # - name: scratch
      #   ephemeral:
      #     volumeClaimTemplate:
      #       spec:
      #         accessModes: [ "ReadWriteOnce" ]
      #         storageClassName: local-path
      #         resources:
      #           requests:
      #             storage: "10Gi"
    extraVolumeMounts:
      - name: cvmfs-host
        mountPath: "/cvmfs:shared"
        mountPath: '/home/jovyan/b2drop:shared'
      - name: owncloud-home
        mountPath: '/home/jovyan:shared'
      - name: owncloud-shared
        mountPath: '/owncloud/Shared:shared'
      - name: owncloud-spaces
        mountPath: '/owncloud/Spaces:shared'
      # - name: scratch
      #   mountPath: '/scratch'
    guarantee: 128M
  cpu:
    limit: 2
    guarantee: .02
  defaultUrl: "/lab"
  image:
    name: eginotebooks/single-user
    tag: "sha-b94a3ef"
    - display_name: Small Environment - 2 vCPU / 4 GB RAM (non-collaboratice)
        The notebook environment includes Python, R, Julia and Octave kernels. Non-collaborative.
      default: true
      kubespawner_override:
        args:
          - "--CondaKernelSpecManager.env_filter='/opt/conda$'"
        image: "eginotebooks/single-user:sha-b94a3ef"
    - display_name: Small Environment - 2 vCPU / 4 GB RAM (collaboratice)
        The notebook environment includes Python, R, Julia and Octave kernels. Collaborative.
      kubespawner_override:
        args:
          - "--CondaKernelSpecManager.env_filter='/opt/conda$'"
        image: "valtri/single-user:jupyter-4e-collab"
  cmd: jupyterhub-singleuser-webdav-wrapper
  extraFiles:
      mountPath: /usr/local/bin/jupyterhub-wait-remote-home
      stringData: |-
        #! /bin/sh

        #
        # Dirty hack to make remote mount on home directory working properly:
        #
        # 1) wait for webdav sidecar image to kick in
        # 2) change directory to the mounted version of itself
        # 3) launch notebook server
        #
        i=0
        while ! grep '^webdav-fs: /home/jovyan ' /proc/mounts && test $i -lt 30; do
          echo 'Waiting for ownClound mount...'
          sleep 0.5
          i=$((i+1))
        done
    singleuser-webdav-wrapper.sh:
      mode: 0755
      mountPath: /usr/local/bin/jupyterhub-singleuser-webdav-wrapper
      stringData: |-
        #! /bin/sh
        /usr/local/bin/jupyterhub-wait-remote-home
  services:
    status:
      url: "http://status-web/"
      admin: true
      display: false
        # valtri@civ.zcu.cz
        - c36b18fe-e03a-4a22-ab14-5965e0171410@eosc-federation.eu
        - urn:geant:eosc-federation.eu:testing:group:eosc#testing.eosc-federation.eu
      claim_groups_key: "entitlements"
    EGICheckinAuthenticator:
      checkin_host: "{{ secret['checkin_host'] }}"
      authorize_url: "https://{{ secret['checkin_host'] }}/OIDC/authorization"
      token_url: "https://{{ secret['checkin_host'] }}/OIDC/token"
      userdata_url: "https://{{ secret['checkin_host'] }}/OIDC/userinfo"
      client_id: "{{ secret['client_id'] }}"
      client_secret: "{{ secret['client_secret'] }}"
      oauth_callback_url: "https://fullhub.eosc.zcu.cz/hub/oauth_callback"
      openid_configuration_url: "https://proxy.testing.eosc-federation.eu/.well-known/openid-configuration"
      scope: ["openid", "profile", "email", "offline_access", "entitlements"]
      username_claim: "sub"
      extra_authorize_params:
        prompt: consent
    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
      from tornado.httpclient import AsyncHTTPClient, HTTPClientError, HTTPRequest


      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,
                        "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

      class WebDavOIDCSpawner(B2DropSpawner):
          # ownCloud Infinite Scale parameters
          # (https://owncloud.dev/apis/http/graph/spaces/#list-my-spaces-get-medrives)
          OCIS_URL = "https://ocis.aaitest.owncloud.works"
          # personal space
          OCIS_PERSONAL_SPACE = "/graph/v1.0/me/drives?%24filter=driveType+eq+personal"
          # shared space
          OCIS_SHARED_WITH_ME = "/graph/v1.0/me/drives?%24filter=driveType+eq+virtual"
          # otter spaces
          OCIS_SPACES = "/graph/v1.0/me/drives?%24filter=driveType+eq+project"

          async def append_owncloud_sidecar(self, spawner, type, query, fallback_url=None, headers={}):
              owncloud_url = fallback_url
              http_client = AsyncHTTPClient()
              req = HTTPRequest(
                  self.OCIS_URL + query,
                  headers=headers,
                  method="GET",
              )
              try:
                  resp = await http_client.fetch(req)
                  body = json.loads(resp.body.decode("utf8", "replace"))
                  self.log.debug("OCIS response: %s", body)
                  if "value" in body:
                      ocis_infos = body["value"]
                      if len(ocis_infos) >= 1 and "root" in ocis_infos[0]:
                          owncloud_url = ocis_infos[0]["root"].get("webDavUrl", None)
              except HTTPClientError as e:
                  self.log.error("can't query ownCloud: %s", e)
              self.log.info("ownCloud %s URL: %s", type, owncloud_url)

              if owncloud_url is None:
                  return

              volume_mounts = [
                  {"mountPath": "/owncloud:shared", "name": "owncloud-" + type},
                  {"mountPath": self.token_mount_path, "name": self.token_secret_volume_name, "readOnly": True},
              ]
              spawner.extra_containers.append(
                  {
                      "name": "owncloud-" + type,
                      "image": "eginotebooks/webdav-rclone-sidecar:sha-95b4f95",
                      "args": ["bearer_token_command=cat " + self.token_path],
                      "env": [
                          {"name": "WEBDAV_URL", "value": owncloud_url},
                          {"name": "WEBDAV_VENDOR", "value": "owncloud"},
                          # XXX: strict permissions needed for .local/share/jupyter/runtime/jupyter_cookie_secret
                          # quicker directory cache and polling
                          {"name": "MOUNT_OPTS", "value": "--file-perms=0600 --dir-perms=0770 --dir-cache-time=1m0s --poll-interval=0m20s"},
                          {"name": "MOUNT_PATH", "value": "/owncloud"},
                          # default mode is "full"
                          {"name": "VFS_CACHE_MODE", "value": "full"},
                      ],
                      "resources": self.sidecar_resources,
                      "securityContext": {
                          "runAsUser": 1000,
                          "fsUser": 1000,
                          "fsGroup": 100,
                          "privileged": True,
                          "capabilities": {"add": ["SYS_ADMIN"]},
                      },
                      "volumeMounts": volume_mounts,
                  }
              )

          async def pre_spawn_hook(self, spawner):
              await super(WebDavOIDCSpawner, self).pre_spawn_hook(spawner)
              auth_state = await self.user.get_auth_state()
              # volume name as in EGI spawner
              self.token_secret_volume_name = self._expand_user_properties(
                self.token_secret_volume_name_template
              )
              self.token_path = os.path.join(self.token_mount_path, "access_token")

              access_token = auth_state.get("access_token", None)
              headers = {
                  "Accept": "application/json",
                  "User-Agent": "JupyterHub",
                  "Authorization": "Bearer %s" % access_token,
              }

              # ownCloud user home
              await self.append_owncloud_sidecar(spawner, "home", self.OCIS_PERSONAL_SPACE, headers=headers)
              await self.append_owncloud_sidecar(spawner, "shared", self.OCIS_SHARED_WITH_ME, headers=headers)
              await self.append_owncloud_sidecar(spawner, "spaces", self.OCIS_SPACES, headers=headers)


      c.JupyterHub.spawner_class = WebDavOIDCSpawner
      c.B2DropSpawner.http_timeout = 90
      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>
        {% endblock main_intro %}
{% endraw %}