Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions Taskfile.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,23 @@ tasks:
vars:
CONFIG: "{{.CONFIG}}"

# Config generation

generate_tutorial_config:
desc: |
Generates tutorial-config.yml with all remote desktop profiles + the
sample Streamlit app profile. Requires app-hub-configurator installed
(pip install app-hub-configurator) and iga-streamlit-demo cloned
alongside this repository.
Usage: task generate_tutorial_config
cmds:
- dump-config
--profiles-dir ../iga-streamlit-demo/profile
--profiles sample_app_slug,remote_desktop,qgis_remote_desktop,panoply_remote_desktop,snap_remote_desktop
--groups group-a,group-b,group-c
--storage-class-rwo hostpath
--output tutorial-config.yml

# Python models

create_models:
Expand Down
13 changes: 8 additions & 5 deletions application_hub_context/app_hub_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -978,9 +978,7 @@ def initialise(self):
f"Create init container {init_container.name}"
)
try:
self.spawner.init_containers.extend(
[
{
init_container_spec = {
"name": init_container.name,
"image": init_container.image,
"command": init_container.command,
Expand All @@ -991,8 +989,13 @@ def initialise(self):
for volume_mount in init_container.volume_mounts
],
}
]
)
# Inherit the spawner UID so the init container can
# write to the workspace volume (owned by that UID).
if self.spawner.uid is not None:
init_container_spec["securityContext"] = {
"runAsUser": self.spawner.uid
}
self.spawner.init_containers.extend([init_container_spec])
except Exception as err:
self.spawner.log.error(f"Unexpected {err}, {type(err)}")
self.spawner.log.error(
Expand Down
268 changes: 268 additions & 0 deletions docs/tutorial.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
# ApplicationHub — Getting Started Tutorial

This tutorial walks you through the full ApplicationHub lifecycle on a local Kubernetes cluster:

1. [Prerequisites](#1-prerequisites)
2. [Generate the Hub configuration](#2-generate-the-hub-configuration)
3. [Deploy the ApplicationHub](#3-deploy-the-applicationhub)
4. [Log in and use the sample application](#4-log-in-and-use-the-sample-application)
5. [Build your own application](#5-build-your-own-application)
6. [Add your application to the Hub](#6-add-your-application-to-the-hub)
7. [Next steps](#7-next-steps)

---

## 1. Prerequisites

### Local Kubernetes cluster

You need a running Kubernetes cluster reachable from your machine. Any of the following work out of the box:

=== "kind"

```bash
kind create cluster --name apphub
kubectl config use-context kind-apphub
```

=== "minikube"

```bash
minikube start --driver=docker
kubectl config use-context minikube
```

=== "Docker Desktop"

Enable Kubernetes in **Settings → Kubernetes → Enable Kubernetes**.

### Tools

| Tool | Minimum version | Install |
| --------- | --------------- | -------------------------------- |
| `kubectl` | 1.28 | <https://kubernetes.io/docs/tasks/tools/> |
| `skaffold` | 2.17 | <https://skaffold.dev/docs/install/> |
| `helm` | 3.14 | <https://helm.sh/docs/intro/install/> |
| `docker` | 24 | <https://docs.docker.com/get-docker/> |

### Clone the repository

```bash
git clone https://github.com/EOEPCA/application-hub-context.git
cd application-hub-context
```

---

## 2. Generate the Hub configuration

`tutorial-config.yml` is not committed to the repository — it must be generated locally before deploying.

### Install the prerequisites

```bash
pip install app-hub-configurator # provides the dump-config command
```

You also need [`iga-streamlit-demo`](https://github.com/EOEPCA/iga-streamlit-demo) cloned **alongside** this repository — `dump-config` reads the profile definition from its `profile/` directory:

```
parent/
├── application-hub-context/ ← this repo
└── iga-streamlit-demo/ ← must be here
```

```bash
git clone https://github.com/EOEPCA/iga-streamlit-demo ../iga-streamlit-demo
```

!!! tip "Virtual environment"
If you cloned the repository, a `pyproject.toml` is available. Run `pip install -e .` inside a virtual environment to install all dev dependencies at once.

### Generate `tutorial-config.yml`

```bash
task generate_tutorial_config
```

This runs two commands in sequence:

1. **`dump-config`** — generates all profiles (including `iga-streamlit-demo`) using the profile definition from `../iga-streamlit-demo/profile/`.

The result is a `tutorial-config.yml` ready to be loaded by Skaffold.

!!! warning "Do not edit by hand"
Any manual change to `tutorial-config.yml` will be overwritten on the next `task generate_tutorial_config`. To modify the `iga-streamlit-demo` profile, edit `profile/iga_profiles.py` in the [`iga-streamlit-demo`](https://github.com/EOEPCA/iga-streamlit-demo) repository.

---

## 3. Deploy the ApplicationHub

The `tutorial` Skaffold profile deploys the hub using the pre-built stable image together with the [sample application](https://github.com/EOEPCA/iga-streamlit-demo) profile, so no local Docker build is required.

```bash
skaffold run -p tutorial
```

Skaffold will:

1. Add the `eoepca` Helm repository (`https://eoepca.github.io/helm-charts-dev/`) automatically.
2. Deploy the `application-hub` Helm chart (v4.0.2) into the `jupyter` namespace.
3. Apply the cluster role binding and initialisation job.
4. Forward port `8000` on your machine to the Hub proxy.

Wait until the hub pod is ready:

```bash
kubectl get pods -n jupyter -w
```

You should see a `hub-*` pod in `Running` state and a `proxy-*` pod in `Running` state.

### Verify

```bash
kubectl get pods -n jupyter
kubectl get configmap -n jupyter
```

---

## 4. Log in and use the sample application

Open <http://localhost:8000> in your browser.

Log in with the default test credentials:

| Field | Value |
| -------- | -------- |
| Username | `jovyan` |
| Password | `12345` |

On the profile selection page you will see **Sample Streamlit App**. Click **Start** to spawn it.

The app displays runtime information injected by the Hub (user name, service prefix, …) and links to the next steps of this tutorial.

!!! tip "User groups"
The initialisation job (`hub-content-init`) automatically creates `group-a`, `group-b`, and `group-c` and adds `jovyan`, `alice`, and `bob` to each. You can log in as any of these users.

---

## 5. Build your own application

### 5.1 Clone the sample app

```bash
git clone https://github.com/EOEPCA/iga-streamlit-demo.git
cd iga-streamlit-demo
```

The repository contains:

```
Dockerfile # Python 3.12 + Streamlit + jhsingle-native-proxy
app.py # Streamlit application — edit this
entrypoint.sh # Startup script (port forwarding via jhsingle-native-proxy)
```

### 5.2 Customise `app.py`

Replace the content of `app.py` with your own Streamlit application. For example:

```python
import streamlit as st

st.title("My EO Dashboard")
st.write("Hello from my custom ApplicationHub app!")
```

### 5.3 Add Python dependencies

Add any extra packages to the `Dockerfile`:

```dockerfile
RUN pip install --no-cache-dir \
"jhsingle-native-proxy>=0.0.9" \
streamlit \
folium \ # example: interactive maps
xarray # example: NetCDF / EO data
```

### 5.4 Build and test locally

```bash
docker build -t my-app:local .
docker run --rm -p 8888:8888 my-app:local
```

Open <http://localhost:8888> to verify your app.

### 5.5 Push to a registry

```bash
docker tag my-app:local ghcr.io/<your-org>/my-app:latest
docker push ghcr.io/<your-org>/my-app:latest
```

---

## 6. Add your application to the Hub

### 6.1 Write a profile

Copy `tutorial-config.yml` to `custom-config.yml` and add a new profile:

```yaml
profiles:

- id: sample_app # keep the existing sample profile
# … (unchanged)

- id: my_app # your new profile
groups:
- group-a
definition:
display_name: My EO Dashboard
description: My custom Earth Observation dashboard.
slug: my_app_slug
default: false
kubespawner_override:
image: ghcr.io/<your-org>/my-app:latest
cpu_limit: 1
cpu_guarantee: null
mem_limit: 2G
mem_guarantee: null
extra_resource_limits: {}
extra_resource_guarantees: {}
volumes:
- name: workspace-volume
claim_name: workspace-claim
size: 5Gi
storage_class: standard
access_modes:
- ReadWriteOnce
persist: false
volume_mount:
name: workspace-volume
mount_path: /home/jovyan
pod_env_vars:
HOME: /home/jovyan
```

For a full reference of all profile fields, see the [Configuration](configuration.md) page.

### 6.2 Validate the config (optional)

```bash
task check_schema CONFIG=custom-config.yml
```

### 6.3 Deploy with the custom profile

```bash
skaffold run -p custom
```

Skaffold patches the Hub's config map with your `custom-config.yml`. After the Hub pod restarts, your new profile will appear on the login page.


21 changes: 21 additions & 0 deletions files/hub/jupyterhub_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,27 @@ def post_stop_hook(spawner):
c.KubeSpawner.pre_spawn_hook = pre_spawn_hook
c.KubeSpawner.post_stop_hook = post_stop_hook


async def modify_pod_hook(spawner, pod):
"""Ensure init containers run with the same UID as the main container.

KubeSpawner applies `uid` only to the main container's securityContext.
Init containers created by app_hub_context inherit none, so they run as
the image's default user (UID 1000). When the workspace volume already
contains files written by UID 1001 (the spawner UID), those init
containers cannot chmod/write them — triggering a back-off restart.
"""
uid = getattr(spawner, "uid", None)
if uid is not None and pod.spec.init_containers:
from kubernetes_asyncio.client.models import V1SecurityContext
for container in pod.spec.init_containers:
if container.security_context is None:
container.security_context = V1SecurityContext(run_as_user=uid)
return pod


c.KubeSpawner.modify_pod_hook = modify_pod_hook

c.JupyterHub.template_paths = [
"/opt/jupyterhub/template",
"/usr/local/share/jupyterhub/templates",
Expand Down
4 changes: 4 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ markdown_extensions:
- admonition
- pymdownx.highlight
- pymdownx.superfences
- pymdownx.tabbed:
alternate_style: true
- attr_list

plugins:
Expand All @@ -28,3 +30,5 @@ nav:
- Configurator Reference: 'README.md'
- JupyterHub API: 'jupyterhub-api.md'
- Hands-on: 'hands-on.md'
- Tutorial: 'tutorial.md'
- Notebook Application: 'notebook-app.md'
Loading