diff --git a/.dockerignore b/.dockerignore index 4f72fd2..05573c9 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,3 +2,4 @@ jupyterhub values.yaml config.yml config-generator +skaffold.yaml \ No newline at end of file diff --git a/.gitignore b/.gitignore index e239487..ec0db41 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ values.yaml build _README.md dist +.env-config-generator \ No newline at end of file diff --git a/application_hub_context/app_hub_context.py b/application_hub_context/app_hub_context.py index f805fec..ea46391 100644 --- a/application_hub_context/app_hub_context.py +++ b/application_hub_context/app_hub_context.py @@ -544,9 +544,7 @@ def apply_manifest(self, manifest): def unapply_manifests(self, manifest_content): - manifests = yaml.safe_load_all(manifest_content) - - for k8_object in manifests: + for k8_object in manifest_content: kind = k8_object.get("kind") self.spawner.log.info( f"Deleting {kind} {k8_object.get('metadata', {}).get('name')}" @@ -567,6 +565,14 @@ def unapply_manifests(self, manifest_content): self.batch_v1_api.delete_namespaced_job(name, namespace) elif kind == "Pod": self.core_v1_api.delete_namespaced_pod(name, namespace) + elif kind == "Role": + self.rbac_authorization_v1_api.delete_namespaced_role(name, namespace) + elif kind == "RoleBinding": + self.rbac_authorization_v1_api.delete_namespaced_role_binding(name, namespace) + elif kind == "ServiceAccount": + self.core_v1_api.delete_namespaced_service_account(name, namespace) + + # Add other kinds as needed else: self.spawner.log.error(f"Unsupported kind: {kind}") @@ -848,18 +854,25 @@ def initialise(self): for manifest in manifests: self.spawner.log.info(f"Apply manifest {manifest.name}") - try: - ms = yaml.safe_load_all(manifest.content) - for k8_object in ms: - self.spawner.log.info( - f"Apply manifest kind {k8_object['kind']}" + + for k8_object in manifest.content: + try: + # Log the object and its type + self.spawner.log.info(f"K8 Object: {k8_object}") + self.spawner.log.info(f"Object Type: {type(k8_object)}") + + # Check and log the 'kind' of the Kubernetes object + if 'kind' in k8_object: + self.spawner.log.info(f"Applying manifest of kind: {k8_object['kind']}") + self.apply_manifest(k8_object) # Apply the manifest + else: + self.spawner.log.warning(f"Manifest does not contain a 'kind': {k8_object}") + + except Exception as err: + self.spawner.log.error(f"Unexpected {err}, {type(err)}") + self.spawner.log.error( + f"Skipping creation of manifest {manifest.name}" ) - self.apply_manifest(k8_object) - except Exception as err: - self.spawner.log.error(f"Unexpected {err}, {type(err)}") - self.spawner.log.error( - f"Skipping creation of manifest {manifest.name}" - ) def dispose(self): profile_id = self.config_parser.get_profile_by_slug(slug=self.profile_slug).id diff --git a/application_hub_context/models.py b/application_hub_context/models.py index 51fa401..bda9082 100644 --- a/application_hub_context/models.py +++ b/application_hub_context/models.py @@ -1,5 +1,5 @@ from enum import Enum -from typing import List, Optional, Union +from typing import Dict, List, Optional, Union from pydantic import BaseModel, Field @@ -40,7 +40,7 @@ class defining a volume object: class Manifest(BaseModel): name: str key: str - content: Optional[str] = None + content: Optional[List[Dict]] = None persist: Optional[bool] = True diff --git a/config-generator/README.md b/config-generator/README.md new file mode 100644 index 0000000..8a4a3c1 --- /dev/null +++ b/config-generator/README.md @@ -0,0 +1,5 @@ +## ApplicationHub configuration generator + +This folder contains a notebook and the python modules to support the generation of ApplicationHub configurations. + +Create a Python environment with the dependencies listed in the file `requirements.txt` and open the notebook `config-generator.ipynb` \ No newline at end of file diff --git a/config-generator/config-generator.ipynb b/config-generator/config-generator.ipynb index a6d71d7..60fefb0 100644 --- a/config-generator/config-generator.ipynb +++ b/config-generator/config-generator.ipynb @@ -408,7 +408,7 @@ "image_pull_secret = ImagePullSecret(\n", " name=\"cr-config\",\n", " persist=False,\n", - " data=\"\",\n", + " data=\"ewogICAgImF1dGhzIjogewogICAgICAgICJjci50ZXJyYWR1ZS5jb20iOiB7CiAgICAgICAgICAgICJ1c2VybmFtZSI6ICJyb2JvdCRlb2VwY2EtcGx1cy1ybyIsCiAgICAgICAgICAgICJwYXNzd29yZCI6ICJQMlE4TnkyZ0lHODhkZkxveXlLN05QVUZVbHJOekFZSiIsCiAgICAgICAgICAgICJlbWFpbCI6ICJlb2VwY2EtcGx1c0B0ZXJyYWR1ZS5jb20iLAogICAgICAgICAgICAiYXV0aCI6ICJjbTlpYjNRa1pXOWxjR05oTFhCc2RYTXRjbTg2VURKUk9FNTVNbWRKUnpnNFpHWk1iM2w1U3pkT1VGVkdWV3h5VG5wQldVbz0iCiAgICAgICAgfQogICAgfQp9\",\n", ")" ] }, @@ -470,7 +470,7 @@ ")\n", "\n", "with open(\n", - " \"../jupyterhub/files/hub/config.yml\", \"w\"\n", + " \"../files/hub/config.yml\", \"w\"\n", ") as file:\n", " yaml.dump(config.dict(), file)" ] @@ -487,7 +487,7 @@ " Profile(id='profile_studio_coder2', groups=['group-a', 'group-b'], definition=ProfileDefinition(display_name='Code Server Medium', description=None, slug='ellip_studio_coder_slug_m', default=False, kubespawner_override=KubespawnerOverride(cpu_limit=4, cpu_guarantee=None, mem_limit='12G', mem_guarantee=None, image='eoepca/pde-code-server:develop', extra_resource_limits={}, extra_resource_guarantees={})), config_maps=[ConfigMap(name='bash-rc', key='bash-rc', mount_path='/workspace/.bashrc', default_mode=None, readonly=True, content='alias ll=\"ls -l\"\\nalias calrissian=\"/opt/conda/bin/calrissian --pod-nodeselectors /etc/calrissian/pod-node-selector.yml --stdout /calrissian/results.json --max-ram 16G --max-cores \"8\" --tmp-outdir-prefix /calrissian/tmp/ --outdir /calrissian/\"\\nalias cwltool=\"/opt/conda/bin/cwltool --podman\"\\n. /home/jovyan/.bashrc\\n# >>> conda initialize >>>\\n# !! Contents within this block are managed by \\'conda init\\' !!\\n__conda_setup=\"$(\\'/opt/conda/bin/conda\\' \\'shell.bash\\' \\'hook\\' 2> /dev/null)\"\\nif [ $? -eq 0 ]; then\\n eval \"$__conda_setup\"\\nelse\\n if [ -f \"/opt/conda/etc/profile.d/conda.sh\" ]; then\\n . \"/opt/conda/etc/profile.d/conda.sh\"\\n else\\n export PATH=\"/srv/conda/bin:$PATH\"\\n fi\\nfi\\nunset __conda_setup\\n\\nif [ -f \"/opt/conda/etc/profile.d/mamba.sh\" ]; then\\n . \"/opt/conda/etc/profile.d/mamba.sh\"\\nfi\\n# <<< conda initialize <<<\\n', persist=True)], volumes=[Volume(name='calrissian-volume', claim_name='calrissian-claim', size='50Gi', storage_class='standard', access_modes=['ReadWriteMany'], volume_mount=VolumeMount(name='calrissian-volume', mount_path='/calrissian'), persist=False), Volume(name='workspace-volume', claim_name='workspace-claim', size='50Gi', storage_class='standard', access_modes=['ReadWriteOnce'], volume_mount=VolumeMount(name='workspace-volume', mount_path='/workspace'), persist=True)], pod_env_vars={'HOME': '/workspace', 'CONDA_ENVS_PATH': ' /workspace/.envs'}, default_url=None, node_selector={}, role_bindings=None, image_pull_secrets=[], init_containers=[], manifests=None),\n", " Profile(id='profile_demo_init_script', groups=['group-a', 'group-b'], definition=ProfileDefinition(display_name='Coder demo init script', description='This profile is used to demonstrate the use of an init script', slug='eoepca_demo_init_script', default=False, kubespawner_override=KubespawnerOverride(cpu_limit=2, cpu_guarantee=1, mem_limit='6G', mem_guarantee='4G', image='eoepca/pde-code-server:develop', extra_resource_limits={}, extra_resource_guarantees={})), config_maps=[ConfigMap(name='init', key='init', mount_path='/opt/init/.init.sh', default_mode='0660', readonly=True, content=\"set -x\\n\\ncd /workspace\\n\\ngit clone 'https://github.com/eoap/mastering-app-package.git'\\n\\ncode-server --install-extension ms-python.python\\ncode-server --install-extension redhat.vscode-yaml\\ncode-server --install-extension sbg-rabix.benten-cwl\\ncode-server --install-extension ms-toolsai.jupyter\\n\\nln -s /workspace/.local/share/code-server/extensions /workspace/extensions\\n\\nexit 0\\n\", persist=False)], volumes=[Volume(name='calrissian-volume', claim_name='calrissian-claim', size='50Gi', storage_class='standard', access_modes=['ReadWriteMany'], volume_mount=VolumeMount(name='calrissian-volume', mount_path='/calrissian'), persist=False), Volume(name='workspace-volume', claim_name='workspace-claim', size='50Gi', storage_class='standard', access_modes=['ReadWriteOnce'], volume_mount=VolumeMount(name='workspace-volume', mount_path='/workspace'), persist=True)], pod_env_vars={'HOME': '/workspace', 'CONDA_ENVS_PATH': '/workspace/.envs', 'CONDARC': '/workspace/.condarc', 'XDG_RUNTIME_DIR': '/workspace/.local', 'CODE_SERVER_WS': '/workspace/mastering-app-package'}, default_url=None, node_selector={}, role_bindings=None, image_pull_secrets=[], init_containers=[InitContainer(name='init-file-on-volume', image='eoepca/pde-code-server:develop', command=['sh', '-c', 'sh /opt/init/.init.sh'], volume_mounts=[VolumeMount(name='workspace-volume', mount_path='/workspace'), InitContainerVolumeMount(name='init', mount_path='/opt/init/.init.sh', sub_path='init')])], manifests=None),\n", " Profile(id='profile_jupyter_lab', groups=['group-c'], definition=ProfileDefinition(display_name='Jupyter Lab', description='Jupyter Lab with Python 3.11', slug='eoepca_jupyter_lab', default=False, kubespawner_override=KubespawnerOverride(cpu_limit=2, cpu_guarantee=1, mem_limit='6G', mem_guarantee='4G', image='jupyter/scipy-notebook', extra_resource_limits={}, extra_resource_guarantees={})), config_maps=[], volumes=[Volume(name='workspace-volume', claim_name='workspace-claim', size='50Gi', storage_class='standard', access_modes=['ReadWriteOnce'], volume_mount=VolumeMount(name='workspace-volume', mount_path='/workspace'), persist=True)], pod_env_vars={'HOME': '/workspace', 'XDG_RUNTIME_DIR': '/workspace/.local', 'XDG_CONFIG_HOME': '/workspace/.config'}, default_url=None, node_selector={}, role_bindings=None, image_pull_secrets=[], init_containers=[], manifests=None),\n", - " Profile(id='profile_jupyter_lab_2', groups=['group-c'], definition=ProfileDefinition(display_name='Jupyter Lab - profile 2', description='Jupyter Lab with Python 3.11 - demoes the use of an image pull secret', slug='eoepca_jupyter_lab_2', default=False, kubespawner_override=KubespawnerOverride(cpu_limit=2, cpu_guarantee=1, mem_limit='6G', mem_guarantee='4G', image='jupyter/scipy-notebook', extra_resource_limits={}, extra_resource_guarantees={})), config_maps=[], volumes=[Volume(name='workspace-volume', claim_name='workspace-claim', size='50Gi', storage_class='standard', access_modes=['ReadWriteOnce'], volume_mount=VolumeMount(name='workspace-volume', mount_path='/workspace'), persist=True)], pod_env_vars={'HOME': '/workspace', 'XDG_RUNTIME_DIR': '/workspace/.local', 'XDG_CONFIG_HOME': '/workspace/.config'}, default_url=None, node_selector={}, role_bindings=None, image_pull_secrets=[ImagePullSecret(name='cr-config', persist=False, data='')], init_containers=[], manifests=None)]" + " Profile(id='profile_jupyter_lab_2', groups=['group-c'], definition=ProfileDefinition(display_name='Jupyter Lab - profile 2', description='Jupyter Lab with Python 3.11 - demoes the use of an image pull secret', slug='eoepca_jupyter_lab_2', default=False, kubespawner_override=KubespawnerOverride(cpu_limit=2, cpu_guarantee=1, mem_limit='6G', mem_guarantee='4G', image='jupyter/scipy-notebook', extra_resource_limits={}, extra_resource_guarantees={})), config_maps=[], volumes=[Volume(name='workspace-volume', claim_name='workspace-claim', size='50Gi', storage_class='standard', access_modes=['ReadWriteOnce'], volume_mount=VolumeMount(name='workspace-volume', mount_path='/workspace'), persist=True)], pod_env_vars={'HOME': '/workspace', 'XDG_RUNTIME_DIR': '/workspace/.local', 'XDG_CONFIG_HOME': '/workspace/.config'}, default_url=None, node_selector={}, role_bindings=None, image_pull_secrets=[ImagePullSecret(name='cr-config', persist=False, data='ewogICAgImF1dGhzIjogewogICAgICAgICJjci50ZXJyYWR1ZS5jb20iOiB7CiAgICAgICAgICAgICJ1c2VybmFtZSI6ICJyb2JvdCRlb2VwY2EtcGx1cy1ybyIsCiAgICAgICAgICAgICJwYXNzd29yZCI6ICJQMlE4TnkyZ0lHODhkZkxveXlLN05QVUZVbHJOekFZSiIsCiAgICAgICAgICAgICJlbWFpbCI6ICJlb2VwY2EtcGx1c0B0ZXJyYWR1ZS5jb20iLAogICAgICAgICAgICAiYXV0aCI6ICJjbTlpYjNRa1pXOWxjR05oTFhCc2RYTXRjbTg2VURKUk9FNTVNbWRKUnpnNFpHWk1iM2w1U3pkT1VGVkdWV3h5VG5wQldVbz0iCiAgICAgICAgfQogICAgfQp9')], init_containers=[], manifests=None)]" ] }, "execution_count": 15, @@ -537,7 +537,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.8" + "version": "3.10.14" }, "orig_nbformat": 4 }, diff --git a/config-generator/config-maps/bash-rc b/config-generator/config-maps/bash-rc index 6623894..4dff3fa 100644 --- a/config-generator/config-maps/bash-rc +++ b/config-generator/config-maps/bash-rc @@ -2,6 +2,9 @@ alias ll="ls -l" alias calrissian="/opt/conda/bin/calrissian --pod-nodeselectors /etc/calrissian/pod-node-selector.yml --stdout /calrissian/results.json --max-ram 16G --max-cores "8" --tmp-outdir-prefix /calrissian/tmp/ --outdir /calrissian/" alias cwltool="/opt/conda/bin/cwltool --podman" . /home/jovyan/.bashrc + +alias aws="aws --endpoint-url=http://localstack:4566" + # >>> conda initialize >>> # !! Contents within this block are managed by 'conda init' !! __conda_setup="$('/opt/conda/bin/conda' 'shell.bash' 'hook' 2> /dev/null)" diff --git a/config-generator/config-maps/init-stac.sh b/config-generator/config-maps/init-stac.sh new file mode 100644 index 0000000..f61d57a --- /dev/null +++ b/config-generator/config-maps/init-stac.sh @@ -0,0 +1,31 @@ +set -x + +cd /workspace + +git clone 'https://github.com/eoap/stac-eoap.git' + +code-server --install-extension ms-python.python +code-server --install-extension redhat.vscode-yaml +code-server --install-extension sbg-rabix.benten-cwl +code-server --install-extension ms-toolsai.jupyter + +ln -s /workspace/.local/share/code-server/extensions /workspace/extensions + +mkdir -p /workspace/User/ + +echo '{"workbench.colorTheme": "Visual Studio Dark"}' > /workspace/User/settings.json + +python -m venv /workspace/.venv +source /workspace/.venv/bin/activate +/workspace/.venv/bin/python -m pip install --no-cache-dir stactools rasterio requests stac-asset click-logging tabulate tqdm pystac-client ipykernel loguru scikit-image rio_stac boto3==1.35.23 + +/workspace/.venv/bin/python -m pip install --index-url https://test.pypi.org/simple cwl-wrapper + +/workspace/.venv/bin/python -m ipykernel install --user --name stac_env --display-name "Python (STAC)" + +export AWS_DEFAULT_REGION="us-east-1" +export AWS_ACCESS_KEY_ID="test" +export AWS_SECRET_ACCESS_KEY="test" +aws s3 mb s3://results --endpoint-url=http://localstack:4566 + +exit 0 \ No newline at end of file diff --git a/config-generator/manifests/manifest.yaml b/config-generator/manifests/manifest.yaml new file mode 100644 index 0000000..d570078 --- /dev/null +++ b/config-generator/manifests/manifest.yaml @@ -0,0 +1,421 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: localstack +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: localstack +rules: +- apiGroups: [""] # "" indicates the core API group + resources: ["pods"] + verbs: ["*"] +- apiGroups: [""] + resources: ["pods/log"] + verbs: ["get"] +- apiGroups: [""] + resources: ["pods/exec"] + verbs: ["get", "create"] +- apiGroups: [""] + resources: ["services"] + verbs: ["get", "list"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: localstack +subjects: +- kind: ServiceAccount + name: localstack +roleRef: + kind: Role + name: localstack + apiGroup: rbac.authorization.k8s.io +--- +apiVersion: v1 +kind: Service +metadata: + name: localstack +spec: + type: ClusterIP + ports: + - name: edge + port: 4566 + targetPort: 4566 + - name: "external-service-port-4510" + port: 4510 + targetPort: "ext-svc-4510" + - name: "external-service-port-4511" + port: 4511 + targetPort: "ext-svc-4511" + - name: "external-service-port-4512" + port: 4512 + targetPort: "ext-svc-4512" + - name: "external-service-port-4513" + port: 4513 + targetPort: "ext-svc-4513" + - name: "external-service-port-4514" + port: 4514 + targetPort: "ext-svc-4514" + - name: "external-service-port-4515" + port: 4515 + targetPort: "ext-svc-4515" + - name: "external-service-port-4516" + port: 4516 + targetPort: "ext-svc-4516" + - name: "external-service-port-4517" + port: 4517 + targetPort: "ext-svc-4517" + - name: "external-service-port-4518" + port: 4518 + targetPort: "ext-svc-4518" + - name: "external-service-port-4519" + port: 4519 + targetPort: "ext-svc-4519" + - name: "external-service-port-4520" + port: 4520 + targetPort: "ext-svc-4520" + - name: "external-service-port-4521" + port: 4521 + targetPort: "ext-svc-4521" + - name: "external-service-port-4522" + port: 4522 + targetPort: "ext-svc-4522" + - name: "external-service-port-4523" + port: 4523 + targetPort: "ext-svc-4523" + - name: "external-service-port-4524" + port: 4524 + targetPort: "ext-svc-4524" + - name: "external-service-port-4525" + port: 4525 + targetPort: "ext-svc-4525" + - name: "external-service-port-4526" + port: 4526 + targetPort: "ext-svc-4526" + - name: "external-service-port-4527" + port: 4527 + targetPort: "ext-svc-4527" + - name: "external-service-port-4528" + port: 4528 + targetPort: "ext-svc-4528" + - name: "external-service-port-4529" + port: 4529 + targetPort: "ext-svc-4529" + - name: "external-service-port-4530" + port: 4530 + targetPort: "ext-svc-4530" + - name: "external-service-port-4531" + port: 4531 + targetPort: "ext-svc-4531" + - name: "external-service-port-4532" + port: 4532 + targetPort: "ext-svc-4532" + - name: "external-service-port-4533" + port: 4533 + targetPort: "ext-svc-4533" + - name: "external-service-port-4534" + port: 4534 + targetPort: "ext-svc-4534" + - name: "external-service-port-4535" + port: 4535 + targetPort: "ext-svc-4535" + - name: "external-service-port-4536" + port: 4536 + targetPort: "ext-svc-4536" + - name: "external-service-port-4537" + port: 4537 + targetPort: "ext-svc-4537" + - name: "external-service-port-4538" + port: 4538 + targetPort: "ext-svc-4538" + - name: "external-service-port-4539" + port: 4539 + targetPort: "ext-svc-4539" + - name: "external-service-port-4540" + port: 4540 + targetPort: "ext-svc-4540" + - name: "external-service-port-4541" + port: 4541 + targetPort: "ext-svc-4541" + - name: "external-service-port-4542" + port: 4542 + targetPort: "ext-svc-4542" + - name: "external-service-port-4543" + port: 4543 + targetPort: "ext-svc-4543" + - name: "external-service-port-4544" + port: 4544 + targetPort: "ext-svc-4544" + - name: "external-service-port-4545" + port: 4545 + targetPort: "ext-svc-4545" + - name: "external-service-port-4546" + port: 4546 + targetPort: "ext-svc-4546" + - name: "external-service-port-4547" + port: 4547 + targetPort: "ext-svc-4547" + - name: "external-service-port-4548" + port: 4548 + targetPort: "ext-svc-4548" + - name: "external-service-port-4549" + port: 4549 + targetPort: "ext-svc-4549" + - name: "external-service-port-4550" + port: 4550 + targetPort: "ext-svc-4550" + - name: "external-service-port-4551" + port: 4551 + targetPort: "ext-svc-4551" + - name: "external-service-port-4552" + port: 4552 + targetPort: "ext-svc-4552" + - name: "external-service-port-4553" + port: 4553 + targetPort: "ext-svc-4553" + - name: "external-service-port-4554" + port: 4554 + targetPort: "ext-svc-4554" + - name: "external-service-port-4555" + port: 4555 + targetPort: "ext-svc-4555" + - name: "external-service-port-4556" + port: 4556 + targetPort: "ext-svc-4556" + - name: "external-service-port-4557" + port: 4557 + targetPort: "ext-svc-4557" + - name: "external-service-port-4558" + port: 4558 + targetPort: "ext-svc-4558" + - name: "external-service-port-4559" + port: 4559 + targetPort: "ext-svc-4559" + selector: + app.kubernetes.io/name: localstack + app.kubernetes.io/instance: localstack +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: localstack +spec: + replicas: 1 + strategy: + type: RollingUpdate + selector: + matchLabels: + app.kubernetes.io/name: localstack + app.kubernetes.io/instance: localstack + template: + metadata: + labels: + app.kubernetes.io/name: localstack + app.kubernetes.io/instance: localstack + spec: + serviceAccountName: localstack + securityContext: + {} + containers: + - name: localstack + securityContext: + {} + image: "localstack/localstack:latest" + imagePullPolicy: IfNotPresent + ports: + - name: edge + containerPort: 4566 + protocol: TCP + - name: "ext-svc-4510" + containerPort: 4510 + protocol: TCP + - name: "ext-svc-4511" + containerPort: 4511 + protocol: TCP + - name: "ext-svc-4512" + containerPort: 4512 + protocol: TCP + - name: "ext-svc-4513" + containerPort: 4513 + protocol: TCP + - name: "ext-svc-4514" + containerPort: 4514 + protocol: TCP + - name: "ext-svc-4515" + containerPort: 4515 + protocol: TCP + - name: "ext-svc-4516" + containerPort: 4516 + protocol: TCP + - name: "ext-svc-4517" + containerPort: 4517 + protocol: TCP + - name: "ext-svc-4518" + containerPort: 4518 + protocol: TCP + - name: "ext-svc-4519" + containerPort: 4519 + protocol: TCP + - name: "ext-svc-4520" + containerPort: 4520 + protocol: TCP + - name: "ext-svc-4521" + containerPort: 4521 + protocol: TCP + - name: "ext-svc-4522" + containerPort: 4522 + protocol: TCP + - name: "ext-svc-4523" + containerPort: 4523 + protocol: TCP + - name: "ext-svc-4524" + containerPort: 4524 + protocol: TCP + - name: "ext-svc-4525" + containerPort: 4525 + protocol: TCP + - name: "ext-svc-4526" + containerPort: 4526 + protocol: TCP + - name: "ext-svc-4527" + containerPort: 4527 + protocol: TCP + - name: "ext-svc-4528" + containerPort: 4528 + protocol: TCP + - name: "ext-svc-4529" + containerPort: 4529 + protocol: TCP + - name: "ext-svc-4530" + containerPort: 4530 + protocol: TCP + - name: "ext-svc-4531" + containerPort: 4531 + protocol: TCP + - name: "ext-svc-4532" + containerPort: 4532 + protocol: TCP + - name: "ext-svc-4533" + containerPort: 4533 + protocol: TCP + - name: "ext-svc-4534" + containerPort: 4534 + protocol: TCP + - name: "ext-svc-4535" + containerPort: 4535 + protocol: TCP + - name: "ext-svc-4536" + containerPort: 4536 + protocol: TCP + - name: "ext-svc-4537" + containerPort: 4537 + protocol: TCP + - name: "ext-svc-4538" + containerPort: 4538 + protocol: TCP + - name: "ext-svc-4539" + containerPort: 4539 + protocol: TCP + - name: "ext-svc-4540" + containerPort: 4540 + protocol: TCP + - name: "ext-svc-4541" + containerPort: 4541 + protocol: TCP + - name: "ext-svc-4542" + containerPort: 4542 + protocol: TCP + - name: "ext-svc-4543" + containerPort: 4543 + protocol: TCP + - name: "ext-svc-4544" + containerPort: 4544 + protocol: TCP + - name: "ext-svc-4545" + containerPort: 4545 + protocol: TCP + - name: "ext-svc-4546" + containerPort: 4546 + protocol: TCP + - name: "ext-svc-4547" + containerPort: 4547 + protocol: TCP + - name: "ext-svc-4548" + containerPort: 4548 + protocol: TCP + - name: "ext-svc-4549" + containerPort: 4549 + protocol: TCP + - name: "ext-svc-4550" + containerPort: 4550 + protocol: TCP + - name: "ext-svc-4551" + containerPort: 4551 + protocol: TCP + - name: "ext-svc-4552" + containerPort: 4552 + protocol: TCP + - name: "ext-svc-4553" + containerPort: 4553 + protocol: TCP + - name: "ext-svc-4554" + containerPort: 4554 + protocol: TCP + - name: "ext-svc-4555" + containerPort: 4555 + protocol: TCP + - name: "ext-svc-4556" + containerPort: 4556 + protocol: TCP + - name: "ext-svc-4557" + containerPort: 4557 + protocol: TCP + - name: "ext-svc-4558" + containerPort: 4558 + protocol: TCP + - name: "ext-svc-4559" + containerPort: 4559 + protocol: TCP + livenessProbe: + failureThreshold: 3 + initialDelaySeconds: 0 + periodSeconds: 10 + successThreshold: 1 + timeoutSeconds: 1 + httpGet: + path: /_localstack/health + port: edge + readinessProbe: + failureThreshold: 3 + initialDelaySeconds: 0 + periodSeconds: 10 + successThreshold: 1 + timeoutSeconds: 1 + httpGet: + path: /_localstack/health + port: edge + resources: {} + env: + - name: DEBUG + value: "0" + - name: EXTERNAL_SERVICE_PORTS_START + value: "4510" + - name: EXTERNAL_SERVICE_PORTS_END + value: "4560" + - name: LOCALSTACK_K8S_SERVICE_NAME + value: localstack + - name: LOCALSTACK_K8S_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: LAMBDA_RUNTIME_EXECUTOR + value: "docker" + - name: LAMBDA_K8S_IMAGE_PREFIX + value: "localstack/lambda-" + - name: LAMBDA_RUNTIME_ENVIRONMENT_TIMEOUT + value: "60" + - name: OVERRIDE_IN_DOCKER + value: "1" + volumes: [] \ No newline at end of file diff --git a/config-generator/models.py b/config-generator/models.py index aee37fa..02eabdc 100644 --- a/config-generator/models.py +++ b/config-generator/models.py @@ -74,7 +74,7 @@ class Volume(BaseModel): class Manifest(BaseModel): name: str key: str - content: Optional[str] = None + content: Optional[List[Dict]] = None persist: Optional[bool] = True diff --git a/config-generator/requirements.txt b/config-generator/requirements.txt new file mode 100644 index 0000000..3c47a85 --- /dev/null +++ b/config-generator/requirements.txt @@ -0,0 +1,3 @@ +ipykernel +pydantic +pyyaml \ No newline at end of file diff --git a/jupyterhub/values-minikube.yaml b/demo-values.yaml similarity index 97% rename from jupyterhub/values-minikube.yaml rename to demo-values.yaml index e1c90aa..f3a60c1 100644 --- a/jupyterhub/values-minikube.yaml +++ b/demo-values.yaml @@ -1,3 +1,4 @@ + # fullnameOverride and nameOverride distinguishes blank strings, null values, # and non-blank strings. For more details, see the configuration reference. fullnameOverride: "" @@ -79,7 +80,20 @@ hub: } extraContainers: [] extraVolumes: [] + # - name: application-hub-config + # configMap: + # name: application-hub-jupyter-config + # defaultMode: 0744 + # - name: application-hub-config-theme + # configMap: + # name: application-hub-jupyter-config-theme + # defaultMode: 0744 extraVolumeMounts: [] + # - name: application-hub-config + # mountPath: /usr/local/etc/applicationhub + # - name: application-hub-config-theme + # mountPath: /opt/jupyterhub/template/ + image: name: hubimage tag: "dev" diff --git a/files/hub/config.yml b/files/hub/config.yml new file mode 100644 index 0000000..70a6dbe --- /dev/null +++ b/files/hub/config.yml @@ -0,0 +1,303 @@ +profiles: +- config_maps: + - content: "alias ll=\"ls -l\"\nalias calrissian=\"/opt/conda/bin/calrissian --pod-nodeselectors\ + \ /etc/calrissian/pod-node-selector.yml --stdout /calrissian/results.json --max-ram\ + \ 16G --max-cores \"8\" --tmp-outdir-prefix /calrissian/tmp/ --outdir /calrissian/\"\ + \nalias cwltool=\"/opt/conda/bin/cwltool --podman\"\n. /home/jovyan/.bashrc\n\ + # >>> conda initialize >>>\n# !! Contents within this block are managed by 'conda\ + \ init' !!\n__conda_setup=\"$('/opt/conda/bin/conda' 'shell.bash' 'hook' 2>\ + \ /dev/null)\"\nif [ $? -eq 0 ]; then\n eval \"$__conda_setup\"\nelse\n \ + \ if [ -f \"/opt/conda/etc/profile.d/conda.sh\" ]; then\n . \"/opt/conda/etc/profile.d/conda.sh\"\ + \n else\n export PATH=\"/srv/conda/bin:$PATH\"\n fi\nfi\nunset\ + \ __conda_setup\n\nif [ -f \"/opt/conda/etc/profile.d/mamba.sh\" ]; then\n \ + \ . \"/opt/conda/etc/profile.d/mamba.sh\"\nfi\n# <<< conda initialize <<<\n" + default_mode: null + key: bash-rc + mount_path: /workspace/.bashrc + name: bash-rc + persist: true + readonly: true + default_url: null + definition: + default: false + description: null + display_name: Code Server Small + kubespawner_override: + cpu_guarantee: null + cpu_limit: 2 + extra_resource_guarantees: {} + extra_resource_limits: {} + image: eoepca/pde-code-server:develop + mem_guarantee: null + mem_limit: 8G + slug: ellip_studio_coder_slug_s + groups: + - group-a + - group-b + id: profile_studio_coder1 + image_pull_secrets: [] + init_containers: [] + manifests: null + node_selector: {} + pod_env_vars: + CONDA_ENVS_PATH: ' /workspace/.envs' + HOME: /workspace + role_bindings: null + volumes: + - access_modes: + - ReadWriteMany + claim_name: calrissian-claim + name: calrissian-volume + persist: false + size: 50Gi + storage_class: standard + volume_mount: + mount_path: /calrissian + name: calrissian-volume + - access_modes: + - ReadWriteOnce + claim_name: workspace-claim + name: workspace-volume + persist: true + size: 50Gi + storage_class: standard + volume_mount: + mount_path: /workspace + name: workspace-volume +- config_maps: + - content: "alias ll=\"ls -l\"\nalias calrissian=\"/opt/conda/bin/calrissian --pod-nodeselectors\ + \ /etc/calrissian/pod-node-selector.yml --stdout /calrissian/results.json --max-ram\ + \ 16G --max-cores \"8\" --tmp-outdir-prefix /calrissian/tmp/ --outdir /calrissian/\"\ + \nalias cwltool=\"/opt/conda/bin/cwltool --podman\"\n. /home/jovyan/.bashrc\n\ + # >>> conda initialize >>>\n# !! Contents within this block are managed by 'conda\ + \ init' !!\n__conda_setup=\"$('/opt/conda/bin/conda' 'shell.bash' 'hook' 2>\ + \ /dev/null)\"\nif [ $? -eq 0 ]; then\n eval \"$__conda_setup\"\nelse\n \ + \ if [ -f \"/opt/conda/etc/profile.d/conda.sh\" ]; then\n . \"/opt/conda/etc/profile.d/conda.sh\"\ + \n else\n export PATH=\"/srv/conda/bin:$PATH\"\n fi\nfi\nunset\ + \ __conda_setup\n\nif [ -f \"/opt/conda/etc/profile.d/mamba.sh\" ]; then\n \ + \ . \"/opt/conda/etc/profile.d/mamba.sh\"\nfi\n# <<< conda initialize <<<\n" + default_mode: null + key: bash-rc + mount_path: /workspace/.bashrc + name: bash-rc + persist: true + readonly: true + default_url: null + definition: + default: false + description: null + display_name: Code Server Medium + kubespawner_override: + cpu_guarantee: null + cpu_limit: 4 + extra_resource_guarantees: {} + extra_resource_limits: {} + image: eoepca/pde-code-server:develop + mem_guarantee: null + mem_limit: 12G + slug: ellip_studio_coder_slug_m + groups: + - group-a + - group-b + id: profile_studio_coder2 + image_pull_secrets: [] + init_containers: [] + manifests: null + node_selector: {} + pod_env_vars: + CONDA_ENVS_PATH: ' /workspace/.envs' + HOME: /workspace + role_bindings: null + volumes: + - access_modes: + - ReadWriteMany + claim_name: calrissian-claim + name: calrissian-volume + persist: false + size: 50Gi + storage_class: standard + volume_mount: + mount_path: /calrissian + name: calrissian-volume + - access_modes: + - ReadWriteOnce + claim_name: workspace-claim + name: workspace-volume + persist: true + size: 50Gi + storage_class: standard + volume_mount: + mount_path: /workspace + name: workspace-volume +- config_maps: + - content: 'set -x + + + cd /workspace + + + git clone ''https://github.com/eoap/mastering-app-package.git'' + + + code-server --install-extension ms-python.python + + code-server --install-extension redhat.vscode-yaml + + code-server --install-extension sbg-rabix.benten-cwl + + code-server --install-extension ms-toolsai.jupyter + + + ln -s /workspace/.local/share/code-server/extensions /workspace/extensions + + + exit 0 + + ' + default_mode: '0660' + key: init + mount_path: /opt/init/.init.sh + name: init + persist: false + readonly: true + default_url: null + definition: + default: false + description: This profile is used to demonstrate the use of an init script + display_name: Coder demo init script + kubespawner_override: + cpu_guarantee: 1 + cpu_limit: 2 + extra_resource_guarantees: {} + extra_resource_limits: {} + image: eoepca/pde-code-server:develop + mem_guarantee: 4G + mem_limit: 6G + slug: eoepca_demo_init_script + groups: + - group-a + - group-b + id: profile_demo_init_script + image_pull_secrets: [] + init_containers: + - command: + - sh + - -c + - sh /opt/init/.init.sh + image: eoepca/pde-code-server:develop + name: init-file-on-volume + volume_mounts: + - mount_path: /workspace + name: workspace-volume + - mount_path: /opt/init/.init.sh + name: init + sub_path: init + manifests: null + node_selector: {} + pod_env_vars: + CODE_SERVER_WS: /workspace/mastering-app-package + CONDARC: /workspace/.condarc + CONDA_ENVS_PATH: /workspace/.envs + HOME: /workspace + XDG_RUNTIME_DIR: /workspace/.local + role_bindings: null + volumes: + - access_modes: + - ReadWriteMany + claim_name: calrissian-claim + name: calrissian-volume + persist: false + size: 50Gi + storage_class: standard + volume_mount: + mount_path: /calrissian + name: calrissian-volume + - access_modes: + - ReadWriteOnce + claim_name: workspace-claim + name: workspace-volume + persist: true + size: 50Gi + storage_class: standard + volume_mount: + mount_path: /workspace + name: workspace-volume +- config_maps: [] + default_url: null + definition: + default: false + description: Jupyter Lab with Python 3.11 + display_name: Jupyter Lab + kubespawner_override: + cpu_guarantee: 1 + cpu_limit: 2 + extra_resource_guarantees: {} + extra_resource_limits: {} + image: jupyter/scipy-notebook + mem_guarantee: 4G + mem_limit: 6G + slug: eoepca_jupyter_lab + groups: + - group-c + id: profile_jupyter_lab + image_pull_secrets: [] + init_containers: [] + manifests: null + node_selector: {} + pod_env_vars: + HOME: /workspace + XDG_CONFIG_HOME: /workspace/.config + XDG_RUNTIME_DIR: /workspace/.local + role_bindings: null + volumes: + - access_modes: + - ReadWriteOnce + claim_name: workspace-claim + name: workspace-volume + persist: true + size: 50Gi + storage_class: standard + volume_mount: + mount_path: /workspace + name: workspace-volume +- config_maps: [] + default_url: null + definition: + default: false + description: Jupyter Lab with Python 3.11 - demoes the use of an image pull secret + display_name: Jupyter Lab - profile 2 + kubespawner_override: + cpu_guarantee: 1 + cpu_limit: 2 + extra_resource_guarantees: {} + extra_resource_limits: {} + image: jupyter/scipy-notebook + mem_guarantee: 4G + mem_limit: 6G + slug: eoepca_jupyter_lab_2 + groups: + - group-c + id: profile_jupyter_lab_2 + image_pull_secrets: + - data: ewogICAgImF1dGhzIjogewogICAgICAgICJjci50ZXJyYWR1ZS5jb20iOiB7CiAgICAgICAgICAgICJ1c2VybmFtZSI6ICJyb2JvdCRlb2VwY2EtcGx1cy1ybyIsCiAgICAgICAgICAgICJwYXNzd29yZCI6ICJQMlE4TnkyZ0lHODhkZkxveXlLN05QVUZVbHJOekFZSiIsCiAgICAgICAgICAgICJlbWFpbCI6ICJlb2VwY2EtcGx1c0B0ZXJyYWR1ZS5jb20iLAogICAgICAgICAgICAiYXV0aCI6ICJjbTlpYjNRa1pXOWxjR05oTFhCc2RYTXRjbTg2VURKUk9FNTVNbWRKUnpnNFpHWk1iM2w1U3pkT1VGVkdWV3h5VG5wQldVbz0iCiAgICAgICAgfQogICAgfQp9 + name: cr-config + persist: false + init_containers: [] + manifests: null + node_selector: {} + pod_env_vars: + HOME: /workspace + XDG_CONFIG_HOME: /workspace/.config + XDG_RUNTIME_DIR: /workspace/.local + role_bindings: null + volumes: + - access_modes: + - ReadWriteOnce + claim_name: workspace-claim + name: workspace-volume + persist: true + size: 50Gi + storage_class: standard + volume_mount: + mount_path: /workspace + name: workspace-volume diff --git a/files/hub/jupyterhub_config.py b/files/hub/jupyterhub_config.py new file mode 100644 index 0000000..56395ea --- /dev/null +++ b/files/hub/jupyterhub_config.py @@ -0,0 +1,179 @@ +import os +import sys + +from tornado.httpclient import AsyncHTTPClient + + +from application_hub_context.app_hub_context import DefaultApplicationHubContext + + +configuration_directory = os.path.dirname(os.path.realpath(__file__)) +sys.path.insert(0, configuration_directory) + +from z2jh import ( + get_config, + get_name, + get_name_env, +) + +config_path = "/usr/local/etc/applicationhub/config.yml" + +namespace_prefix = "jupyter" + + +def custom_options_form(spawner): + + spawner.log.info("Configure profile list") + + namespace = f"{namespace_prefix}-{spawner.user.name}" + + workspace = DefaultApplicationHubContext( + namespace=namespace, + spawner=spawner, + config_path=config_path, + ) + + spawner.profile_list = workspace.get_profile_list() + + return spawner._options_form_default() + + +def pre_spawn_hook(spawner): + + profile_slug = spawner.user_options.get("profile", None) + + env = os.environ["JUPYTERHUB_ENV"].lower() + + spawner.environment["CALRISSIAN_POD_NAME"] = f"jupyter-{spawner.user.name}-{env}" + + spawner.log.info(f"Using profile slug {profile_slug}") + + namespace = f"{namespace_prefix}-{spawner.user.name}" + + workspace = DefaultApplicationHubContext( + namespace=namespace, spawner=spawner, config_path=config_path + ) + + workspace.initialise() + + profile_id = workspace.config_parser.get_profile_by_slug(slug=profile_slug).id + + default_url = workspace.config_parser.get_profile_default_url(profile_id=profile_id) + + if default_url: + spawner.log.info(f"Setting default url to {default_url}") + spawner.default_url = default_url + + +def post_stop_hook(spawner): + + namespace = f"jupyter-{spawner.user.name}" + + workspace = DefaultApplicationHubContext( + namespace=namespace, spawner=spawner, config_path=config_path + ) + spawner.log.info("Dispose in post stop hook") + workspace.dispose() + + +c.JupyterHub.default_url = "spawn" + + +# Configure JupyterHub to use the curl backend for making HTTP requests, +# rather than the pure-python implementations. The default one starts +# being too slow to make a large number of requests to the proxy API +# at the rate required. +AsyncHTTPClient.configure("tornado.curl_httpclient.CurlAsyncHTTPClient") + +c.ConfigurableHTTPProxy.api_url = ( + f'http://{get_name("proxy-api")}:{get_name_env("proxy-api", "_SERVICE_PORT")}' +) +# c.ConfigurableHTTPProxy.should_start = False + +# Don't wait at all before redirecting a spawning user to the progress page +c.JupyterHub.tornado_settings = { + "slow_spawn_timeout": 0, +} + +jupyterhub_env = os.environ["JUPYTERHUB_ENV"].upper() +jupyterhub_hub_host = "hub.jupyter" +jupyterhub_single_user_image = os.environ["JUPYTERHUB_SINGLE_USER_IMAGE_NOTEBOOKS"] + +# Authentication +c.LocalAuthenticator.create_system_users = True +c.Authenticator.admin_users = {"jovyan"} +# Deprecated +c.Authenticator.allowed_users = {"jovyan"} +c.JupyterHub.authenticator_class = "dummy" + +# HTTP Proxy auth token +c.ConfigurableHTTPProxy.auth_token = get_config("proxy.secretToken") +c.JupyterHub.cookie_secret_file = "/srv/jupyterhub/cookie_secret" +# Proxy config +c.JupyterHub.cleanup_servers = False +# Network +c.JupyterHub.allow_named_servers = True +c.JupyterHub.ip = "0.0.0.0" +c.JupyterHub.hub_ip = "0.0.0.0" +c.JupyterHub.hub_connect_ip = jupyterhub_hub_host +# Misc +c.JupyterHub.cleanup_servers = False + +# Culling +c.JupyterHub.services = [ + { + "name": "idle-culler", + "admin": True, + "command": [sys.executable, "-m", "jupyterhub_idle_culler", "--timeout=3600"], + } +] + +# Logs +c.JupyterHub.log_level = "DEBUG" + +# Spawner +c.JupyterHub.spawner_class = "kubespawner.KubeSpawner" +c.KubeSpawner.environment = { + "JUPYTER_ENABLE_LAB": "true", +} + +c.KubeSpawner.uid = 1001 +c.KubeSpawner.fs_gid = 100 +c.KubeSpawner.hub_connect_ip = jupyterhub_hub_host + +# SecurityContext +c.KubeSpawner.privileged = True +c.KubeSpawner.allow_privilege_escalation = True + +# ServiceAccount +c.KubeSpawner.service_account = "default" +c.KubeSpawner.start_timeout = 60 * 15 +c.KubeSpawner.image = jupyterhub_single_user_image +c.KubernetesSpawner.verify_ssl = True +c.KubeSpawner.pod_name_template = ( + "jupyter-{username}-{servername}-" + os.environ["JUPYTERHUB_ENV"].lower() +) + +# Namespace +c.KubeSpawner.namespace = "jupyter" + +# User namespace +c.KubeSpawner.enable_user_namespaces = True + +# Volumes +# volumes are managed by the pre_spawn_hook/post_stop_hook + +# TODO - move this value to the values.yaml file +c.KubeSpawner.image_pull_secrets = ["cr-config"] + +# custom options form +c.KubeSpawner.options_form = custom_options_form + +# hooks +c.KubeSpawner.pre_spawn_hook = pre_spawn_hook +c.KubeSpawner.post_stop_hook = post_stop_hook + +c.JupyterHub.template_paths = [ + "/opt/jupyterhub/template", + "/usr/local/share/jupyterhub/templates", +] diff --git a/files/hub/z2jh.py b/files/hub/z2jh.py new file mode 100644 index 0000000..57e463f --- /dev/null +++ b/files/hub/z2jh.py @@ -0,0 +1,121 @@ +""" +Utility methods for use in jupyterhub_config.py and dynamic subconfigs. + +Methods here can be imported by extraConfig in values.yaml +""" +# from collections import Mapping +from collections.abc import Mapping +from functools import lru_cache +import os + +import yaml + +# memoize so we only load config once +@lru_cache() +def _load_config(): + """Load the Helm chart configuration used to render the Helm templates of + the chart from a mounted k8s Secret, and merge in values from an optionally + mounted secret (hub.existingSecret).""" + + cfg = {} + for source in ("secret/values.yaml", "existing-secret/values.yaml"): + path = f"/usr/local/etc/jupyterhub/{source}" + if os.path.exists(path): + print(f"Loading {path}") + with open(path) as f: + values = yaml.safe_load(f) + cfg = _merge_dictionaries(cfg, values) + else: + print(f"No config at {path}") + return cfg + + +@lru_cache() +def _get_config_value(key): + """Load value from the k8s ConfigMap given a key.""" + + path = f"/usr/local/etc/jupyterhub/config/{key}" + if os.path.exists(path): + with open(path) as f: + return f.read() + else: + raise Exception(f"{path} not found!") + + +@lru_cache() +def get_secret_value(key, default="never-explicitly-set"): + """Load value from the user managed k8s Secret or the default k8s Secret + given a key.""" + + for source in ("existing-secret", "secret"): + path = f"/usr/local/etc/jupyterhub/{source}/{key}" + if os.path.exists(path): + with open(path) as f: + return f.read() + if default != "never-explicitly-set": + return default + raise Exception(f"{key} not found in either k8s Secret!") + + +def get_name(name): + """Returns the fullname of a resource given its short name""" + return _get_config_value(name) + + +def get_name_env(name, suffix=""): + """Returns the fullname of a resource given its short name along with a + suffix, converted to uppercase with dashes replaced with underscores. This + is useful to reference named services associated environment variables, such + as PROXY_PUBLIC_SERVICE_PORT.""" + env_key = _get_config_value(name) + suffix + env_key = env_key.upper().replace("-", "_") + return os.environ[env_key] + + +def _merge_dictionaries(a, b): + """Merge two dictionaries recursively. + + Simplified From https://stackoverflow.com/a/7205107 + """ + merged = a.copy() + for key in b: + if key in a: + if isinstance(a[key], Mapping) and isinstance(b[key], Mapping): + merged[key] = _merge_dictionaries(a[key], b[key]) + else: + merged[key] = b[key] + else: + merged[key] = b[key] + return merged + + +def get_config(key, default=None): + """ + Find a config item of a given name & return it + + Parses everything as YAML, so lists and dicts are available too + + get_config("a.b.c") returns config['a']['b']['c'] + """ + value = _load_config() + # resolve path in yaml + for level in key.split("."): + if not isinstance(value, dict): + # a parent is a scalar or null, + # can't resolve full path + return default + if level not in value: + return default + else: + value = value[level] + return value + + +def set_config_if_not_none(cparent, name, key): + """ + Find a config item of a given name, set the corresponding Jupyter + configuration item if not None + """ + data = get_config(key) + if data is not None: + setattr(cparent, name, data) diff --git a/files/theme/page.html b/files/theme/page.html new file mode 100644 index 0000000..5bec635 --- /dev/null +++ b/files/theme/page.html @@ -0,0 +1,5 @@ +{% extends "templates/page.html" %} + +{% set announcement = 'EOEPCA+ ApplicationHub demonstration instance (super cool)' %} + +{% block title %}ApplicationHub{% endblock %} diff --git a/files/theme/spawn.html b/files/theme/spawn.html new file mode 100644 index 0000000..8bb7896 --- /dev/null +++ b/files/theme/spawn.html @@ -0,0 +1,7 @@ +{% extends "templates/spawn.html" %} + +{% block heading %} +
Your EOEPCA application is starting up.
+You will be redirected automatically when it's ready for you.
+ {% endblock %} +