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